LedgerBee Developer
  • Getting started
  • Conventions
  • Products
  • Configuration
  • API Reference
Subscriptions
Payment flowCard paymentsProducts & PricingProduct Entitlements
Billing documents
Accounting
WebhooksPartner-Hosted Checkout
Customer Portal
    Portal SSO
    Embedded Checkout
      OverviewPricing cards & snippetLifecycle eventsBind to a customerGated plansFulfillment & errors
Embedded Checkout

Bind to a customer

Anonymous checkout (default)

With no partner binding, an anonymous buyer who completes checkout receives a magic-link verification email; clicking it finalizes the subscription. The buyer is identified by the email they enter. Payment is taken on the provider's hosted window via a redirect at verify time, so your origin is never in PCI scope.

Authed checkout — bind to a customer you already know (optional)

If you have already authenticated the end-customer on your side, bind the embedded checkout directly to that customer in LedgerBee, skipping the anonymous magic-link step. The buyer subscribes in one flow.

Security model: never put a raw customer id on the page — that is an IDOR. Your backend mints a short-lived, single-use, opaque checkout-bind ref. The embed requests it on demand from a provider you register on your page, carries only that ref, and LedgerBee resolves the customer from it server-side.

1. Your backend mints a checkout-bind token

The LedgerBee public API base URL is https://api.ledgerbee.com/api/v1; the exact host is confirmed when your API key is issued. Authenticate with a public API key that carries the portal-provision and portal-sso-mint scopes, in a tenant that holds the CustomerPortal license (a 403 LICENSE.REQUIRED otherwise). Pass the key in the x-api-key request header, not Authorization: Bearer — the API reserves Bearer for OAuth access tokens and rejects a raw key sent that way. The call ensures the customer's portal user and membership exist and returns the bind token in one round trip.

Identify the customer by customerId (an existing customer) or a customer object that upserts by its customerNumber (reuse-or-create). Supply exactly one. Set mintCheckoutBindToken: true to get the bind token back. Optionally pass your own clientReferenceId (your order/cart id) — it rides the bind ref onto the resulting subscription and into every later subscription webhook so you can reconcile (see Fulfillment & reconciliation).

The top-level email is required and is the portal-user identity to grant access to: a credential-less portal user is created (or reused) for that email and given an active membership — it's the login the buyer later uses to manage the subscription. It is distinct from customer.email (the customer record's contact email); when you send a customer object and omit customer.email, this top-level email is used as the customer's contact email too.

When you create a customer (the customer object, not customerId), the payload is the same shape as POST /customers and rejects with raw validation errors if a required field is missing. The non-obvious required fields are vatZone (a VATZone enum — domestic, eu, abroad, or domestic_without_vat, all lowercase) and customerGroupId (a uuid — list your tenant's groups via GET /v1/customers/groups), alongside the expected customerType (BUSINESS or PRIVATE_PERSON), customerNumber, name, and countryCode. The create-customer payload is the source of truth for the full field list and defaults.

TerminalCode
curl -X POST https://api.ledgerbee.com/api/v1/portal-sso/provision \ -H "x-api-key: $LEDGERBEE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "550e8400-e29b-41d4-a716-446655440010", "email": "jane.doe@acme.example", "mintCheckoutBindToken": true, "clientReferenceId": "order_7f3a9c21" }'

The response returns the resolved customerId (plus customerCreated, portalUserId, membershipId, and role), and — when you set mintCheckoutBindToken — checkoutBindToken + checkoutBindExpiresAt. The call is idempotent — an existing customer is reused as-is, never mutated — so it is safe to make on every checkout; cache the returned customerId and pass it back as customerId on later calls. clientReferenceId is optional, server-to-server only (it never reaches the iframe), max 200 chars, [A-Za-z0-9_-].

The request/response schema is defined in the published LedgerBee Public API reference, not here. Every field — including the customer create payload (identical to POST /customers), its required and defaulted fields, and the error codes — lives on the POST /portal-sso/provision operation. That spec is the source of truth; this guide covers only how the call fits into the embed flow.

2. Register a fetchBindToken provider on your page

Rather than putting the ref on the iframe, register an async provider on a host-page global. embed.js retains the reference and invokes it on demand at checkout-start — when the buyer starts a checkout, not on page load. The iframe keeps only data-ledgerbee-pricing:

Code
<iframe src="https://<slug>.portal.ledgerbee.com/embed/<vanity>?lang=en&target=inline&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> fetchBindToken: async () => { const r = await fetch('/your-backend/bind-token', { method: 'POST' }); if (!r.ok) return null; // → anonymous checkout const { checkoutBindToken } = await r.json(); // your backend's provision proxy return checkoutBindToken; // a FRESH ref, per call }, }; </script>

Your /your-backend/bind-token endpoint is a thin proxy that calls POST /v1/portal-sso/provision with mintCheckoutBindToken: true (step 1) and returns the fresh checkoutBindToken. Mint a new ref per call; do not cache the ref. You may cache the resolved customerId and pass it back on subsequent calls, since the provision call is idempotent. Because embed.js invokes your provider at checkout-start (not page-load), the window.LedgerBee.embed assignment can sit before or after the async embed.js tag.

Token lifecycle (on demand — always fresh)

  1. Minted just-in-time when embed.js calls your fetchBindToken provider at checkout-start (the buy click or forced-checkout mount), not at page-load. Opaque, single-use, ~60-second TTL.
  2. Delivered into the iframe over an origin-pinned requestBindToken / bindToken postMessage handshake. The message carries a requestId so concurrent embeds can't cross-deliver. The ref never rides the iframe URL, referrer, or browser history.
  3. Redeemed atomically at POST /api/checkout/session (the embed iframe calls this internally — you never call it directly): peek tenant-check, signed-in-customer check, and single-use consume, binding the session to your customer in place. No intermediate handle, no claim step.
  4. Every checkout start mints a new ref. The mint-to-redeem window is seconds — the provider call and the session mint happen on the same checkout-start. A buyer who dwells on the checkout step can let a ref age past its 60s TTL, in which case the redeem degrades to anonymous; re-start checkout for a fresh ref.
  5. Expired, already used, or forged ref. The redeem treats it as a miss and the checkout proceeds anonymously (magic-link). No hard error.
  6. Wrong tenant or wrong intent. A ref minted for another tenant, or a login (SSO handoff) ref, is rejected.

A signed-in portal session takes precedence. If the buyer is already authenticated as a portal user and your provider hands back a ref for a different customer, the bind is rejected — don't hand an authenticated buyer a foreign-customer ref. Same-customer is harmless.

Guarantees

  • Authed bind works on every surface. In-frame binds directly; a breakout binds via the session-locator redirect (see the support matrix). The only requirement is a registered fetchBindToken provider.
  • The bind ref is single-use, tenant-scoped, and redeemed in place at session mint. The iframe clears it after the session is created, so a still-valid ref never lingers in client JS.
  • Card capture stays on the provider's hosted window. Your origin and LedgerBee's embed never see raw card data (PCI SAQ A).

What the buyer sees — bound vs anonymous Details step

A bound checkout pre-fills the Details step read-only from the customer you provisioned: name, email, VAT, and address come from the LedgerBee customer record. Only the start date stays editable. The bind subscribes that exact customer; to change those fields, correct the customer record operator-side.

An anonymous checkout shows the same Details step editable and empty. The buyer fills it in, and confirm stages a magic-link signup keyed on the email they enter.

A vouched checkout sits between the two: pre-filled, but editable apart from the email.

Do not diagnose the mode by looking at the form. Listen for checkoutIdentity, which reports what the session actually resolved to:

modeWhat happened
READ_ONLYBound to your existing customer.
EMAIL_LOCKEDVouched — the buyer fills their details in, email fixed.
NONEAnonymous — the buyer verifies by email.

A NONE after your provider returned a ref means the ref did not take: the provider returned null or wasn't registered, the ref expired between mint and checkout-start, it was for the wrong tenant or intent, or (for a vouch) the tenant has vouched checkout switched off.

Vouched checkout — skip verification without creating a customer first

Bound checkout requires a customer to exist before the buyer starts. That is the right shape when you already hold their billing details. When you don't — a buyer signed in to your platform whose company name, VAT number and address you have never asked for — provisioning first forces you to collect those details in your own UI just to have something to provision, and every abandoned checkout leaves a customer row with no subscription behind.

A vouched checkout inverts that. You vouch for the buyer's email; they fill their own billing details into the checkout with that email fixed; and the customer is created only when they confirm. Nothing exists until the purchase does.

BoundVouchedAnonymous
Customer exists before checkoutyesnono
Details steppre-filled, read-onlypre-filled, editable (email fixed)empty, editable
Email verificationskippedskippedmagic link
Abandoned checkout leavesa customer rownothingnothing
Your order id on webhooksyesyesno

Enable it under Settings → Portal → Embedding → Partner-vouched checkout. It is off by default: the API key's scope proves which integration is calling, and this proves the tenant chose to accept vouches at all, so a leaked key on its own cannot create customers.

1. Your backend mints a checkout-vouch token

Same authentication as a bind token — an x-api-key header carrying the checkout scope (portal-provision is also accepted, so existing integrations minted against the provisioning scope keep working). Neither requires portal-sso-mint: a vouch grants a checkout, never a portal session.

Code
POST /api/v1/portal-checkout/vouch x-api-key: <your key> content-type: application/json
Code
{ "email": "buyer@acme.example", "clientReferenceId": "cart_8837", "prefill": { "customerType": "BUSINESS", "companyName": "Acme A/S", "countryCode": "DK" } }
FieldMeaning
emailThe buyer you are vouching for — the address you have already authenticated on your side. Becomes their portal login, is fixed in the checkout form, and a confirm carrying any other address is refused. Required.
contactEmailWhere invoices and receipts go, when that differs from the login identity. Written to the customer’s default contact. Omit and billing follows email.
customerIdA customer you already hold the id for — typically one whose earlier subscription ended and who is now buying again. The purchase lands on that record instead of a second one. Must exist, be active, and belong to your tenant, or the mint returns 404. Does not make the checkout read-only: the buyer still sees only what you prefilled, and what they submit overwrites the record. Omit for a first-time buyer.
prefillStarting values for the Details step. A seed, not a constraint — see the table below.
allowIdentityChangeWhether the checkout shows a “Not you?” escape. It does not let the buyer type a different address — the vouch is dropped and the checkout restarts unvouched. Defaults to true; set false when your authentication makes a wrong identity impossible.
clientReferenceIdYour own order/cart id. Rides onto the created subscription, every later subscription webhook, AND the customer-created event — the only field that maps a brand-new customer back to the cart. Max 200 chars, [A-Za-z0-9_-].

prefill is a seed, not a constraint — whatever you already know, to save the buyer typing. They can change any of it except the email, and every field maps to one the Details step actually shows: a seed for a field the buyer cannot see would persist something they never agreed to.

FieldMeaning
customerTypeBUSINESS or PRIVATE_PERSON.
companyNameCompany name, for a BUSINESS buyer.
firstNameGiven name, for a PRIVATE_PERSON buyer.
lastNameFamily name, for a PRIVATE_PERSON buyer.
vatNumberVAT / company registration number.
addressStreet address.
postalCodePostal code.
cityCity.
countryCodeISO 3166-1 alpha-2, exactly two letters (DK, SE). Anything else is a 400.

The response:

Code
{ "checkoutVouchToken": "k7Fq2mZ8x1vN4pQ...", "expiresAt": "2026-08-07T12:01:00.000Z" }
FieldMeaning
checkoutVouchTokenOpaque, single-use, 60-second reference. Return it from your page’s fetchBindToken provider; the embedded checkout redeems it server-side.
expiresAtISO 8601 expiry of the reference above.

Mint it just-in-time and hand it to the page exactly like a bind ref: never a raw customer id, never a token in the URL.

2. Return it from your fetchBindToken provider

There is no second provider. The same window.LedgerBee.embed.fetchBindToken hook returns either kind of ref, and LedgerBee tells them apart server-side:

Code
<script> window.LedgerBee = window.LedgerBee || {}; window.LedgerBee.embed = { fetchBindToken: async () => { const res = await fetch('/api/ledgerbee/checkout-ref', { method: 'POST' }); const { ref } = await res.json(); return ref; }, }; </script>

3. Confirm it took effect

Listen for checkoutIdentity. A vouched session reports mode: 'EMAIL_LOCKED'.

A mode: 'NONE' means the vouch did not take — the ref expired, it was for another tenant, or vouched checkout is switched off for this tenant — and the buyer will be asked to verify by email instead. The checkout still works; you just don't get your order id on the webhooks. Nothing else on your page signals this, so hook the event if you rely on the reconciliation.

What the buyer sees

The Details step opens pre-filled from your prefill, with the email fixed and a "Not you?" link beside it. Every other field is theirs to complete, so the billing details you receive are the ones the buyer stands behind rather than ones your form guessed at.

"Not you?" drops the vouched identity for that checkout and restarts it unvouched. It is the escape for a shared device or a stale session on your side: we cannot re-vouch a buyer as somebody else, only stop claiming we know who they are. The drop is scoped to the checkout it was used on and lasts for the browser tab, so it never silently follows the buyer into a different purchase.

Nothing in the checkout tells the buyer where the address came from. They are on your page — the frame is invisible to them — so naming a "site you came from" would describe a boundary they never crossed.

Set allowIdentityChange: false on the mint to hide the escape entirely, when your authentication makes a wrong identity impossible. It defaults to true on purpose: a buyer who genuinely is not the person you named has no other route than abandoning the purchase, so the default is the one that cannot strand them. Whichever you choose is carried through a card-provider redirect, so a returning buyer sees the same thing they left.

One purchase, one customer

Confirm creates the customer, its portal user, and the membership in a single transaction, then subscribes. Two things follow that are worth knowing:

  • A vouched confirm creates a new customer unless you name one. It never guesses a reuse from a matching VAT number or address — merging is an operator decision made against the real records. To reuse a specific record, pass its id as customerId on the mint (see below).
  • Every legitimate refusal happens before anything is written — the plan is still purchasable, the terms are current, the card brand is accepted, the buyer's email matches the vouch. A checkout that cannot complete therefore leaves nothing behind, which is the property the whole flow exists for.

Returning buyers: reuse a customer you already know

Pass customerId on the mint when you already hold the id of the customer buying — typically one whose earlier subscription ended and who is now resubscribing. The confirm attaches the new subscription to that record instead of creating a second one, so a buyer who comes and goes stays a single customer in the tenant's books.

Code
{ "email": "buyer@acme.example", "customerId": "550e8400-e29b-41d4-a716-446655440010", "clientReferenceId": "cart_8837" }

The customer must exist, be active, and belong to your tenant. Anything else returns 404 CUSTOMER_NOT_FOUND from the mint, and the confirm re-checks before it writes.

Reuse rewrites billing details and bills the record, so the confirm also checks that the vouched buyer is entitled to it. It succeeds when they already hold an active portal membership on that customer, or when no portal user holds it yet. Two refusals, each with its own fix:

ErrorMeansFix
403 PORTAL_CHECKOUT_VOUCH_CUSTOMER_NOT_THEIRSThe customer belongs to other portal loginsOmit customerId. A colleague with their own billing details is a separate customer relationship, and the confirm creates them one.
403 PORTAL_CHECKOUT_VOUCH_CUSTOMER_ACCESS_REVOKEDThis buyer holds the customer, but their portal access is switched offThe tenant re-enables the member from the gated app. A vouch does not restore access somebody deliberately removed.

The id decides which record the purchase lands on, and nothing else. The Details step behaves as it does without it: the buyer sees your prefill, never the stored record, and what they submit is written over that record. The customer number is the one field the purchase leaves alone, because invoices already issued to this customer cite it.

To have the buyer confirm details you supply, read-only, mint a bind ref instead (step 1).

Defer the buy action to your page (signup-first funnel)

To show anonymous pricing cards on a marketing page and route the buy click to your own signup first — then drop the buyer into the bound checkout once the customer exists — use target=callback. The buy click fires checkoutInitiated and opens nothing, so your listener owns the action.

Code
<iframe src="https://<slug>.portal.ledgerbee.com/embed/<vanity>?target=callback" data-ledgerbee-pricing loading="lazy" style="width:100%;border:0"></iframe> <script src="https://<slug>.portal.ledgerbee.com/embed.js" async></script> <script> window.addEventListener('ledgerbee:embed:checkoutInitiated', (e) => { const { vanity, planItemId } = e.detail; // ids + vanity only — no PII sessionStorage.setItem('lb.resume', JSON.stringify({ vanity, planItemId })); window.location.href = '/signup?next=checkout'; // your own funnel }); </script>

After your signup completes and the customer exists in your tenant, send the buyer to the forced single-item checkout for the stored item, …/embed/checkout/<vanity>/<planItemId>, on a page that registers a fetchBindToken provider (step 2). The provider mints a ref against the just-created customer, the checkout binds, and Details pre-fills.

If your signup round-trips through email — a new tab or device loses sessionStorage — persist the chosen { vanity, planItemId } against your own order record server-side instead, and rebuild the resume URL once the customer is created.

Last modified on September 13, 2026
Lifecycle eventsGated plans
On this page
  • Anonymous checkout (default)
  • Authed checkout — bind to a customer you already know (optional)
    • 1. Your backend mints a checkout-bind token
    • 2. Register a fetchBindToken provider on your page
    • Token lifecycle (on demand — always fresh)
    • Guarantees
    • What the buyer sees — bound vs anonymous Details step
  • Vouched checkout — skip verification without creating a customer first
    • 1. Your backend mints a checkout-vouch token
    • 2. Return it from your fetchBindToken provider
    • 3. Confirm it took effect
    • What the buyer sees
    • One purchase, one customer
    • Returning buyers: reuse a customer you already know
  • Defer the buy action to your page (signup-first funnel)
JSON
JSON
JSON