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.
Code
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
customercreate payload (identical toPOST /customers), its required and defaulted fields, and the error codes — lives on thePOST /portal-sso/provisionoperation. 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
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)
- Minted just-in-time when
embed.jscalls yourfetchBindTokenprovider at checkout-start (the buy click or forced-checkout mount), not at page-load. Opaque, single-use, ~60-second TTL. - Delivered into the iframe over an origin-pinned
requestBindToken/bindTokenpostMessagehandshake. The message carries arequestIdso concurrent embeds can't cross-deliver. The ref never rides the iframe URL, referrer, or browser history. - 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. - 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.
- Expired, already used, or forged ref. The redeem treats it as a miss and the checkout proceeds anonymously (magic-link). No hard error.
- 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
fetchBindTokenprovider. - 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:
mode | What happened |
|---|---|
READ_ONLY | Bound to your existing customer. |
EMAIL_LOCKED | Vouched — the buyer fills their details in, email fixed. |
NONE | Anonymous — 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.
| Bound | Vouched | Anonymous | |
|---|---|---|---|
| Customer exists before checkout | yes | no | no |
| Details step | pre-filled, read-only | pre-filled, editable (email fixed) | empty, editable |
| Email verification | skipped | skipped | magic link |
| Abandoned checkout leaves | a customer row | nothing | nothing |
| Your order id on webhooks | yes | yes | no |
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
Code
| Field | Meaning |
|---|---|
email | The 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. |
contactEmail | Where invoices and receipts go, when that differs from the login identity. Written to the customer’s default contact. Omit and billing follows email. |
customerId | A 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. |
prefill | Starting values for the Details step. A seed, not a constraint — see the table below. |
allowIdentityChange | Whether 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. |
clientReferenceId | Your 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.
| Field | Meaning |
|---|---|
customerType | BUSINESS or PRIVATE_PERSON. |
companyName | Company name, for a BUSINESS buyer. |
firstName | Given name, for a PRIVATE_PERSON buyer. |
lastName | Family name, for a PRIVATE_PERSON buyer. |
vatNumber | VAT / company registration number. |
address | Street address. |
postalCode | Postal code. |
city | City. |
countryCode | ISO 3166-1 alpha-2, exactly two letters (DK, SE). Anything else is a 400. |
The response:
Code
| Field | Meaning |
|---|---|
checkoutVouchToken | Opaque, single-use, 60-second reference. Return it from your page’s fetchBindToken provider; the embedded checkout redeems it server-side. |
expiresAt | ISO 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
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
customerIdon 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
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:
| Error | Means | Fix |
|---|---|---|
403 PORTAL_CHECKOUT_VOUCH_CUSTOMER_NOT_THEIRS | The customer belongs to other portal logins | Omit 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_REVOKED | This buyer holds the customer, but their portal access is switched off | The 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
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.