LedgerBee Developer
  • Getting started
  • Conventions
  • Products
  • API Reference
Subscriptions
Products & Pricing
Billing documents
WebhooksMCP (AI agents)
Customer Portal
    Portal SSO
    Embedded Checkout
      OverviewPricing cards & snippetLifecycle eventsBind to a customerGated plansFulfillment & errors
Embedded Checkout

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.

ReadEndpoint
List all plansGET /v1/portal/plans
One plan by idGET /v1/portal/plans/:planId
TerminalCode
# 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.) 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.

TerminalCode
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:

Code
<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

SurfaceURL
Gated pricing cardhttps://<slug>.portal.ledgerbee.com/embed/by-id/<planId>
Gated forced checkouthttps://<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:

ReadHostAuthWho calls itReturns
GET /v1/portal/plans/:planId ("Read your full plan catalogue")public API (api.ledgerbee.com)x-api-key header (portal-catalog-read)your backendthe full native card, no visibility filter
the gated iframe's by-id readthe 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)

  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.

BuyerWhat the card shows
Holds a tier of this plan, customer has an emailView details + upgrade/downgrade; clicking logs into the portal
Has set a portal password or passkeySent to the tenant login screen with the deep-link preserved — own factors still gate access
Customer record has no emailCheckout 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.

Last modified on June 15, 2026
Bind to a customerFulfillment & errors
On this page
  • Read your full plan catalogue from your backend (optional)
  • Gated (authed) pricing cards — non-public plans
    • 1. Your backend resolves the buyer's catalogue
    • 2. Register a fetchCatalogToken provider on your page
    • Gated by-id surfaces
    • Catalog token lifecycle (reusable, read-only)
    • Returning buyers — view details, upgrade & downgrade