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

Partner-Hosted Checkout

Sell your subscription plans inside your own product. Your pages render the pricing and your backend drives the checkout; LedgerBee creates the subscription, issues the invoices, stores the card and runs the recurring billing. The checkout requires an API key with the checkout scope, minted under Settings → Portal → Checkout API, and no hosted portal or portal license. Plans are built from the Subscription module, so you need that license to have anything to sell. The readiness endpoint reports it alongside the other go-live checks.

Every call is server-to-server. Buyer identity is request data that your backend supplies. The buyer sees one LedgerBee-provided surface: the Stripe card form, mounted on your page or, in hosted mode, on checkout.stripe.com.

The buyer journey is quote → session → card window → confirm on the card rail, and quote → session → confirm on the invoice rail. Confirm is synchronous and returns the subscription id. Treat webhooks as async signals, not as the fulfillment trigger.

Two flows create a subscription billed to the buyer's card:

FlowCallsUse when
CheckoutPOST /v1/checkout/quote → POST /v1/checkout/session → POST /v1/checkout/session/:id/card-window → POST /v1/checkout/confirmThe buyer picks and buys a plan in your UI. Steps 0–5 below.
Subscription first, card afterPOST /v1/subscriptions with "allowedPaymentProviders": ["STRIPE"] → POST /v1/checkout/subscriptions/:id/card-windowYour backend decides the subscription (its plan, price or start date), and the buyer only adds the card. See Adding or updating the card on a live subscription.

Both flows require the checkout scope. The second also requires the subscriptions-write scope for POST /v1/subscriptions. In both, the buyer enters the card only in Stripe's window.

Step 0 — readiness

Call GET /v1/checkout/readiness first. It returns one object with a machine-readable state per precondition:

FieldPrecondition
subscriptionLicenseYou hold the Subscription module license.
publishedPlans / checkoutablePlanspublishedPlans counts your published plans; checkoutablePlans counts those with at least one sellable item. A published plan whose items are all retired is not checkoutable. 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 chargeableCurrencies, the currencies your Stripe account can charge.
embeddableOriginsThe origin allowlist hosted card windows validate your returnUrl against.

Customer groups are per-request data. List 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. To use an existing key, edit it under Integrations → API keys and turn on the checkout scope. Also turn on portal-sso-mint and portal-provision if the same key drives portal SSO. The portal/ prefix 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.

Each plan item lists the add-on plans a buyer may take with it under addons; see Add-ons.

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 the fields your UI branches on:

  • allowedPaymentMethods — the payment methods the plan accepts, from CARD and INVOICE. Offer the buyer only the methods in the list, and offer CARD only when cardPaymentsEnabled is also true. null means both are accepted. A plan whose regular price at the buyer's quantities, before any discount code, is below the smallest amount a card can be charged lists INVOICE only. A code that takes only the first period below that amount leaves CARD in the list; see firstPeriodChargeable below. A confirm whose paymentMethod is not in the list returns 400 PORTAL_CHECKOUT_PLAN_NO_LONGER_AVAILABLE with details.reason: "PAYMENT_METHOD_NOT_ALLOWED" and the list in details.allowed. A CARD confirm for a plan below the card minimum returns the same code with details.reason: "CARD_AMOUNT_BELOW_MINIMUM" instead.
  • cardPaymentsEnabled — includes 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, not a buyer error; do not show it to the buyer. Fix it by enabling cross-currency charges, or by adding a bank account in that currency on Stripe.
  • firstPeriodChargeable — false means a zero first period (free trial or 100% discount), a discount code that takes the first period below what a card can be charged, or a session that bills in arrears. 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, not against the VIES registry.

The terms array carries your LedgerBee-hosted terms. Render them. When you host no terms in LedgerBee, the array is empty and the buyer accepts your own terms. The confirm's termsAccepted assertion is recorded in both cases.

Pass addons to price add-ons into the first period alongside the plan.

Pass billingDirection to choose when the buyer pays for each period. Without it, the default billing direction in your subscription settings applies. The response states the direction it resolved and the day the first invoice is issued:

billingDirectionFirst invoice (firstInvoiceDate)
advanceOn the start date, less the send offset in your subscription settings, and not before today. A checkout starting today is invoiced at confirm.
arrearsWhen the first period ends. Nothing is invoiced at confirm.

pricing is the first period in both cases; with arrears that is the amount the first invoice bills when the period ends. A card checkout in arrears charges nothing at confirm: the card window opens save-only, the subscription is active from the confirm, and the first invoice is charged to the saved card on firstInvoiceDate. Tell the buyer so, for example "Nothing is charged now; your card is charged on your first invoice on 14 November 2026". A value other than advance or arrears is refused with 400 SHARED_VALIDATION_FAILED.

Step 3 — mint the session

POST /v1/checkout/session creates the single-use session the rest of the flow runs against. It pins what your buyer was shown: the plan version, the terms and disclosure versions, the resolved pricing, currency, quantities, add-ons and start date. Confirm re-runs the billing engine against the pinned inputs and refuses a drifted amount, so the buyer is never billed a number the engine would not produce. Republishing a plan does not re-price an in-flight session; new sessions pin the new version. What a 410 at confirm means, and how a captured card carries over, is in 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. Confirm accepts the reuse only when the customer is already linked to the buyer's email or is linked to no email. A discount code is judged for this customer, so a code made for them applies; see Discount codes.
  • reference — your order or cart id, used as your join key. It is echoed on session reads, the confirm response and every checkout.* webhook, and is stored on the subscription as its partner reference.
  • billingDirection — optional, advance or arrears; the default billing direction in your subscription settings when absent. The session pins the resolved direction in pinned.billingDirection with pinned.firstInvoiceDate, and confirm creates the subscription in it, even if the default changes in between. Confirm takes no direction of its own.
  • expiresInMinutes — session TTL, 5–1440, default 30.

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. On the card rail, show it, or equivalent wording of your own, before the buyer accepts recurring billing.

Since recurring-billing-disclosure@2 the agreement also states how small invoices are handled, and your wording must include it: an invoice below the seller's minimum invoice amount, or below the smallest amount a card can be charged, is added to the buyer's next invoice for at most 12 months, then sent for payment by bank transfer if it still cannot be charged.

Since recurring-billing-disclosure@4 the amount in pinned.disclosureText is written in the number format of the session's language: DKK 1.234,50 for a Danish session, DKK 1,234.50 for an English one. Earlier versions write DKK 1234.50 in every language.

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 Stripe's card form, the only place a card is entered, so your systems never handle card data. The card rail is your active Stripe Connect account; there is no provider selection. The window has two UI modes:

  • 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). On completion, Stripe sends the buyer back to your returnUrl unchanged. Read the card's state from the poll, not from the return.

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, which carries the same signal without polling. The poll is authoritative. Poll every second or two while the buyer is in the card window, and stop once the session's expiresAt passes. 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, or a session that bills in arrears (firstPeriodChargeable: false in both cases), opens the window save-only: the card is stored for the first real charge and nothing is authorized. In arrears that charge is the first invoice, on firstInvoiceDate.

Step 5 — confirm

POST /v1/checkout/confirm consumes the session and writes the customer, the consent evidence, the subscription and the first charge in one transaction. The response carries the subscription id. 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, such as a UUID, per checkout attempt and reuse it on retries:

OutcomeMeaning
400 IDEMPOTENCY_KEY_REQUIREDNo key sent.
Replay (x-idempotent-replay: true)Same key retried. Returns the full original response body and writes nothing new.
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 that you presented the terms and disclosure in your UI. LedgerBee records termsAccepted: true, plus recurringConsentAccepted: true on the card rail, with acceptedAt and 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, LedgerBee records the assertion against your own terms. Every confirm requires the assertion. A false or missing assertion refuses with PORTAL_CHECKOUT_CONSENT_REQUIRED before the session is consumed, so you can retry the same session.

On the invoice rail, the subscription allows no card provider, so its invoice emails carry no online pay-now link. The invoice document carries the payment instructions (bank transfer / FIK). See Taking payment.

Discount codes

Pass the code the buyer typed as discountCode on the quote and on the session mint. Codes are matched without regard to case or surrounding spaces.

Code
{ "planVersionId": "<planVersionId>", "planItemId": "<planItemId>", "country": "DK", "customerType": "BUSINESS", "discountCode": "WELCOME10" }

The quote prices the code onto the first period and answers it in quote.discount:

FieldMeaning
codeThe code as your company wrote it.
nameThe discount's name.
typepercentage, fixed (an amount per period) or per_unit.
firstPeriodAmountOffWhat the code takes off the first period, before VAT. quote.plan.items keep the regular price; quote.pricing is net of this amount.
endsAtYYYY-MM-DD. A billing period that starts on or after this day is billed at the regular price. Null when the discount has no end.
durationInPeriodsHow many billing periods the discount lasts; null when it has no end.
firstPeriodChargedToCardfalse when the discounted first period is too small to charge to a card while the regular price is not.

The session pins the code (pinned.discountCode). The card window and confirm validate the code again, and subscription creation re-checks its limits. If another buyer has taken the code's last use, the request is refused, nothing is created, and any hold taken for the session is released.

Which customer a code is judged for

A code can be made for one customer, and a discount can be assigned to one customer (see Discounts). Which customer checkout judges the code for depends on the call:

CallCustomer the code is judged for
POST /v1/checkout/quoteNone: the buyer is a guest. A code made for one customer, or whose discount is assigned only to named customers, is refused.
POST /v1/checkout/session with buyer.existingCustomerIdThat customer, before it is proven to be the buyer's.
POST /v1/checkout/session without buyer.existingCustomerIdNone.
POST /v1/checkout/session/:id/card-windowThe customer the session names.
POST /v1/checkout/confirmThe customer the session names, once confirm has proven the buyer may use it. That customer's own uses count against a per-customer limit.

For a returning customer's own code, mint the session with buyer.existingCustomerId and skip the quote, or quote without the code. If the buyer turns out not to be that customer, confirm refuses with 403 PORTAL_CHECKOUT_VOUCH_CUSTOMER_NOT_THEIRS (or 403 PORTAL_CHECKOUT_VOUCH_CUSTOMER_ACCESS_REVOKED when the buyer's own access to that customer is switched off). Nothing is created and the card hold is released. The captured card is kept: pass its handoffToken as cardHandoffToken on a fresh session, as in the edit-after-card loop. A code refused at confirm releases the hold the same way.

When firstPeriodChargedToCard is false, the card window opens save-only: the card is stored and nothing is authorized. The discounted first invoice is sent for bank transfer, and the card is charged from the first full-price period.

The subscription takes one use of the discount and of the code, however many periods the discount lasts. A quote or session that names a code spends one use of the code-attempt budget your API key shares with the discount cart check and code lookup.

RefusalMeaning
400 DISCOUNT_CODE_INVALIDThe text is not a valid code.
400 DISCOUNT_NOT_APPLICABLEThe code is unknown, switched off, expired, for another customer, or its discount does not apply; details.reason says which.
400 DISCOUNT_NO_AMOUNT_IN_CURRENCYA fixed or per-unit discount has no amount in the plan's currency.
400 DISCOUNT_REDEMPTION_LIMIT_REACHED, DISCOUNT_CUSTOMER_REDEMPTION_LIMIT_REACHED, DISCOUNT_CODE_REDEMPTION_LIMIT_REACHEDThe discount, the customer or the code has no use left.
429 DISCOUNT_CHECK_RATE_LIMITEDThe code-attempt budget is spent; wait for Retry-After.

How limits and codes work is in Discounts.

Example: a first-month discount

This example sells a plan at 400 DKK a month with a code that takes 50% off the first month. The calls take the products-write scope for the discount, checkout for the quote, session and confirm, customers-write and subscriptions-write for the direct sequence, and invoices-read to read the invoices (invoices-write to send one).

Create the discount and its code

A discount with durationInPeriods applies to that many billing periods, counted from the first period the subscription bills (a trial does not use them up). requiresCode: true makes it apply only through a code.

TerminalCode
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts' \ -H 'x-api-key: <your-key>' \ -H 'Content-Type: application/json' \ -d '{ "name": "Welcome 50% first month", "type": "percentage", "scope": "line", "percentagePct": "50", "requiresCode": true, "durationInPeriods": 1 }'

Assign it to the plan's products, or to their product group as below. A discount with no assignment applies nowhere: a code for it is refused with 400 DISCOUNT_NOT_APPLICABLE and details.reason: "notAssigned".

TerminalCode
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/<discountId>/assignments' \ -H 'x-api-key: <your-key>' \ -H 'Content-Type: application/json' \ -d '{ "productGroupId": "<productGroupId>" }' curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/<discountId>/codes' \ -H 'x-api-key: <your-key>' \ -H 'Content-Type: application/json' \ -d '{ "code": "WELCOME50", "maxRedemptions": 1 }'

Show the price with the code

POST /v1/checkout/quote with the code the buyer typed:

Code
{ "planVersionId": "<planVersionId>", "planItemId": "<planItemId>", "country": "DK", "customerType": "BUSINESS", "discountCode": "welcome50" }

The response, shortened to the fields this example uses:

Code
{ "quote": { "discount": { "code": "WELCOME50", "name": "Welcome 50% first month", "type": "percentage", "firstPeriodAmountOff": 200, "endsAt": "2026-11-14", "durationInPeriods": 1, "firstPeriodChargedToCard": true }, "plan": { "items": [ { "name": "Monthly 400", "unitAmount": 400, "qty": 1, "currency": "DKK", "billingInterval": "month", "billingIntervalCount": 1 } ] }, "pricing": { "subtotal": 200, "vatAmount": 50, "total": 250, "currency": "DKK" }, "startDate": { "resolved": "2026-10-14" }, "allowedPaymentMethods": null, "cardPaymentsEnabled": true }, "firstPeriodChargeable": true }

The request names no startDate, so the quote starts today (2026-10-14 in this example) and endsAt falls one month later.

Render the first period from pricing (250 DKK including VAT) and the regular price from plan.items (400 DKK, 500 DKK including VAT). Every period that starts on or after endsAt is billed at the regular price. allowedPaymentMethods is null and cardPaymentsEnabled is true, so offer the buyer both card and invoice.

The quote reserves no use of the code. The code is validated again when the subscription is created, so a buyer can be quoted a discounted price and then lose the code's last use to another buyer.

Create the customer and the subscription

Two call sequences create the subscription with the code:

SequenceCallsScopesBilling direction
CheckoutPOST /v1/checkout/session with discountCode → POST /v1/checkout/confirmcheckoutbillingDirection on the session, or the default
DirectPOST /v1/customers → POST /v1/subscriptions with discountCodecustomers-write, subscriptions-writebillingDirection on the request, or the default

The checkout sequence creates the customer from buyer (or reuses existingCustomerId) and records the buyer's consent; Steps 3 to 5 above describe it. Confirm with the paymentMethod the buyer chose from the quote's allowedPaymentMethods: "CARD" after the card window, "INVOICE" on the invoice rail. The response names the subscription:

Code
{ "subscriptionId": "<subscriptionId>", "reference": "order-1001", "billingPending": true }

The direct sequence suits a page that already knows the customer. Reuse the customer id your system stored when it created the customer, or create one. defaultCurrency is required on create:

Code
{ "name": "Acme ApS", "customerType": "BUSINESS", "email": "buyer@acme.example", "customerGroupId": "<customerGroupId>", "countryCode": "DK", "vatZone": "domestic", "defaultCurrency": "DKK" }

Then POST /v1/subscriptions with the subscription plan behind the catalogue item (its sourceSubscriptionId) and the code:

Code
{ "customerId": "<customerId>", "planId": "<planId>", "startDate": "2026-10-14", "billingDirection": "advance", "discountCode": "welcome50" }

Either way, the subscription takes one use of the discount and one of the code when it is created, and the discount ends on the same endsAt the quote showed. The subscription response does not repeat the discount; the invoices carry it.

What the invoices show

The first invoice bills the first month at half price. With advance billing it is created on the start date; with arrears it is created when the first month ends, and still carries the discount because that month started before endsAt. The checkout quote and session state the day as firstInvoiceDate. Read it with GET /v1/invoices?subscriptionId=<subscriptionId> and GET /v1/invoices/{id}:

Code
{ "status": "draft", "date": "2026-10-14", "currency": "DKK", "subtotal": "200.00", "vat": "50.00", "amount": "250.00", "lines": [ { "lineType": "subscription", "quantity": 1, "unitPrice": "400.00", "totalPrice": "400.00", "vatAmount": "100.00", "appliedDiscounts": [ { "discountId": "<discountId>", "name": "Welcome 50% first month", "type": "percentage", "percentagePct": "50", "originalReductionAmount": "200.00" } ] } ], "totalDiscounted": "200.00" }

A line's totalPrice and vatAmount are before its discounts; appliedDiscounts[].originalReductionAmount is what each discount took off, and the invoice's subtotal, vat and amount are after them. The PDF (GET /v1/invoices/{id}/pdf) prints the discount by name under the line.

The next period starts on 2026-11-14, on endsAt, so its invoice bills 400 DKK plus 100 DKK VAT, and so does every invoice after it.

When automatic sending is on in your subscription settings, each invoice is sent to the buyer as it is created. When it is off, invoices stay drafts until you send them with POST /v1/invoices/{id}/send.

Refusals in this flow

CallResponseWhat to show the buyer
Quote, session or subscription with a code nobody created400 DISCOUNT_NOT_APPLICABLE, details.reason: "codeUnknown", details.codeThe code is not valid.
Quote, session or subscription with a code whose uses are spent400 DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED, details.code, details.limit, details.usedThe code has been used up. Offer the regular price.
Any of them with a code switched off, expired, or for another customer400 DISCOUNT_NOT_APPLICABLE, with the reason in details.reason (reasons)The code cannot be used for this purchase.
More than 600 code-bearing calls in a minute from one API key429 DISCOUNT_CHECK_RATE_LIMITED, Retry-After headerAsk the buyer to try again shortly.

A refused POST /v1/subscriptions or confirm creates nothing.

Taking payment

PaymentHowWhat the buyer meets
Bank transferThe checkout invoice rail, or POST /v1/subscriptions without allowedPaymentProvidersAn invoice email whose document carries the payment instructions (bank account, FIK). No pay-now link.
CardThe Stripe card window: Step 4 during checkout, or POST /v1/checkout/subscriptions/{id}/card-window after POST /v1/subscriptionsStripe's card form, on your page or on checkout.stripe.com. Later invoices are charged to the card.
Pay-now link on invoicesallowedPaymentProviders set on the subscriptionThe invoice email links to a LedgerBee-hosted payment page: the customer portal's billing page when you hold the Customer Portal license and the buyer has a portal login, otherwise a payment page that needs no login.

The API accepts no card details and no payment method id; the buyer enters the card in Stripe's window.

Add-ons

A plan item can offer add-ons: extra plans the buyer takes with it, each created as its own subscription that bills on the plan's invoice. See Add-ons for how they behave once sold.

The catalogue lists them on each item under addons, priced at the item's period in its presented currency. A custom call-to-action item, and an item with no add-ons, carries addons: [].

Field on plan.items[].addons[]Meaning
planIdThe add-on plan. Send it back as addons[].planId on the quote and the session.
sourceSubscriptionIdThe same value as planId, kept for compatibility. Read planId.
name / descriptionThe add-on's display texts.
priceMinorRecurring price per period, minor units, net. null when the price is not flat-rate (metered or tiered).
cadenceKeyThe cadence the price is for: the item's own.
oneTimeMinorOne-time charges on the first invoice, minor units, net.
productsThe add-on's products.

Select add-ons on POST /v1/checkout/quote and POST /v1/checkout/session with addons, up to 19 entries. Each names a planId from the item, with optional quantities for the add-on's quantity-bearing products, keyed by the add-on's own price ids:

Code
{ "planVersionId": "0195f9a2-…", "planItemId": "0195f9a2-…", "addons": [ { "planId": "0195f9a3-…" }, { "planId": "0195f9a4-…", "quantities": [{ "priceId": "0195f9a5-…", "quantity": 3 }] } ] }
  • Each planId may appear once. A repeat, in any letter case, answers 400 SHARED_VALIDATION_FAILED.
  • Quantities follow the add-on's own quantity rules and are clamped to them. Without quantities, the add-on bills at its default quantities.
  • An add-on sold at checkout bills at the plan item's period, on the same invoice as the plan. Its first period is priced into the quote and into the amount the card window authorizes.

The quote lists every add-on the item offers in quote.addonQuantityRows, selected or not, each naming its add-on by planId with the quantity rows to render. In quote.plan.items, the rows an add-on contributes carry its planId and its subscriptionName. On both, addonSubscriptionId carries the same value as planId.

The session pins the selection in pinned.addons, each entry with the quantities it was sent with ([] when none); a session without add-ons pins []. Confirm creates exactly the pinned add-ons, together with the plan's subscription, and the confirm response names the plan's subscription.

ConditionWhat you observeFix
An add-on the item does not offer, one with no price at the item's period, or one not priced in the session's currency400 PORTAL_CHECKOUT_ADDON_NOT_OFFERED, details.planItemId, details.planId, details.reason: NOT_ON_CARD, NO_CADENCE_AT_CARD_PERIOD or CURRENCY_UNAVAILABLEOffer only the item's addons, in a currency the item lists.
The same planId twice in addons400 SHARED_VALIDATION_FAILEDSend each add-on once.
A pinned add-on's plan was archived after the session was minted410 PORTAL_CHECKOUT_PLAN_NO_LONGER_AVAILABLE, details.reason: "PINNED_ADDON_RETIRED", details.planIdRe-read the catalogue and mint a fresh session. Nothing was written.

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. Use the same steps when a session expires after the card was captured.

A 410 on confirm means the session expired, or you republished the plan or terms after it was minted. Re-mint and re-render.

Adding or updating the card on a live subscription

POST /v1/checkout/subscriptions/:id/card-window opens the same embedded or hosted window against an existing subscription. Use it for "add payment method" and "update payment method" in your UI. It takes the same body as the checkout card window (uiMode, and returnUrl in hosted mode) and returns the same response. The window is save-only: nothing is charged when the buyer enters the card. The manage link emailed to the buyer keeps working unchanged as the self-service fallback.

The card lands asynchronously, through Stripe. It becomes the subscription's card, and automatic charging turns on, so the next invoice is charged to it. Read GET /v1/subscriptions/{id} until paymentMethodId is set; a card new to the customer also fires the card.added webhook.

The window replaces a Stripe card on file whatever the subscription allows. Adding a first card, or replacing a card from another provider, requires the subscription to allow Stripe card payment. For a subscription you create with POST /v1/subscriptions, set it at creation or later with PATCH /v1/subscriptions/{id}:

Code
{ "allowedPaymentProviders": ["STRIPE"] }

GET /v1/subscriptions/{id} and the create and update responses return the current value; the list (GET /v1/subscriptions) carries no card fields. Field details are in the API Reference.

ConditionWhat you observeFix
The subscription does not allow Stripe and has no Stripe card400 SUBSCRIPTION_MANAGE_PROVIDER_NOT_ALLOWEDSet allowedPaymentProviders: ["STRIPE"] with PATCH /v1/subscriptions/{id}.
Two card providers sent in allowedPaymentProviders400 INVOICE_MULTIPLE_CARD_PROVIDERSSend one.
No active Stripe Connect account400 CUSTOMER_PORTAL_CARD_PROVIDER_NOT_CONNECTEDConnect Stripe; GET /v1/checkout/readiness reports it.
The subscription bills on a billing group400 PORTAL_PAYMENT_METHOD_CHANGE_CARD_NOT_ALLOWED, details.reason: "BILLING_GROUP_MEMBER"The group's own card charges it.
A first card, on a subscription whose regular invoice is below the smallest amount a card can be charged400 SUBSCRIPTION_MANAGE_CARD_BELOW_MINIMUM, with details.steadyGross and details.minimumBill the subscription by invoice, or raise its price.

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. Subscriptions created through checkout fire the same customer, subscription and billing lifecycle events as any other subscription.

Testing

Integrate against the demo environment. It runs the full stack with Stripe in 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 November 6, 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
  • Discount codes
    • Which customer a code is judged for
  • Example: a first-month discount
    • Create the discount and its code
    • Show the price with the code
    • Create the customer and the subscription
    • What the invoices show
    • Refusals in this flow
    • Taking payment
  • Add-ons
  • The edit-after-card loop
  • Adding or updating the card on a live subscription
  • Webhooks
  • Testing
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON