# 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](#step-0--readiness) 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 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](#the-edit-after-card-loop)).

```json
{
  "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](#webhooks) — 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](/guides/webhooks), or poll the
subscription.

```json
{
  "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:

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

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

{/* @codegen webhook-topics-checkout */}

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

{/* @codegen-end webhook-topics-checkout */}

Payloads and the delivery envelope are in the
[webhooks guide](/guides/webhooks); 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.
