LedgerBee Developer
  • Getting started
  • Conventions
  • Products
  • Configuration
  • API Reference
Subscriptions
Payment flowCard paymentsProducts & PricingProduct Entitlements
Billing documents
Accounting
WebhooksPartner-Hosted Checkout
Customer Portal
Products

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:

FieldPrecondition
subscriptionLicenseYou hold the Subscription module — without it nothing is checkoutable.
publishedPlans / checkoutablePlanspublishedPlans 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.
generalTermsPublishedInformational, 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.
stripeConnectYour active Stripe Connect account backs the card rail; includes the settlement currency and the chargeableCurrencies set that passes the charge predicate.
embeddableOriginsThe 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 is false, do not offer a card step. A later STRIPE_CROSS_CURRENCY_NOT_ENABLED refusal 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 — false means 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
{ "planVersionId": "0195f9a2-…", "planItemId": "0195f9a2-…", "buyer": { "customerType": "BUSINESS", "name": "Acme ApS", "email": "buyer@acme.example", "customerGroupId": "0195f9a2-…", "country": "DK", "vatNumber": "DK12345678", "billingEmail": "billing@acme.example" }, "reference": "order-4711", "expiresInMinutes": 30 }
  • 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 every checkout.* 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) returns clientSecret, publishableKey and stripeAccountId. Mount Stripe's embedded Checkout on your page: loadStripe(publishableKey, { stripeAccount: stripeAccountId }) + <EmbeddedCheckout>. Embedded completes in-page, so a returnUrl is refused.
  • uiMode: "hosted" returns redirectUrl to checkout.stripe.com. returnUrl is required, and its origin must be on your embeddableOrigins allowlist (Settings → Portal → Embedding). Stripe sends the buyer back to your returnUrl unchanged 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
{ "sessionId": "9f2c…64 hex…", "paymentMethod": "CARD", "termsAccepted": true, "recurringConsentAccepted": true, "acceptedAt": "2026-08-12T14:03:07Z" }

The x-api-idempotency-key header is required. Generate a fresh key per checkout attempt (a UUID is fine) and reuse it on retries:

OutcomeMeaning
400 IDEMPOTENCY_KEY_REQUIREDNo key sent.
Replay (x-idempotent-replay: true)Same key retried — the full original response body, nothing new written.
409 IDEMPOTENT_REQUEST_IN_PROGRESSA concurrent request with the same key is still running — wait and retry.
422 IDEMPOTENCY_KEY_REUSEDThe 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:

  1. POST /v1/checkout/session/:id/card-release.
    • SAVE_ONLY — the captured card survives; keep its handoffToken.
    • RECAPTURE_REQUIRED — discard the card and open a fresh window later.
  2. Re-quote and mint a fresh session with the new inputs.
  3. 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:

TopicFires when
checkout.card_readyFired when the buyer's card lands on a checkout session's card window — confirm may proceed. The card-status poll remains authoritative.
checkout.card_failedFired when a checkout card window closes without a card (the provider window expired mid-capture) — open a fresh window to retry.
checkout.session_expiredFired 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.

Last modified on September 13, 2026
WebhooksPortal SSO
On this page
  • Step 0 — readiness
  • Step 1 — render pricing
  • Step 2 — quote
  • Step 3 — mint the session
  • Step 4 — the card window
  • Step 5 — confirm
  • The edit-after-card loop
  • Updating the card on a live subscription
  • Webhooks
  • Testing
JSON
JSON