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.
| Read | Endpoint |
|---|---|
| List all plans | GET /v1/portal/plans |
| One plan by id | GET /v1/portal/plans/:planId |
Code
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.)
Call
POST /v1/portal/plans/resolve
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.
Code
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:
Code
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:
| Read | Host | Auth | Who calls it | Returns |
|---|---|---|---|---|
GET /v1/portal/plans/:planId ("Read your full plan catalogue") | 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 |
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)
- Minted by
POST /v1/portal/plans/resolveafter 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. - Delivered into each gated iframe over an origin-pinned
requestCatalogToken/catalogTokenpostMessagehandshake (fired on load). Held for the iframe's lifetime; never on the URL, referrer, or history. - Redeemed on each by-id read as an
Authorization: Bearerheader (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). - 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.