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:
| Flow | Calls | Use when |
|---|---|---|
| Checkout | POST /v1/checkout/quote → POST /v1/checkout/session → POST /v1/checkout/session/:id/card-window → POST /v1/checkout/confirm | The buyer picks and buys a plan in your UI. Steps 0–5 below. |
| Subscription first, card after | POST /v1/subscriptions with "allowedPaymentProviders": ["STRIPE"] → POST /v1/checkout/subscriptions/:id/card-window | Your 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:
| Field | Precondition |
|---|---|
subscriptionLicense | You hold the Subscription module license. |
publishedPlans / checkoutablePlans | publishedPlans 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. |
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 chargeableCurrencies, the currencies your Stripe account can charge. |
embeddableOrigins | The 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, fromCARDandINVOICE. Offer the buyer only the methods in the list, and offerCARDonly whencardPaymentsEnabledis alsotrue.nullmeans 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 listsINVOICEonly. A code that takes only the first period below that amount leavesCARDin the list; seefirstPeriodChargeablebelow. A confirm whosepaymentMethodis not in the list returns400 PORTAL_CHECKOUT_PLAN_NO_LONGER_AVAILABLEwithdetails.reason: "PAYMENT_METHOD_NOT_ALLOWED"and the list indetails.allowed. ACARDconfirm for a plan below the card minimum returns the same code withdetails.reason: "CARD_AMOUNT_BELOW_MINIMUM"instead.cardPaymentsEnabled— includes 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, 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—falsemeans 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 inarrears. 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:
billingDirection | First invoice (firstInvoiceDate) |
|---|---|
advance | On the start date, less the send offset in your subscription settings, and not before today. A checkout starting today is invoiced at confirm. |
arrears | When 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
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 everycheckout.*webhook, and is stored on the subscription as its partner reference.billingDirection— optional,advanceorarrears; the default billing direction in your subscription settings when absent. The session pins the resolved direction inpinned.billingDirectionwithpinned.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) 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). On completion, Stripe sends the buyer back to yourreturnUrlunchanged. 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
The x-api-idempotency-key header is required. Generate a fresh key, such as a
UUID, per checkout attempt and reuse it on retries:
| Outcome | Meaning |
|---|---|
400 IDEMPOTENCY_KEY_REQUIRED | No key sent. |
Replay (x-idempotent-replay: true) | Same key retried. Returns the full original response body and writes nothing new. |
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 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
The quote prices the code onto the first period and answers it in
quote.discount:
| Field | Meaning |
|---|---|
code | The code as your company wrote it. |
name | The discount's name. |
type | percentage, fixed (an amount per period) or per_unit. |
firstPeriodAmountOff | What the code takes off the first period, before VAT. quote.plan.items keep the regular price; quote.pricing is net of this amount. |
endsAt | YYYY-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. |
durationInPeriods | How many billing periods the discount lasts; null when it has no end. |
firstPeriodChargedToCard | false 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:
| Call | Customer the code is judged for |
|---|---|
POST /v1/checkout/quote | None: 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.existingCustomerId | That customer, before it is proven to be the buyer's. |
POST /v1/checkout/session without buyer.existingCustomerId | None. |
POST /v1/checkout/session/:id/card-window | The customer the session names. |
POST /v1/checkout/confirm | The 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.
| Refusal | Meaning |
|---|---|
400 DISCOUNT_CODE_INVALID | The text is not a valid code. |
400 DISCOUNT_NOT_APPLICABLE | The code is unknown, switched off, expired, for another customer, or its discount does not apply; details.reason says which. |
400 DISCOUNT_NO_AMOUNT_IN_CURRENCY | A 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_REACHED | The discount, the customer or the code has no use left. |
429 DISCOUNT_CHECK_RATE_LIMITED | The 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.
Code
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".
Code
Show the price with the code
POST /v1/checkout/quote with the code the buyer typed:
Code
The response, shortened to the fields this example uses:
Code
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:
| Sequence | Calls | Scopes | Billing direction |
|---|---|---|---|
| Checkout | POST /v1/checkout/session with discountCode → POST /v1/checkout/confirm | checkout | billingDirection on the session, or the default |
| Direct | POST /v1/customers → POST /v1/subscriptions with discountCode | customers-write, subscriptions-write | billingDirection 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
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
Then POST /v1/subscriptions with the subscription plan behind the catalogue
item (its sourceSubscriptionId) and the code:
Code
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
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
| Call | Response | What to show the buyer |
|---|---|---|
| Quote, session or subscription with a code nobody created | 400 DISCOUNT_NOT_APPLICABLE, details.reason: "codeUnknown", details.code | The code is not valid. |
| Quote, session or subscription with a code whose uses are spent | 400 DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED, details.code, details.limit, details.used | The code has been used up. Offer the regular price. |
| Any of them with a code switched off, expired, or for another customer | 400 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 key | 429 DISCOUNT_CHECK_RATE_LIMITED, Retry-After header | Ask the buyer to try again shortly. |
A refused POST /v1/subscriptions or confirm creates nothing.
Taking payment
| Payment | How | What the buyer meets |
|---|---|---|
| Bank transfer | The checkout invoice rail, or POST /v1/subscriptions without allowedPaymentProviders | An invoice email whose document carries the payment instructions (bank account, FIK). No pay-now link. |
| Card | The Stripe card window: Step 4 during checkout, or POST /v1/checkout/subscriptions/{id}/card-window after POST /v1/subscriptions | Stripe's card form, on your page or on checkout.stripe.com. Later invoices are charged to the card. |
| Pay-now link on invoices | allowedPaymentProviders set on the subscription | The 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 |
|---|---|
planId | The add-on plan. Send it back as addons[].planId on the quote and the session. |
sourceSubscriptionId | The same value as planId, kept for compatibility. Read planId. |
name / description | The add-on's display texts. |
priceMinor | Recurring price per period, minor units, net. null when the price is not flat-rate (metered or tiered). |
cadenceKey | The cadence the price is for: the item's own. |
oneTimeMinor | One-time charges on the first invoice, minor units, net. |
products | The 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
- Each
planIdmay appear once. A repeat, in any letter case, answers400 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.
| Condition | What you observe | Fix |
|---|---|---|
| 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 currency | 400 PORTAL_CHECKOUT_ADDON_NOT_OFFERED, details.planItemId, details.planId, details.reason: NOT_ON_CARD, NO_CADENCE_AT_CARD_PERIOD or CURRENCY_UNAVAILABLE | Offer only the item's addons, in a currency the item lists. |
The same planId twice in addons | 400 SHARED_VALIDATION_FAILED | Send each add-on once. |
| A pinned add-on's plan was archived after the session was minted | 410 PORTAL_CHECKOUT_PLAN_NO_LONGER_AVAILABLE, details.reason: "PINNED_ADDON_RETIRED", details.planId | Re-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:
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. 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
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.
| Condition | What you observe | Fix |
|---|---|---|
| The subscription does not allow Stripe and has no Stripe card | 400 SUBSCRIPTION_MANAGE_PROVIDER_NOT_ALLOWED | Set allowedPaymentProviders: ["STRIPE"] with PATCH /v1/subscriptions/{id}. |
Two card providers sent in allowedPaymentProviders | 400 INVOICE_MULTIPLE_CARD_PROVIDERS | Send one. |
| No active Stripe Connect account | 400 CUSTOMER_PORTAL_CARD_PROVIDER_NOT_CONNECTED | Connect Stripe; GET /v1/checkout/readiness reports it. |
| The subscription bills on a billing group | 400 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 charged | 400 SUBSCRIPTION_MANAGE_CARD_BELOW_MINIMUM, with details.steadyGross and details.minimum | Bill 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:
| 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. 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.