# Gated (non-public) plans

## Read your full plan catalogue from your backend (optional)

`resolve` (below) returns only the plans a *specific buyer* matches. To read
**all** your plans instead — to build a pricing page, pick which to embed, or
render your own cards — read the catalogue directly. These reads run NO per-buyer
rule engine, apply NO visibility rules, and mint NO token: they return every
ACTIVE plan that has a PUBLISHED version, as its canonical native card. (The
public embedding surfaces already handle what an anonymous visitor sees;
this is your own view of the full catalogue, scoped to the API key's tenant.)

Authenticate with a public API key carrying the `portal-catalog-read` scope,
passed in the `x-api-key` header. Optional `?language=en|da` (default `en`).
The tenant must hold the CustomerPortal license; without it these reads (and
`resolve` below) return `403` with `code: "LICENSE.REQUIRED"` — see
[Errors](/guides/errors).

| Read | Endpoint |
|---|---|
| **List all plans** | [`GET /v1/portal/plans`](/api/portal-plans#list-the-full-plan-catalogue) |
| **One plan by id** | [`GET /v1/portal/plans/:planId`](/api/portal-plans#get-one-plan-by-id) |

```bash
# Every published plan, ordered by display order
curl https://api.ledgerbee.com/api/v1/portal/plans?language=en \
  -H "x-api-key: $LEDGERBEE_API_KEY"

# One plan by its id
curl https://api.ledgerbee.com/api/v1/portal/plans/550e8400-e29b-41d4-a716-446655440100 \
  -H "x-api-key: $LEDGERBEE_API_KEY"
```

The list returns `{ plans }`; the by-id read returns `{ plan }`, each carrying
the full card content (the same `PortalCatalogEntry` shape the resolve response
returns). A by-id read returns `404 PORTAL_PLAN_NOT_FOUND` only when the id is
unknown to your tenant or has no published version.

Each entry carries **`plan.vanity`** — the plan's public slug, the same `<vanity>`
you put in an `/embed/<vanity>` URL. It is non-null only for **public** plans (one
with a reserved vanity URL); a gated plan, or a public plan with no slug yet, has
`plan.vanity: null` and no public `/embed/<vanity>` surface (embed it via the
gated by-id surface below). So you can build the embed URL straight from the
catalogue read instead of copying the slug out of the operator UI. The
`resolve` response (gated catalogue, next section) carries `plan.vanity` too —
`null` for any plan that isn't public.

Two fields named `displayOrder` live at different scopes in the entry, don't
conflate them: the **entry-level** `displayOrder` orders plans within the
catalogue (the list is already returned in that order), while each
**`plan.items[].displayOrder`** orders the item-cards *within* a plan. The card
body itself is nested under `plan` (so a single read is `{ plan: { displayOrder,
isLegacyForVisitor, plan: { …card… } } }`) — the outer `plan` is the entry
wrapper, the inner `plan` is the card.

## Gated (authed) pricing cards — non-public plans

The public surfaces render a plan by its public `<vanity>` slug and require the
plan to be publicly reachable — i.e. **public**: published, with a vanity URL and
an allow-all routing rule resolving to anonymous visitors. To embed a
**non-public** plan for a buyer you've already identified — a plan with no vanity
URL, or no allow-all rule — use the gated by-id surfaces. They resolve a plan by
its id, authorized by a load-time **catalog token** your backend mints, and never
publish the plan or place it on a CDN.

This is the display analog of the authed checkout-bind: that binds the *write*
(the subscribe); this authorizes the *read* (which cards a buyer may see). A
gated card's buy CTA runs the bound checkout **in-frame** (it can't break out — a
non-public plan has no standalone checkout to break out to).

### 1. Your backend resolves the buyer's catalogue

Authenticate with a public API key carrying the `portal-catalog-read` scope —
a normal read scope you grant yourself on the API-key / OAuth page. (Binding the
checkout for the same buyer uses the separate partner SSO key carrying
`portal-provision` + `portal-sso-mint`; see
[Bind to a customer](/guides/embedded-checkout/customer-binding#authed-checkout--bind-to-a-customer-you-already-know-optional).)
Call
[`POST /v1/portal/plans/resolve`](/api/portal-plans#resolve-the-gated-pricing-catalogue-for-an-identified-buyer-partner-display)
with the buyer's identity handle — their `customerId` (the server hydrates the
rule attributes from it), or the no-customer-yet `countryCode` / `customerType`
hints. It runs the real plan-routing rule engine for that buyer and returns the
plans they may see and check out (gated plans included) plus a reusable,
read-only catalog token. With a `customerId` you may also pass `buyerEmail` — the
specific person you are acting for under that customer; the returning-buyer login
(below) signs that person in, rather than the customer's contact email.

```bash
curl -X POST https://api.ledgerbee.com/api/v1/portal/plans/resolve \
  -H "x-api-key: $LEDGERBEE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "customerId": "550e8400-e29b-41d4-a716-446655440010", "language": "en" }'
```

The response returns `plans` (each carrying the card content) plus `catalogToken`
and `expiresAt`. Never accept a raw customer group / customer id as a rule
attribute on the wire — the server resolves identity from the handle only; that's
deliberate (no forged-visitor oracle).

### 2. Register a `fetchCatalogToken` provider on your page

Parallel to `fetchBindToken`, but for display and fired **on iframe load** (not
at the buy click). `embed.js` invokes it once per gated embed and presents the
returned token as a Bearer to the by-id read. Cache the resolve result on your
backend and return the same token across the page's cards:

```html
<iframe
  src="https://<slug>.portal.ledgerbee.com/embed/by-id/<planId>?lang=en&theme=system"
  data-ledgerbee-pricing
  loading="lazy"
  style="width:100%;border:0;min-height:520px"
  title="Pricing"></iframe>
<script src="https://<slug>.portal.ledgerbee.com/embed.js" async></script>
<script>
  window.LedgerBee = window.LedgerBee || {};
  window.LedgerBee.embed = {
    // () => Promise<string | null | undefined> — the catalog token from step 1.
    fetchCatalogToken: async () => {
      const r = await fetch('/your-backend/catalog-token', { method: 'POST' });
      if (!r.ok) return null;
      const { catalogToken } = await r.json();
      return catalogToken;
    },
    // Add fetchBindToken too if the gated card's buy CTA should bind (see Bind to a customer).
    fetchBindToken: async () => {
      const r = await fetch('/your-backend/bind-token', { method: 'POST' });
      if (!r.ok) return null;
      return (await r.json()).checkoutBindToken;
    },
  };
</script>
```

### Gated by-id surfaces

| Surface | URL |
|---|---|
| **Gated pricing card** | `https://<slug>.portal.ledgerbee.com/embed/by-id/<planId>` |
| **Gated forced checkout** | `https://<slug>.portal.ledgerbee.com/embed/checkout/by-id/<planId>/<planItemId>` |

`<planId>` is the plan's id (from the resolve response or the operator's gated
snippet). The gated forced checkout needs "Allow in-frame checkout" on.

**Two different "by-id" reads — don't mix their auth.** There are two endpoints
that read a plan by id, on two different hosts with two different credentials:

<div className="lb-fixed-table">

| Read | Host | Auth | Who calls it | Returns |
|---|---|---|---|---|
| `GET /v1/portal/plans/:planId` (["Read your full plan catalogue"](#read-your-full-plan-catalogue-from-your-backend-optional)) | public API (`api.ledgerbee.com`) | **`x-api-key`** header (`portal-catalog-read`) | your backend | the full native card, no visibility filter |
| the gated iframe's by-id read | the tenant portal origin (`<slug>.portal.ledgerbee.com`) | **`Authorization: Bearer <catalogToken>`** | **`embed.js`, internally** (via the `fetchCatalogToken` handshake) | the buyer-visible card |

</div>

The catalog token authorizes only the **iframe's** read — `embed.js` redeems it
for you over the postMessage handshake; you never call that endpoint directly.
The catalog token is **not** a credential for the public-API
`GET /v1/portal/plans/:planId` read: that endpoint is `x-api-key`-only, so
sending the catalog token as `Authorization: Bearer` there returns **401**. Use
the `x-api-key` header for the backend read, and hand the catalog token to
`embed.js` via `fetchCatalogToken` for the gated cards — never cross them.

### Catalog token lifecycle (reusable, read-only)

1. **Minted** by `POST /v1/portal/plans/resolve` after your backend authenticates
   the buyer. Opaque, 43-char, read-only, **reusable** (unlike the single-use bind
   ref), ~30-minute TTL. Scoped to the resolved buyer + the returned plans.
2. **Delivered** into each gated iframe over an origin-pinned `requestCatalogToken`
   / `catalogToken` `postMessage` handshake (fired on load). Held for the iframe's
   lifetime; never on the URL, referrer, or history.
3. **Redeemed** on each by-id read as an `Authorization: Bearer` header
   (non-credentialed — the buyer has no portal cookie). The read re-asserts the
   tenant, rejects a plan outside the token's allow-set, and re-resolves visibility
   **live** (so a card hidden after mint stops rendering).
4. **Expired / forged token** → the by-id read returns `401`; re-request a fresh
   token from your host (re-run resolve). A plan that was allowed at mint but has
   since become unavailable → `410`, distinct from a bad token.

Treat the catalog token as a client secret: TLS-only, never logged, never on a URL.

### Returning buyers — view details, upgrade & downgrade

When the catalog token identifies a specific customer (minted with a `customerId`
in the resolve call above), the gated card resolves that buyer's subscription
status and renders the affordances the logged-in portal shows. The card reuses
the catalog token it already holds, so there is nothing extra to wire. The person
signed in on click is the resolve call's `buyerEmail` when you passed one, else
the customer's contact email.

- A tier the buyer already holds shows **View details** in place of the buy CTA,
  marked "Current plan".
- The other tiers of that plan show **Upgrade to …** / **Downgrade to …**.
  Direction follows the plan's tier order, not price.
- Tiers in a plan the buyer holds nothing in keep the normal checkout CTA.

Clicking any of these logs the buyer into the tenant portal through a one-time
handoff and navigates the top window — the buyer leaves the embed and lands in
the portal. View details opens the subscription's detail page; upgrade and
downgrade open it with the change-plan modal preselected on the clicked tier. The
handoff sets the portal session cookie, which only the portal origin can set, so
it is a full-page navigation rather than an in-frame step.

| Buyer | What the card shows |
| --- | --- |
| Holds a tier of this plan, customer has an email | View details + upgrade/downgrade; clicking logs into the portal |
| Has set a portal password or passkey | Sent to the tenant login screen with the deep-link preserved — own factors still gate access |
| Customer record has no email | Checkout CTA (no portal login to bootstrap) |
| Anonymous catalog token (`countryCode` / `customerType` hints, no `customerId`) | Checkout-only; no buyer is identified |

The status read and the handoff exchange both present the catalog token as
`Authorization: Bearer`; the redirect URL carries only an opaque single-use
reference plus the same-origin deep-link path — never the catalog token.
