Partner-Hosted Checkout
Sell your subscription plans entirely inside your own product: your pages
render the pricing, your backend drives the checkout, and LedgerBee handles the
subscription, the invoicing, the card storage and the recurring billing. You
need no hosted portal and no portal license — access is an API key with the
checkout scope, minted under Settings → Portal → Checkout API. The
checkout calls themselves are not license-gated, but everything they sell is:
plans are built from the Subscription module, so without that license there is
nothing checkoutable. The readiness endpoint reports it
alongside the other go-live checks.
Every call on this surface is server-to-server, and buyer identity is plain request data: you tell us who is buying, because you already know. The one place a buyer meets anything LedgerBee-adjacent is the Stripe card form — mounted on your page, or on checkout.stripe.com in hosted mode.
The buyer journey is quote → session → card window → confirm on the card
rail, and quote → session → confirm on the invoice rail. Confirm is
synchronous — the subscription id is in the response; webhooks are async
signals, never fulfillment.
Step 0 — readiness
Make GET /v1/checkout/readiness the first call of your integration. It
returns one object with a machine-readable state per precondition, so you
never discover a misconfiguration error-by-error:
| Field | Precondition |
|---|---|
subscriptionLicense | You hold the Subscription module — without it nothing is checkoutable. |
publishedPlans / checkoutablePlans | publishedPlans counts your published plans; checkoutablePlans counts those with a sellable item — a gap between the two means a published plan's cards were all retired. You need at least one checkoutable plan. |
generalTermsPublished | Informational, never a gate: whether you host terms in LedgerBee. When true, quote and session responses carry the terms bodies to render and confirm records per-version acceptance evidence; when false, you host your own terms and confirm records your acceptance assertion. |
stripeConnect | Your active Stripe Connect account backs the card rail; includes the settlement currency and the chargeableCurrencies set that passes the charge predicate. |
embeddableOrigins | The origin allowlist hosted card windows validate your returnUrl against. |
Customer groups are per-request data rather than a readiness item: discover
them with GET /v1/customers/groups and pass the chosen customerGroupId on
every session.
Step 1 — render pricing
Read the catalogue with GET /v1/portal/plans, or resolve a specific buyer's
view with POST /v1/portal/plans/resolve. The catalogue endpoints require the
portal-catalog-read scope — mint the API key with both checkout and
portal-catalog-read so one key drives the whole flow. The portal/ prefix
there is the catalogue namespace — the payload is the same card tree the
hosted portal renders, so anything the portal can display, your page can too. Pass the buyer
hints (country, customerType, vatNumber) to get exact per-item VAT, and
currency to present an alternate currency from each item's
availableCurrencies.
Consumer-facing prices must be displayed including VAT: pass the hints and render the gross figures.
Step 2 — quote
POST /v1/checkout/quote prices the first period exactly: display lines,
per-rate VAT breakdown, total, the terms bodies, start-date rules, and two
booleans your UI branches on:
cardPaymentsEnabled— chargeability-aware: it already folds in whether your Stripe account can charge the resolved currency. When it isfalse, do not offer a card step. A laterSTRIPE_CROSS_CURRENCY_NOT_ENABLEDrefusal is a configuration issue on your Stripe account (enable cross-currency charges, or add a bank account in that currency on Stripe) — never a buyer error, so don't surface it as one.firstPeriodChargeable—falsemeans a zero first period (free trial or 100% discount). Adapt your copy ("no charge today"); the card window still opens save-only.
country is required so VAT resolves exactly. A vatNumber is accepted for
BUSINESS buyers only and is checked against the country's published format;
registry (VIES) verification is not performed.
The terms array carries your LedgerBee-hosted terms when they exist —
render them. When you host none in LedgerBee, the array is empty and your own
terms are the ones the buyer accepts; the confirm's termsAccepted assertion
is recorded either way.
Step 3 — mint the session
POST /v1/checkout/session creates the single-use session the rest of the
flow runs against, and pins what your buyer was shown: the plan version, the
terms and disclosure versions, the resolved pricing, currency, quantities and
start date. Confirm re-runs the billing engine against those pinned inputs and
refuses a drifted amount — the buyer is never billed a number the engine would
not produce. Republishing a plan never silently re-prices an in-flight
session: new sessions pin the new version, and a session pinned to the
superseded version answers 410 at confirm — re-mint and re-present (a captured
card carries over, see the edit-after-card loop).
Code
buyer.customerGroupId— required. The created customer is classified into this group (it drives your receivables account and pricing rules).buyer.email— the buyer's identity: consent evidence is recorded against it, and the self-service manage link is emailed to it.buyer.billingEmail— optional; invoices, receipts and payment notices go here when it differs from the identity email.buyer.existingCustomerId— optional; a returning buyer keeps one customer record instead of accumulating duplicates. Reuse is authorized at confirm: the buyer's email must already hold that customer, or nobody may.reference— your order/cart id. It is echoed on session reads, the confirm response and everycheckout.*webhook, and lands on the subscription as its partner reference — your join key.expiresInMinutes— session TTL, 5–1440, default 30. An expired session answers 410: re-mint and continue.
The response echoes the full quote plus the pinned block — render exactly
that. pinned.disclosureText is the server-rendered recurring-billing
disclosure for the pinned version; show it (or equivalent wording of your own)
before the buyer accepts recurring billing on the card rail.
GET /v1/checkout/session/:id rehydrates a live session after a reload on
your side.
Step 4 — the card window
POST /v1/checkout/session/:id/card-window opens the only surface a card is
ever entered on — Stripe's — so your systems never touch card data. Your
active Stripe Connect account is the rail; there is no provider selection.
Two UI modes, one flow:
uiMode: "embedded"(default) returnsclientSecret,publishableKeyandstripeAccountId. Mount Stripe's embedded Checkout on your page:loadStripe(publishableKey, { stripeAccount: stripeAccountId })+<EmbeddedCheckout>. Embedded completes in-page, so areturnUrlis refused.uiMode: "hosted"returnsredirectUrlto checkout.stripe.com.returnUrlis required, and its origin must be on yourembeddableOriginsallowlist (Settings → Portal → Embedding). Stripe sends the buyer back to yourreturnUrlunchanged on completion; the return is purely navigational — state always comes from the poll.
The card lands asynchronously. Poll
GET /v1/checkout/session/:id/card-status until CARD_READY, or subscribe to
the card-ready webhook from the checkout topic group — the poll
stays authoritative either way. Poll at a gentle cadence — every second or two
while the buyer is in the card window — and stop once the session's expiresAt
passes; the webhook carries the same signal without any polling. CARD_READY
includes the captured card's display block; show the buyer which card will be
charged. EXPIRED (or the card-failed webhook) means the window lapsed — open
a fresh one.
A zero first period (firstPeriodChargeable: false) opens the window
save-only: the card is stored for the first real charge and nothing is
authorized.
Step 5 — confirm
POST /v1/checkout/confirm consumes the session and writes everything,
transactionally: the customer, the consent evidence, the subscription and the
first charge. The subscription id is in the response; billingPending: true
means the first invoice is produced asynchronously right after — listen for
the billing webhooks in the webhooks guide, or poll the
subscription.
Code
The x-api-idempotency-key header is required. Generate a fresh key per
checkout attempt (a UUID is fine) and reuse it on retries:
| Outcome | Meaning |
|---|---|
400 IDEMPOTENCY_KEY_REQUIRED | No key sent. |
Replay (x-idempotent-replay: true) | Same key retried — the full original response body, nothing new written. |
409 IDEMPOTENT_REQUEST_IN_PROGRESS | A concurrent request with the same key is still running — wait and retry. |
422 IDEMPOTENCY_KEY_REUSED | The key was already used for a different session — use a fresh key. |
Consent is your assertion: you presented the terms and disclosure in your UI,
and termsAccepted: true (plus recurringConsentAccepted: true on the card
rail) with acceptedAt is what LedgerBee records, with partner-asserted
provenance. When you host terms in LedgerBee the session pins their exact
versions and acceptance is recorded per version; when you host none, the
terms are your own and LedgerBee records the assertion itself. The assertion
is required on every confirm either way. A false or missing assertion
refuses with PORTAL_CHECKOUT_CONSENT_REQUIRED before the session is
consumed, so the same session can be retried.
On the invoice rail, one caveat when you serve no hosted portal: invoice emails carry no online pay-now link — payment instructions ride the invoice document itself (bank transfer / FIK).
The edit-after-card loop
When the buyer changes quantities, start date or VAT inputs after the card
step, the pinned amount no longer matches and confirm refuses with
PORTAL_CHECKOUT_AUTHORIZED_AMOUNT_CHANGED. Recover without making the buyer
re-enter their card:
POST /v1/checkout/session/:id/card-release.SAVE_ONLY— the captured card survives; keep itshandoffToken.RECAPTURE_REQUIRED— discard the card and open a fresh window later.
- Re-quote and mint a fresh session with the new inputs.
- Confirm the fresh session, passing the surviving token as
cardHandoffToken.
The handoffToken from the card-window response outlives the session — the
same carry works when a session expires after the card was captured.
A 410 on confirm means the session expired, or you republished the plan or terms mid-session (the pin names exact versions) — re-mint and re-render.
Updating the card on a live subscription
POST /v1/checkout/subscriptions/:id/card-window opens the same
embedded/hosted window against an existing subscription — your UI's path for
"update payment method". The window renders to the buyer in your page;
when the new card lands, it replaces the card on file for that subscription.
The buyer's emailed manage link keeps working unchanged as the self-service
floor.
Webhooks
Subscribe an endpoint to the checkout topic group for the async signals.
Every payload carries your clientReferenceId:
| Topic | Fires when |
|---|---|
checkout.card_ready | Fired when the buyer's card lands on a checkout session's card window — confirm may proceed. The card-status poll remains authoritative. |
checkout.card_failed | Fired when a checkout card window closes without a card (the provider window expired mid-capture) — open a fresh window to retry. |
checkout.session_expired | Fired when a checkout session expires unconsumed — the abandoned-checkout signal. |
Payloads and the delivery envelope are in the webhooks guide; the customer, subscription and billing lifecycle events fire for subscriptions created on this surface exactly as for any other.
Testing
Integrate against the demo environment — it runs the full stack including Stripe test mode, so card windows, webhooks and billing runs behave as in production. Your LedgerBee contact provisions the demo company; mint its API key the same way and point your integration at the demo host.