Skip to content

BYO checkout API

Storefront requests also carry their current origin: browsers supply Origin; SSR and backend transports forward x-storefront-origin. Domains selects Live or Sandbox data automatically, including token-only calls. Missing or conflicting context is rejected, with no Live fallback. See storefront data selection.

BYO checkout means the tenant owns the checkout UI, but cartonium owns the commerce state. Your storefront collects cart, customer, address, and payment UI inputs; the API computes the authoritative total, creates the Stripe PaymentIntent, receives the webhook, and confirms the order.

This is the same public contract hosted cartonium checkout uses under the hood. The hosted cartonium checkout app is a separate M3 surface, but a custom storefront does not need to wait for hosted pages to use the checkout-session API.

All paths below are under /api/v1.

What BYO can and cannot own

AreaBYO storefront ownscartonium owns
UICart, checkout form, Payment Element container, confirmation/status pagesNone
Tenant contextCurrent storefront origin on every call, plus x-organization-slug on standard public checkout callsResolving data through Domains and enforcing tenant scope
PricingItem refs, quantities, coupon code, customer/contact/address inputs, shipping-rate selectionLive repricing, discounts, shipping rates, tax snapshot, binding total
PaymentRendering Stripe Payment Element with the returned publishableKey, optional stripeAccountId, and clientSecretCreating/reusing Stripe objects through PaymentAttempt
Order creationStatus polling / confirmation UXWebhook-authoritative payment state and order creation

The storefront must never create Stripe PaymentIntents directly. It asks cartonium for the payment handoff and renders the returned client secret.

Restore a saved basket

A browser basket can outlive a catalog edit, archive or explicit identity migration. Validate its item, variant and collection references against the matching tenant/environment's current catalog before a new pricing/checkout request. IDs remain opaque even when UUID-shaped; a legacy item_..., variant_... or col_... string is not a canonical UUID. See catalog identity and retirement and the September 29 retrospective.

Distinguish these outcomes:

EvidenceWhat it establishesRecovery
HTTP 400 VALIDATION_FAILED, message: "Request body failed validation", details.violations array of strings identifying a UUID constraintRequest format was refused before catalog resolution. This can cover itemId, variantId and collection references on pricing previews.Keep the basket and mark the affected selection for review. Fetch current catalog data; do not infer stock, retirement or a replacement from the format error.
A successful complete current parent read lacks the exact saved variant, or a checkout 400 VALIDATION_FAILED identifies details.field: "lines.variantId"The reference is not a live variant of that exact parent now; the error does not distinguish archived, unknown or wrong-parent references.Require explicit shopper reselection from current options or removal of that line. No SKU/name matching or automatic substitution.
Current slug resolves to a different item IDThe current product has a different identity; a slug is not an old-to-new map.Require the shopper to confirm a current product and option or remove the old selection.
HTTP 409 RESOURCE_CONFLICT with details.reason: "INSUFFICIENT_INVENTORY" and details.lines[] at create/bindA measured stock shortfall on valid catalog targets.Follow stock-shortfall recovery; do not relabel a retired reference as out of stock, or retire an existing option because its quantity is short.
404, network/timeout/5xx failure, rate limit or incomplete/malformed parent dataThe read does not establish variant retirement or a safe replacement.Keep the saved selection and show availability as unknown. Offer a bounded read retry when appropriate; do not delete or silently replace it.

For each line needing recovery, let the shopper explicitly reselect a current product and, where required, one exact variant from its complete variants array, or explicitly remove the line. There is no public old-to-new map and none is planned. POST /public/items/resolve is slug-only and returns id, slug and composition; it cannot validate a variant. Use a complete parent read for that. A list/search page omitting a parent is not such a read. An empty nested array also does not authorize omitting a required variant on a SALE item that retains archived variant history.

Preserve unaffected lines, quantities and the shopper's coupon intent. After the shopper confirms a current selection, use that returned item's ID, the selected variant's ID, and the item's current collectionIds on pricing previews that accept them. For an unchanged exact selection, refresh membership from that same current parent. An empty collectionIds means no memberships; do not keep stale collection IDs, infer them from names, or copy them from a different product. Public collection reads support current browsing, not historical mapping. Checkout-session payloads send item/variant refs and quantities, not collectionIds: the server resolves memberships itself. Recalculate estimates and show changes; preserving a coupon code is not a promise that a stale discount remains valid.

Catalog recovery is not checkout/payment recovery. Keep pending checkout markers, the original submitted basket/quantities and correlation evidence separate from the editable basket, including across tabs and reloads. A catalog failure or an expired checkout read does not prove an earlier payment failed. Follow return-path reconciliation before offering another payment; never auto-create, bind, confirm or replay payment to repair a reference. For an existing session with prepared payment, also follow prepared-payment conflict recovery and discard an old client secret after a successful reprice as described below.

Flow

1. Create a checkout session

POST /api/v1/public/checkout/sessions

Send x-organization-slug. The request carries catalog references and customer draft information, not prices. The response returns a repriced summary plus the raw session token exactly once.

In this example, selectedItem and selectedVariant are the current catalog product and its exact option explicitly selected by the shopper.

const session = await api("/public/checkout/sessions", {
  method: "POST",
  headers: { "x-organization-slug": tenantSlug },
  body: JSON.stringify({
    currency: "USD",
    customer: {
      email: "dana@example.com",
      name: "Dana Reyes",
    },
    lines: [
      { itemId: selectedItem.id, variantId: selectedVariant.id, quantity: 1 },
    ],
  }),
});
 
session.token; // store client-side for this checkout only
session.totalInCents; // server-computed estimate

For a create-time stock refusal, follow Recover from a stock shortfall at create or bind; no session exists for that failed request.

A 503 with CHECKOUT_UNAVAILABLE and details.retry NOT_RETRYABLE means checkout is unavailable for this storefront right now, and no session exists. Do not repeat the request or fall back to another checkout or payment path; show checkout as unavailable. The response carries no reason. A transient policy fault can instead return 503 HOSTED_CHECKOUT_POLICY_UNAVAILABLE with details.retry: "SAME_REQUEST_LATER". Generic create publishes no automatic retry bound: stop automatic retries and offer an explicit user retry instead. Do not borrow the separate hosted create/restart retry bounds or treat every create 503 alike. Like paymentPresentation, these policy refusals are dormant until the payment-policy rollout.

The session summary also includes customer-facing action URLs: returnToStoreUrl, postCheckoutRedirectUrl, checkoutRetryUrl, checkoutCancelUrl, accountUrl, and accountSignInUrl. These are resolved by the API from tenant checkout settings plus trusted defaults; use them as-is for confirmation, retry/cancel, return-to-store, and account CTAs, and render no link when a field is null.

2. Reprice as the customer edits

POST /api/v1/public/checkout/sessions/:token/reprice

Call this after cart lines, coupon, customer, address, or shipping-rate inputs change and the storefront needs a fresh server-authoritative estimate. Debounce or batch field edits; do not issue a request for every keystroke. Reprice is mutable: it updates the persisted estimate but does not lock the total.

The request is the complete current cart state, not a patch over the prior session. That includes the coupon selection: send couponCode while the shopper wants that code applied; to remove a coupon applied by an earlier create, reprice, or bind, resend the current lines and omit couponCode. Omission does not retain the old code, and there is no separate clear-coupon action or null sentinel you need to send. A blank or whitespace-only couponCode follows the same no-code path as omission and also clears the prior coupon; omission is the canonical representation.

Use the returned discountSnapshot.appliedCoupon as the result proof. null means no coupon is applied to this cart, including after omit-to-clear. Do not infer removal from discountInCents or the total: automatic, bundle, or entitlement discounts can remain, and a free-shipping coupon can change shipping without a subtotal discount. A supplied but rejected non-blank code also returns appliedCoupon: null. The typed public contract does not distinguish omission, blank input, and rejected input with another snapshot field; do not depend on undeclared attribution keys.

A successful reprice invalidates every previously prepared PaymentIntent before it persists the edited checkout, even when the displayed total did not change. If payment-session already returned a clientSecret, stop using it and tear down its Payment Element. After reprice succeeds, discard that secret, bind the updated checkout again when the customer is ready to pay, and request a new payment session. Never reuse a client secret across a successful reprice.

3. Resolve and offer shipping options

POST /api/v1/public/checkout/sessions/:token/reprice

For a cart with shippable lines, once you've captured a complete delivery address, reprice the session with the current cart and address, then render the complete availableShippingOptions set from that response. Each option carries a rateId. When the customer selects one, send that value as shippingRateId on the next reprice (to preview the new total). From then on, send the id the latest response applied (see rateId is quote-scoped below), including on bind (to charge it). cartonium, not the storefront, owns the shipping math.

GET /api/v1/public/shipping/zones is an optional configuration and eligibility read, not the current cart's option source. It inlines active flat-rate configuration and publishes liveRatesEnabled, but it does not evaluate the cart, address, provider result, or presentation policy. A live-only zone can therefore return rates: [] while reprice returns carrier options, and a flat rate published on the zone can still be inapplicable to the current cart. Do not build the shipping picker from this endpoint.

Rate-required at bind. For a cart that requires shipping, carry the current selection as shippingRateId: the customer-confirmed option, under the id the latest reprice applied. Bind recomputes the current option set; when that fresh set is non-empty, omitting the selection returns 400. A selected option requires a valid shipping/billing address, and a free-shipping discount keeps the method while zeroing the charge. If a supplied selection no longer resolves, follow the reason-coded bind-recovery contract below. Never infer a fallback or bind outcome from the zone configuration read.

Where the option set comes from. availableShippingOptions — the tenant's flat zone rates merged with any live carrier or delivery-promise quotes — is a pre-payment surface, and it is populated only on:

  • reprice and bind responses, and
  • a read (GET …/:token) of a session that is not yet binding.

It is deliberately [] everywhere else, and an empty array in those cases means "not offered on this response", not "no rates available":

  • the create response returns [] (no destination has been resolved yet — call reprice with an address to get options), and
  • a read of a bound session returns [] by design; the picker is a pre-payment surface and the confirmation poll hits that read repeatedly, so it skips the lookup.

So never let a post-bind read or a create response clear a rendered option set or a confirmed selection — refresh options from reprice/bind responses (and pre-bind reads) only.

rateId is quote-scoped. Treat every rate id as belonging to the quote it arrived on. Flat-rate ids are stable tenant configuration. Live carrier ids (se:) wrap the provider's per-quote id and do rotate between quotes even when the shopper changed nothing. Delivery-promise ids (dp:) are derived from a durable promise tier, so the id itself is stable while that promise is still offered — but the promise can stop being offered entirely between quotes, and a stable id never implies a stable price: the server always charges the fresh quote. Replace the entire rendered option set from every fresh response. Never keep a pre-reprice rate id once a fresh response has answered, infer meaning from an id's prefix, or re-match a prior choice by name — labels are neither guaranteed unique across the merged list nor guaranteed to survive between quotes. Send the current selection as shippingRateId with each reprice, then replace it from the response. After a reprice that sent shippingRateId, a non-null shippingMethod.rateId is the server-applied selection: adopt it, even when the id you sent no longer appears in the refreshed set. When the provider rotated the id and the server could still match the same service, it re-selects that service under the fresh quote's id, and that is the id to send to tax preview and bind. Never clear an adopted selection because the old id is gone. Only when no method was applied — shippingMethod is null, or a refreshed set that did not re-quote your selection (such as a pre-bind read) no longer lists it — clear the selection, then read the refreshed set:

  • It lists methods — require the shopper to explicitly reconfirm one: pre-selecting a default in the preview is fine, but never auto-restore the old choice by label and never bind a substitute the shopper did not confirm.
  • It is empty and carries shippingQuoteFailure or shippingUnavailable — keep the shopper on the delivery step and follow that field (see below).
  • It is empty and carries neither — with a complete delivery address, there is nothing to reconfirm: a served destination whose configured rates do not apply to this cart binds without shippingRateId and with no shipping charge (see When no delivery method exists). An incomplete address can also return an empty set; complete it and reprice first, because bind requires a complete destination for a cart that ships.

When the live carrier quote fails

A reprice whose live carrier-rate quote fails — the provider timed out, could not be reached, was rate limited, or refused the request — still returns 200. The response then carries shippingQuoteFailure:

{
  "availableShippingOptions": [],
  "shippingQuoteFailure": {
    "stage": "shipping_rate_quote",
    "category": "timeout",
    "retryable": true,
    "supportReference": "lrq_AAECAwQFBgcICQoL"
  }
}

Read it together with availableShippingOptions:

  • Empty option set with shippingQuoteFailure — no delivery method is currently usable because the quote failed. That is not evidence the address is unserviceable. Keep the shopper on the delivery step and show the category; ask them to change the address only when the category is invalid_address, which means the carriers rejected it.
  • Non-empty option set with shippingQuoteFailure — the listed options, such as the tenant's configured flat rates, remain valid choices; only carrier rates are missing. The shopper may continue with one of them or refresh.
  • No shippingQuoteFailure — this response reports no failed quote. That does not prove rates exist: an empty set can still mean no zone or rate serves the destination, or that the address is not complete enough to quote. Create, bind, and session reads never carry the field.

To recover, reprice the same inputs once with refreshShippingRates: true when retryable is true and the shopper asks for it. That request bypasses a matching cached failure and contacts the provider again; it never retries bind or payment. For rate_limited, wait at least retryAfterSeconds when it is present. Treat the failure as resolved only when a later reprice for the current destination returns usable options without the field. Do not retry automatically in a loop: a provider that is already slow only gets slower.

configuration_failure and authentication_failure are store-side, so a refresh will not help; direct the shopper to the store instead. supportReference is an opaque correlation id for support conversations; no route accepts it as input.

When no delivery method exists

A reprice for a cart that ships, with a destination country and an empty availableShippingOptions, also says when a bind would be refused for lack of a delivery method. The response then carries shippingUnavailable:

{
  "availableShippingOptions": [],
  "shippingUnavailable": { "reason": "DESTINATION_NOT_SERVED" }
}
  • SHIPPING_NOT_CONFIGURED — the store has no active shipping zone, so it cannot ship this order yet. Direct the shopper to the store.
  • DESTINATION_NOT_SERVED — no active zone serves the destination country. The shopper can choose an address in a country the store serves.
  • NO_SHIPPING_METHOD — a zone with live carrier rates serves the destination, but this response has no usable method from it. When shippingQuoteFailure is also present, follow its retry guidance above; otherwise direct the shopper to the store.
  • RATES_TEMPORARILY_UNAVAILABLE — the live quote did not settle for a temporary reason that is not a classified provider failure, for example the store's delivery settings changing mid-quote. Offer an explicit retry that repeats the reprice for the same destination; bind refuses this state as temporarily unavailable.

Keep the shopper on the delivery step for every reason. None of them blames the address: a carrier-rejected address is reported through shippingAddressValidation instead, and this field is then omitted. Without the field, an empty option set is not a refusal; a served destination whose configured rates do not apply to this cart binds with no shipping charge. Bind remains the commitment boundary and can still refuse for other reasons. Create, bind, and session reads never carry the field.

4. Bind before payment

POST /api/v1/public/checkout/sessions/:token/bind

Bind performs the final server-side reprice and marks the session as binding. The effective customer name and email must be non-blank. For a cart that requires shipping, if bind's fresh option set is non-empty, the request must carry the selected shippingRateId (see Resolve and offer shipping options above) or it returns 400. When a tax provider is enabled, the bind step uses the captured address to compute destination-based tax — over products and the charged shipping — and stores the tax snapshot. See Taxes for selected-policy readiness and required tax disclosures. The staged hosted preview does not advance the released BYO contract.

For an ordinary one-time checkout, cartonium also records one canonical semantic identity for the binding. It covers normalized line item/variant/quantity and pricing references, discount attribution and snapshot, recipient and fulfillment/tax address facts, shipping method/rate/package basis, tax basis and snapshot, amount, and currency. The storefront does not compute or send this digest.

An exact semantic bind replay preserves a healthy prepared PaymentIntent and the current payment generation. Any semantic change — even when the displayed total stays equal — invalidates every linked prepared PaymentIntent. All linked prepared intents, if any, must be provider-proven canceled before the new bind commits; an attempt-free semantic change can advance without provider I/O. Once that gate succeeds, the bind advances the generation and persists the new purchase. Tear down the old Payment Element and request a fresh payment session and client secret. If cancellation is ambiguous or contended, bind returns the structured 409 described below and does not persist the requested binding change.

Recover from a stock shortfall at create or bind

Session create and bind check server-repriced SALE quantities, including expanded components of composed products. A measured shortfall returns HTTP 409 with code: "RESOURCE_CONFLICT", details.reason: "INSUFFICIENT_INVENTORY", and details.lines[] naming every short stock target. Counts refer to exact item/variant units or expanded component units, not package quantities; absent counts never mean zero.

A create refusal writes no session: correct the quantity in the storefront and submit create again. A bind stock refusal leaves the existing session alive and re-bindable. Correct the quantity and bind again, or retry after stock is replenished, following the existing prepared-payment cancellation rules for a semantic change. An exact replay that shortfalls preserves the existing binding and prepared PaymentIntent; it does not refresh the session TTL. Do not apply confirm-free's terminal cancellation recovery to this admission response. A moved-session conflict instead follows the prepared-payment refresh policy below, and catalog-target validation errors have no stock counts.

These reads reserve nothing. A missing exact inventory row is not zero and does not cause this refusal; successful admission never guarantees stock at confirmation. The terminal confirm-free reservation conflict described in step 6 is still reachable and uses a single details.line instead of details.lines[].

Recover from shipping bind rejections

Bind — and only bind — emits these shipping/address refusals: 400 with code: "BAD_REQUEST" and a scalar details.reason. For an unresolvable selection, a reprice carrying the same dead shippingRateId returns 200 with no error: shipping is dropped and shippingMethod is null (bind is the commitment, reprice is a mutable preview). Treat that null as the preview's signal and resolve the selection (see rateId is quote-scoped above); bind refuses the same selection with one of the reasons below. Address validation instead rides a successful reprice as guidance, and a failed live carrier quote rides it as shippingQuoteFailure (see When the live carrier quote fails above). Drive commitment recovery from the bind failure, branching on details.reason (never on message):

details.reasonMeaningRecovery
SHIPPING_RATE_NOT_QUOTABLEThe selected rate can no longer be re-quoted — for live carrier rates the quote-scoped id may simply have rotated past its quote.Re-quote → resolve the selection → bind again (below).
SHIPPING_RATE_NOT_APPLICABLE_TO_DESTINATIONNo active zone serves the captured destination.Re-quote → resolve the selection → bind again (below).
SHIPPING_RATE_BELOW_SUBTOTAL_MINIMUMThe cart no longer meets the selected rate's configured subtotal minimum.Re-quote → resolve the selection → bind again (below).
SHIPPING_DELIVERY_PROMISE_UNAVAILABLEThe selected delivery promise can no longer be presented.Re-quote → resolve the selection → bind again (below).
SHIPPING_ADDRESS_REQUIREDA rate was selected before any shipping/billing address was captured.Capture the address, then re-quote and continue.
SHIPPING_ADDRESS_INVALIDThe provider rejected the delivery address, or its normalized suggestion materially changes the U.S. country, state, or five-digit ZIP regardless of a non-invalid provider verdict; retrying the same address will not help.Use violations and any details.addressValidation.normalized suggestion to have the shopper correct or apply the address, then re-quote.
SHIPPING_ADDRESS_REVIEW_REQUIREDThe provider suggested a non-blocking correction in details.addressValidation.normalized; the session remains unbound.Show the suggestion and require one of the two explicit choices below.

The shared recovery sequence for the rate-shaped reasons:

  1. Re-quote — reprice the session and read the fresh availableShippingOptions from the response.
  2. Resolve the shipping selection — replace the rendered option set wholesale. If the reprice sent the selection and returned a non-null shippingMethod.rateId, adopt that id: it is the server-applied selection even when the id you sent is absent from the fresh set, so do not clear it. Only when no method was applied — shippingMethod is null, including after a reprice that did not send the selection — clear it and read the fresh set (see rateId is quote-scoped above — never re-match by label):
    • It lists methods — require an explicit shopper choice.
    • It is empty and carries shippingQuoteFailure or shippingUnavailable — keep the shopper on the delivery step and follow that field; a bind would be refused until a later reprice clears it.
    • It is empty and carries neither — with a complete delivery address, there is nothing to choose: a served destination whose configured rates do not apply to this cart binds without shippingRateId and with no shipping charge.
  3. Bind again — with the adopted or newly chosen shippingRateId, or without one when the fresh set is empty and carries neither field.

For SHIPPING_ADDRESS_REVIEW_REQUIRED, do not prepare payment from client memory. Show the normalized suggestion and let the shopper choose:

  • Use the suggestion: reprice with the suggested address, replace the option set from that response, resolve the shipping selection from it as in step 2 above, then bind normally.
  • Keep the entered address: repeat bind with the same current inputs plus shippingAddressValidationDecision: "KEEP_ENTERED".

The second bind performs fresh provider validation. KEEP_ENTERED can consume only an advisory correction; it never overrides an invalid address, a material country/state/five-digit-ZIP correction, or provider unavailability.

When SHIPPING_ADDRESS_INVALID carries a material normalized suggestion, it is not the advisory flow above: a changed U.S. country, state, or five-digit ZIP is a material routing correction. Keep the session unbound, show the normalized address, and require the shopper to correct or apply it before re-quoting.

A 400 shipping rejection means the requested bind was not persisted. It composes with — and does not replace — the prepared-payment 409 contract described under Recover from a prepared-payment conflict below: a successful re-quote in this sequence is a reprice, so the usual rule applies (discard any previously returned clientSecret and request a new payment session after the re-bind). SHIPPING_ADDRESS_REVIEW_REQUIRED likewise leaves the session unbound, including across a refresh.

5. Request the payment handoff

POST /api/v1/public/checkout/sessions/:token/payment-session

This route is token-authenticated and does not require x-organization-slug; the opaque session token resolves the tenant. The response contains a Stripe clientSecret for the Payment Element, the browser-safe publishableKey from the same API environment that created the PaymentIntent, the optional connected stripeAccountId, and the internal payment attempt id.

For an ordinary one-time checkout, payment-session replay identity is the current semantic binding and payment generation, not the amount alone. Calling this route again while both remain current reuses the same healthy PaymentIntent. A semantic rebind, including one with an equal charge-now amount, requires the fresh payment session and client secret created for the advanced generation. Booking and booking-group handoffs keep their separately documented authorization, due-now amount, and replay contracts.

const handoff = await api(
  `/public/checkout/sessions/${session.token}/payment-session`,
  { method: "POST" },
);
 
const stripe = await loadStripe(
  handoff.publishableKey,
  handoff.stripeAccountId
    ? { stripeAccount: handoff.stripeAccountId }
    : undefined,
);
 
// Render Stripe Payment Element with handoff.clientSecret.
// Do not create or mutate Stripe objects in the storefront.

Card is the only method at one-time checkout

The one-time checkout handoff pins payment_method_types: ['card'] on the PaymentIntent it creates, then verifies the provider echoed back exactly that list — set equality, with no subset tolerance and no "close enough". Any disagreement fails closed with a 502 and no client secret, including a provider response cartonium cannot fully parse: an unreadable method list collapses the whole response rather than being partially trusted.

For your Payment Element, that means asynchronous methods are unavailable at one-time checkout. ACH and other bank debits are not offered. Do not render them as pending, greyed-out, or temporarily unavailable options.

The reason is settlement timing, not method preference. A bank debit's settlement window can outlast the 30-minute checkout session. When it did, the debit succeeded against a session that had already lapsed — the shopper was debited and refunded days apart, with no order ever created and no explanation attached to either movement. Pinning the method list makes that sequence unreachable.

This contains the hazard rather than removing it. The ACH gap is a cost being carried deliberately, not a closed question: #3180 owns the underlying lifecycle work and the restoration of asynchronous methods. So do not assume either that card-only is permanent or that it lifts on a known date.

The eligible method set is server-owned policy. Stripe Payment Element derives the methods it offers from the PaymentIntent behind the clientSecret, so render whatever the Element offers rather than maintaining your own list of methods in the storefront. When the pinned set widens, a storefront built that way picks it up with no code change; one that hardcodes "card" in its own UI will not. Once a checkout carries a frozen payment policy, the handoff also reports the admitted methods: see Payment presentation.

Subscription checkout is unaffected. It pinned card-only independently, through its own path.

Card-only is a behavioral change with no wire-contract change: no request shape, response shape, field, or error code moved for it. That also means the schema digest did not move when it shipped, so a digest-equality check cannot detect it. Digest equality confirms that the published reference describes the contract the runtime was built from; it says nothing about behavior behind an unchanged contract. The digest is not a change feed. For behavioral changes, this guide is the channel.

Payment presentation (dormant)

Once a checkout carries a frozen payment policy, the handoff also returns paymentPresentation: the admitted paymentMethodTypes and a wallets value for the Payment Element. Until that policy's rollout activates it, a tenant-built checkout carries no frozen policy and the field is absent; keep today's card entry.

const elements = stripe.elements({ clientSecret: handoff.clientSecret });
const paymentElement = elements.create(
  "payment",
  handoff.paymentPresentation
    ? { wallets: handoff.paymentPresentation.wallets }
    : {},
);
  • wallets.applePay and wallets.googlePay are auto or never, as frozen when the payment was admitted. auto lets the Element offer the wallet where the shopper's device and browser support it; it does not promise the wallet renders.
  • wallets.link is always never.
  • On the handoff, basis is always ADMISSION: the methods admitted for this payment.
  • The presentation is display configuration, not authorization. The server refuses any payment method type outside the admitted ones, whatever your page renders. Apple Pay and Google Pay are card payments that this check cannot refuse, so pass wallets to the Element unchanged.

For an ordinary policy-bearing payment, the handoff pairs this final ADMISSION presentation with optional, never-null paymentContext: { version: 1, paymentGeneration }. Both inner fields are required: version is exactly integer 1, and generation is a server-derived integer from 0 through 2147483647. Fresh creation, reuse and same-card retry all use that handoff's final locked admission. A same-generation reuse/retry keeps the secret and generation; a generation advance yields a new secret and generation, while replacement of a cancelled unpublished intent yields a new secret in the same generation.

The object is a generation witness for this handoff, distinct from PaymentAttempt.paymentContextType and paymentContextId. It is not a lease or authority; no request accepts it and it never permits automatic confirmation. The shopper’s Pay action and applicable consent remain required. A compatible DISPLAY g0 and ADMISSION g1 can present the same ordered rails and all wallets without equal observed generations; the display read keeps its own contract.

Treat context as optional. UNMARKED and excluded booking/group handoffs omit paymentContext; the existing presentation behavior is preserved. An older API can omit context even when presentation is present. Metadata omission for the same secret grants no legacy authority and does not establish that the payment changed. Existing consumers may ignore this additive metadata.

An impossible final ordinary policy returns 502 / INTERNAL_ERROR with generic message An unexpected error occurred and details.reason: PAYMENT_HANDOFF_POLICY_INVARIANT_VIOLATION. No client secret, preparedPaymentReleased, Retry-After or automatic retry instruction is supplied. Attempt or provider effects may already exist; the refusal promises neither cancellation nor no prior work. Genuine generation movement keeps its existing 409 PREPARED_PAYMENT_SESSION_MOVED contract.

GET /public/checkout/sessions/{token}/payment-entry returns the same object before payment is prepared. There basis is ADMISSION once a payment was prepared for the current generation, otherwise DRAFT: what preparation would admit now. Before bind the read omits it even for a checkout that carries a policy, so configure the Element from the handoff.

payment-entry also returns paymentDisplayPreview, a display preview that works before bind too and tells that case apart. It is display configuration only: the handoff decides again and may still refuse.

stateMeaningWhat your page does
DISPLAYbasis says what it shows: ADMITTED (the payment already prepared for this generation), BOUND_DRAFT (what preparation would admit now) or UNBOUND_PREVIEW (what it would admit, read before bind). paymentMethodTypes and wallets mean what they mean on paymentPresentation.It may configure the Payment Element from it.
UNMARKEDThe checkout carries no frozen payment policy.Keep today's card entry.
NO_PAYABLE_METHODThe frozen payment policy admits no payment method.Do not mount card entry; this checkout cannot be paid online.
UNAVAILABLEThe checkout's payment policy cannot be used here; reason says why.Do not mount card entry; the handoff would refuse.
CHANGEDThe checkout changed while the read ran (reason is SESSION_STATUS or PAYMENT_GENERATION).Read the checkout again. Do not repeat requests automatically.
  • The preview and paymentPresentation are separate reads, so they can disagree when the checkout changes between them. The handoff wins.
  • paymentGeneration names the generation the preview describes. No request accepts it, and it is not a lease.
  • A server that predates the preview omits the field. Treat absence as no preview, and keep today's card entry.
  • Like paymentPresentation, it is dormant until the payment-policy rollout. Until then, every successful payment-entry read carries UNMARKED.

A 503 with HOSTED_CHECKOUT_POLICY_UNAVAILABLE from either route means the checkout's payment policy refuses payment, and its details.retry is NOT_RETRYABLE. Do not repeat the request, and do not offer card entry for that checkout. This is not the retryable rollout 503 described below.

Recover from a prepared-payment conflict

Use HTTP status, code, and details together. Every error envelope has a code; INTERNAL_ERROR is a generic fallback, not a retry instruction. Handle named refusals and reasons before the documented readiness fallback. Do not classify these responses by message text or treat all 5xx alike.

Response and operationRecovery
503 CHECKOUT_UNAVAILABLE on create, with details.retry: "NOT_RETRYABLE"No session was created. Show checkout as unavailable; do not repeat the request or fall back to another checkout/payment path.
503 HOSTED_CHECKOUT_POLICY_UNAVAILABLEFollow details.retry. NOT_RETRYABLE forbids the identical retry; wait for activation, correction, or different input. SAME_REQUEST_LATER, where the operation documents it, permits only its bounded identical-request retry. Keep the same checkout/recovery identity and do not fall back to another payment path. A payment-session refusal can follow earlier provider work and does not prove cancellation.
503 PLATFORM_TAX_UNAVAILABLE on bindThe configured tax provider is unavailable. Contact cartonium support; do not treat it as rollout readiness or continue with tax zero. No binding total, order, or payment was committed.
Documented rollout readiness: 503 INTERNAL_ERRORReprice, bind, payment-session, or confirm-free started no checkout mutation, order, PaymentAttempt, or provider operation for this request. Retry that operation with bounded backoff after readiness; it is not a prepared-payment conflict.
502 INTERNAL_ERROR with details.reason: "PAYMENT_HANDOFF_POLICY_INVARIANT_VIOLATION"Stop automatic retry and payment progression, discard held payment controls, and surface a blocked/support state. No secret is returned; prior attempt/provider effects may exist, and cancellation is not promised.
502 on bindA tax-provider failure does not commit the bind or lock a total and is safe to retry. Keep the session and do not proceed with tax zero. The status alone does not identify the upstream failure.
Other 502 on payment-sessionThe processor may be unavailable, misconfigured, or in an unusable state; card-only enforcement is only one possible cause. Do not claim payment succeeded or reuse a held secret. Follow the operation-specific remedy; the same-card retry evidence-read case below has its own contract.

An unrecognized status/code/reason/retry value or unreadable response grants no automatic retry, restart, order creation, or payment confirmation outside an operation's explicit transport-retry contract. Preserve the checkout identity and offer an explicit recheck or support path. Existing paid truth and the conflict recovery below still take precedence over starting payment again.

Payment-session can return HTTP 409 with code: "CHECKOUT_ALREADY_PAID" when the exact payment has succeeded while order confirmation is still pending. Handle only that status/code pair as paid truth; a generic 409 is insufficient. Discard any held client secret and payment controls, retain the original checkout token, and show “Payment received — we're finalizing your order.” Poll that session's authoritative GET within a bounded window. Only CONFIRMED authorizes the completed order/receipt outcome; the paid response cannot create an order. If reads remain pending, fail, return 404, or the polling budget expires, retain the paid posture and offer a non-payment recheck or support path. Do not offer payment preparation, confirmation, or a replacement checkout. A manual recheck reads immediately, joins an in-flight read, and opens a new bounded window only after the previous window is exhausted. The restart guidance below does not apply while this token's paid truth is held.

Reprice, bind, payment-session, and confirm-free can return 409 with code: "CHECKOUT_PREPARED_PAYMENT_CONFLICT". That response guarantees the requested checkout mutation was not persisted. It does not guarantee that a previous payment intent is still usable: provider cancellation may already have landed. Discard any held clientSecret and its Payment Element, branch on the scalar details.reason value (never on message), and recover as follows:

The envelope exposes neither the binding digest nor payment generation and adds no discriminator, detail field, or reason. It therefore cannot distinguish a permanent legacy NULL-digest quarantine from a current lineage whose generation was already invalidated/admitted or moved concurrently. For PREPARED_PAYMENT_SESSION_MOVED or PREPARED_PAYMENT_CANCEL_CONTENDED, refresh and follow current state and discard every prior secret. If refreshed state is CONFIRMED, render/follow the durable completed order or status; do not retry or start a new checkout. If it is definitively CANCELLED, EXPIRED, ABANDONED, or otherwise nonpayable, abandon it and start a new checkout. If it is active, follow the current binding and summary: re-bind only if needed, then use confirm-free when the current bound summary's requiresFreeConfirmation is true, or payment-session when false, with at most one appropriate retry. If the same conflict persists, refresh once more. Latest CONFIRMED state still wins and must be followed; a latest definitively dead/nonpayable state may restart. If latest state remains active, stop automatic retry and automatic restart, discard every prior secret, keep following durable checkout/payment status, and surface a blocked recovery state for explicit safe resolution. Do not infer legacy quarantine from this envelope.

For a confirm-free PREPARED_PAYMENT_SESSION_MOVED, this request's transaction commits no order, but refreshed state may already be CONFIRMED because another lineage won. Follow that durable completed state; never restart a confirmed checkout. A persistent active conflict does not establish legacy quarantine; stop automatic retry/restart and keep following durable status for explicit safe resolution.

details.reasonRecovery
PREPARED_PAYMENT_SESSION_MOVEDFollow the bounded refresh/one-retry policy above. On a persistent conflict, refresh again: latest CONFIRMED wins; a definitively dead/nonpayable state may restart; an active state must stop automatic retry/restart and keep following durable status for explicit safe resolution.
PREPARED_PAYMENT_LINKAGE_CONTENDEDRefresh and bind if needed, then follow the current bound summary's requiresFreeConfirmation flag. Never reuse a secret from the failed request.
PREPARED_PAYMENT_LINKAGE_CONVERGEDBenign concurrent convergence, not a shopper-facing error. Retry the matching completion flow after refresh.
PREPARED_PAYMENT_IN_FLIGHTFreeze checkout editing, show a processing state, and wait for durable payment status before proceeding.
PREPARED_PAYMENT_CANCEL_AMBIGUOUSThe edit was not saved and release is unproven. Refresh state and retry the checkout operation, then follow the current bound summary's requiresFreeConfirmation flag.
PREPARED_PAYMENT_CANCEL_CONTENDEDThe edit was not saved. Follow the bounded refresh/one-retry policy above. On a persistent conflict, refresh again: latest CONFIRMED wins; a definitively dead/nonpayable state may restart; an active state must stop automatic retry/restart and keep following durable status for explicit safe resolution.

details.preparedPaymentReleased is a boolean. It is true only when cartonium confirmed provider cancellation of the exact prepared PaymentIntent; false means release was not proven, not that the previous intent is definitely alive. On the no-charge confirm-free compatibility path, false can mean no PaymentIntent existed for that operation. Do not abandon or restart the checkout solely because of this response, infer that a prior intent is alive, or treat a persistent active conflict as safe to retry automatically.

Saved cards and same-card retry (dormant)

Saved cards serve tenant-built (BYO) and hosted cartonium checkout one-time checkouts alike; none of the routes below distinguishes them. The flow ships dormant. Until its rollout activates it, a tenant-built checkout carries no frozen payment policy, so the availability read answers UNAVAILABLE (typically NOT_POLICY_BEARING) and the list and preference routes answer the neutral 404 described below. Build against the contract, keep card entry as the default, and offer saved cards only after the availability read answers AVAILABLE.

Admission. Saved cards belong to a signed-in customer, never to a guest:

  1. Sign the shopper in through the passwordless flow in Customer accounts & sign-in. Keep the session token on your backend.
  2. Create and reprice the checkout with that customer's verified email.
  3. Bind with x-organization-slug and the session as Authorization: Bearer <session-token>. Bind records that exact session as the checkout's verified binding.
  4. Send the same bearer on every saved-card route below, with x-organization-slug, and on payment-session.

Every saved-card route serves only an open, bound, one-time checkout whose verified binding is that exact live session. A missing, invalid or expired session, or one from another tenant, answers 401. Every other caller gets a neutral 404 (RESOURCE_NOT_FOUND): a guest checkout, another customer or session, or an unbound, booking or subscription checkout. Treat it as "no saved cards here", keep card entry, and never branch on its message.

Read availability, then the cards. Call both from your backend:

const customerHeaders = {
  "x-organization-slug": tenantSlug,
  authorization: `Bearer ${customerSessionToken}`, // never sent to the browser
};
 
const availability = await api(
  `/public/checkout/sessions/${session.token}/saved-card-availability`,
  { headers: customerHeaders },
);
availability.status; // "AVAILABLE" or "UNAVAILABLE", with a reason
 
const options = await api(
  `/public/checkout/sessions/${session.token}/saved-payment-methods`,
  { headers: customerHeaders },
);
options.methods; // customer-safe ids, never provider ids
options.selectedPaymentMethodId; // this generation's recorded card, or null
options.savePaymentMethod; // this generation's recorded save consent

AVAILABLE is visibility, not purchase authority: payment preparation decides again under its own locks and may still refuse. Every other answer carries a reason whose remedy the API reference documents; offer no saved card for any of them. Responses are private and must not be cached, and paymentGeneration alone does not make one stable, because a change to the store's payment account can change the answer within a generation. In the list, an empty methods array means no saved card can be selected now, and a 409 means the recorded card is no longer selectable: record a new choice.

Record the shopper's choice. The preference write is the only writer of the choice:

await api(`/public/checkout/sessions/${session.token}/saved-card-preference`, {
  method: "PUT",
  headers: customerHeaders,
  body: JSON.stringify({
    selectedPaymentMethodId: chosen.id, // or null to enter a new card
    savePaymentMethod: false, // true only on the shopper's affirmative consent
  }),
});

A changed choice first retires any prepared PaymentIntent, with provider-proven cancellation, then advances the payment generation. Discard any held client secret and request a new payment session. An identical replay changes nothing. The response has the list's shape. A retirement that cannot be proven returns the prepared-payment 409 above.

Present exactly that choice. Payment-session takes the choice the page presents, which must match the recorded one:

const handoff = await api(
  `/public/checkout/sessions/${session.token}/payment-session`,
  {
    method: "POST",
    headers: { authorization: `Bearer ${customerSessionToken}` },
    body: JSON.stringify({
      presentedSavedCardChoice: {
        selectedPaymentMethodId: options.selectedPaymentMethodId,
        savePaymentMethod: options.savePaymentMethod,
      },
      supportsSameCardRetry: true,
    }),
  },
);

Omit presentedSavedCardChoice, or send null, only while the page presents new-card entry without save consent. A presented choice that differs from the recorded one returns 409 with code: "CHECKOUT_SAVED_CARD_CHOICE_MISMATCH" and no client secret. Do not show card entry as a substitute: present the recorded choice, or record a new one, then ask again.

With a saved card recorded and presented, cartonium attaches that card to the PaymentIntent before it releases the secret. Confirm it without card fields; Stripe.js still runs any authentication the card needs:

const { error } = await stripe.confirmPayment({
  clientSecret: handoff.clientSecret,
  confirmParams: { return_url: returnUrl },
  redirect: "if_required",
});

These ordinary responses carry no payment-method identifier.

Try the same card again after a decline. When that confirmation fails with error.type === "card_error" and error.code === "card_declined", the PaymentIntent waits with its card slot empty, so confirming the same secret again as it stands cannot succeed. Offer the shopper a deliberate "try this card again" action beside another way to pay. On that action only:

  1. Request payment-session again with supportsSameCardRetry: true and the same presentedSavedCardChoice.
  2. A response that carries sameCardRetry returns the same PaymentIntent's clientSecret, with status requires_payment_method. Keep sameCardRetry.paymentMethod with that secret alone.
  3. When the shopper presses Pay, confirm that secret with exactly that card:
await stripe.confirmPayment({
  clientSecret: retry.clientSecret,
  confirmParams: {
    return_url: returnUrl,
    payment_method: retry.sameCardRetry.paymentMethod,
  },
  redirect: "if_required",
});

Never request, prepare or confirm a retry automatically on page load, reload or network recovery. Never pair sameCardRetry.paymentMethod with another secret or send it to a cartonium route. The server writes nothing to the payment provider for this handoff. It requires current provider proof that the issuer declined exactly the recorded card; a Radar block, a failed authentication, another card, or a newer or cleared failure gets no retry. The retry can still need authentication, and the bank can decline it again; a newer decline is retried the same way. Earlier client secrets stay valid.

These answers return no client secret:

AnswerMeaning and remedy
PREPARED_PAYMENT_SAME_CARD_RETRY_UNAVAILABLEThe recorded card cannot be confirmed again on this payment: there is no current provider proof of an issuer decline of it, or the request did not declare supportsSameCardRetry. Offer another way to pay; repeating the request does not help.
PREPARED_PAYMENT_IN_FLIGHTA confirmation through an earlier secret is processing. Wait for payment status, as the shared table says.
PREPARED_PAYMENT_RECOVERY_REQUIREDThe payment provider finally cancelled the saved card's PaymentIntent after its client secret could have been released. Recover as described below.
PREPARED_PAYMENT_DELIBERATE_RECOVERY_REQUIREDAn earlier write of the saved card into this PaymentIntent has no recorded outcome yet and may still execute, so cartonium releases no secret for it while that outcome stays unresolved. Recover as described below.
CHECKOUT_ALREADY_PAIDPaid truth answers first. Follow the paid posture above.
HTTP 502On a retry request, this can mean the decline evidence could not be read. Nothing was written, so the shopper may ask again or choose another way to pay.

The first four arrive as details.reason of CHECKOUT_PREPARED_PAYMENT_CONFLICT (HTTP 409). The first, third and fourth belong to this route alone, outside the shared table. To offer another way to pay, record the new choice through the preference (another saved card, or selectedPaymentMethodId: null for card entry), then request a new payment session. A client that omits supportsSameCardRetry, or sends false, keeps the earlier refusals after a saved-card decline: PREPARED_PAYMENT_SAME_CARD_RETRY_UNAVAILABLE, or, once the failure is recorded, a generic 409 with code: "RESOURCE_CONFLICT". Both mean: offer another way to pay. A storefront that records no saved card never receives sameCardRetry or these route-only reasons.

Recover from PREPARED_PAYMENT_RECOVERY_REQUIRED or PREPARED_PAYMENT_DELIBERATE_RECOVERY_REQUIRED. No replacement is prepared automatically, and recovery stays on this checkout token; starting a new checkout is not this exit. Discard the secret and payment controls, then:

  1. Reprice with the current cart. An unchanged reprice proves the cancellation, advances the payment generation and leaves the checkout unbound; payment-session refuses until you bind.
  2. Bind the refreshed checkout.
  3. Let the shopper review the refreshed total and saved-card availability. The new generation carries no recorded choice.
  4. The shopper chooses again, explicitly, through the preference. The same saved card is allowed; never record it on the shopper's behalf.
  5. Request a payment session. It prepares a new PaymentIntent and client secret, without sameCardRetry.

Sending the identical preference before the reprice is a no-op within the same generation, so payment-session keeps answering the same reason (for PREPARED_PAYMENT_DELIBERATE_RECOVERY_REQUIRED, while that earlier write's outcome stays unresolved). The cancelled PaymentIntent stays cancelled, and no server path writes a card into it.

For PREPARED_PAYMENT_DELIBERATE_RECOVERY_REQUIRED, the reprice itself cancels the PaymentIntent, and it advances the payment generation only once the payment provider confirms the cancellation. Three other answers are possible:

  • PREPARED_PAYMENT_CANCEL_AMBIGUOUS: the cancellation could not be proven, and this request advanced neither the payment generation nor the total. Refresh, tell the shopper recovery could not finish yet, and let them try again later.
  • PREPARED_PAYMENT_CANCEL_CONTENDED: another request changed this checkout first, and this reprice was not saved. Follow the bounded refresh/one-retry policy for this reason in the shared table: refresh and follow the current state, which may already show a recovery that another request completed.
  • PREPARED_PAYMENT_IN_FLIGHT: a payment through an earlier secret is already processing. Stop recovering and follow payment status, as the shared table says.

If the request that made that earlier write later receives the payment provider's answer, it resolves the outcome, and a later payment-session can return the same PaymentIntent's secret without a reprice. The recovery above does not depend on that.

details.preparedPaymentReleased is always false for this reason. It describes this response only.

6. Confirm a no-charge checkout

POST /api/v1/public/checkout/sessions/:token/confirm-free

When the current bound summary has requiresFreeConfirmation: true, send x-organization-slug and use confirm-free instead of requesting a Stripe payment session. The flag covers a zero total and a positive total below the payment provider's per-currency minimum. Do not implement that threshold in the client or choose the route from the amount alone. The server re-verifies eligibility and creates the confirmed order through the same atomic inventory, coupon, and order-link machinery used by paid confirmation. When the bound flag is false, use payment-session. Booking, booking-group and subscription checkouts have no no-charge completion path and retain their own handoff contracts.

An ordinary one-time session fails closed with 409 and code: "CHECKOUT_PREPARED_PAYMENT_CONFLICT" when its legacy binding has no semantic digest, its current payment generation is already invalidated, or its binding lineage moves during the final locked order-link gate. This route uses the existing details.reason: "PREPARED_PAYMENT_SESSION_MOVED" value and details.preparedPaymentReleased: false; it does not introduce another reason. This request's transaction commits no order, but refreshed state may already be CONFIRMED because another lineage won. Because the response exposes neither digest nor generation, use the bounded policy above: refresh and follow current state, discard prior secrets, follow the durable order/status without restarting when state is CONFIRMED, and otherwise follow the active session's current binding and requiresFreeConfirmation flag with at most one appropriate retry. If that retry returns the same conflict, refresh once more: latest CONFIRMED still wins. A latest definitively cancelled, expired, abandoned, or otherwise nonpayable state may start a new checkout. If latest state remains active, stop automatic retry and automatic restart, discard every prior secret, keep following durable checkout/payment status, and surface a blocked recovery state for explicit safe resolution. Persistent active conflict does not establish legacy quarantine.

The other 409 on this route is the reservation conflict: the checkout could not reserve the inventory it needed at confirmation — stock may have moved after binding, or the shortfall may not have been measurable at admission. It carries code: "RESOURCE_CONFLICT" with details.reason: "INSUFFICIENT_INVENTORY" and a details.line describing the offending line:

{
  "code": "RESOURCE_CONFLICT",
  "message": "Items in this checkout are no longer available",
  "details": {
    "reason": "INSUFFICIENT_INVENTORY",
    "line": {
      "itemId": "2f0d9a6c-6e2f-4a71-9a63-0f2f1a5b7c31",
      "variantId": null,
      "name": "Facial Serum Unscented 30ml",
      "requested": 2,
      "available": 1
    }
  }
}

Reservation stops at the first short line, so exactly one line is reported even when several are short. Every details.line field is nullable or absent: itemId, variantId and name may each be null (the envelope degrades to nulls when the API cannot read the engine's failure details, while details.reason still classifies the conflict), and requested/available are omitted when the reservation failed without measuring a shortfall — the catalog item was missing, or an inventory row that resolved for the line vanished before it could be locked. An inventory row that was never there does not raise this conflict at all: reservation treats the absent row as unbounded and the line confirms. Read every field defensively and render only what you were given.

Unlike the conflict above, this one has no retry policy, because there is nothing left to retry against: the session is already CANCELLED when the response is returned. A later confirm-free or payment-session call on that token is the neutral 404; the session read still returns the CANCELLED summary, which is how a hosted surface renders the cancelled state. Do not refresh-and-follow, do not re-request, and do not resurrect the session. Tell the shopper that nothing was charged and no order was placed, then send them back to the store to start a new checkout at a quantity the stock can fill. Name the line and its counts only when the envelope carries them: with name and both counts, say which line could not be filled and how many are available; with a name but no counts, say only that the line is no longer available; when even name is null, keep to the generic statement. Never fabricate a count or render a placeholder for a field the API omitted.

7. Confirm payment client-side, trust the webhook server-side

The browser confirms the Payment Element with Stripe. The customer can see an optimistic processing state, but the order is not final until cartonium receives the Stripe webhook, marks the payment attempt, and creates the order. Read the checkout session or customer/order status surfaces for the durable result.

8. Reconcile the cart when the shopper comes back

A storefront that keeps its own cart has to decide what to remove once a checkout succeeds. The session read is the right source for that — but only inside the window where it answers, and only if you read its 404 correctly.

The checkout session is a short-lived credential, not the order record. A session carries an expiresAt (currently 30 minutes from create, refreshed on each reprice and on bind). Read that field from the summary rather than hard-coding the duration; it is the only value that stays correct if the window is ever tuned.

Past expiresAt, an ordinary one-time session read returns the neutral 404 whatever its status — including a session that was confirmed and paid. Expiry gates the read itself, so a successful purchase reads CONFIRMED before the window closes and 404 afterwards, with no change to the order in between. The order, the payment, and the merchant's record are all unaffected. (Subscription checkouts, which remain capability-gated, resolve expiry through their own origin-invoice contract and are not covered by this rule.)

There is one exception to the expiry gate, and it is worth handling. (Only to that gate: other access guards run before reconciliation and return the same neutral 404 whatever the payment state — lifecycle readiness, and in non-production a retired test identity. Reaching the exception below is not guaranteed.) If the session is still bound and unconfirmed when you read it, and reconciliation observes its payment as successful during that read, the read finalizes it and answers 200 even though expiresAt has passed — usually CONFIRMED, but CANCELLED with refundInitiated: true when the charge could not become an order and a refund has been initiated for it. Both are real terminal answers, and the second is a legitimate outcome rather than an impossible state — but note what the flag actually claims: the refund has been enqueued, not that it has settled. Present it as in progress, never as money already returned.

A successful payment does not guarantee this response. That reconciliation is best-effort: a provider hiccup, a deferral inside its grace window, or any non-success answer all fall through to the ordinary 404, and the webhook settles the terminal outcome afterwards regardless — which may be a confirmed order or a refund, not necessarily an order. So treat the 200 as a bonus when it arrives, never as something to wait for or to require.

Persist that answer when you get it, because a later read of the same token returns the 404 again. Make persisting it idempotent: two tabs reading concurrently can both pass the gate and both receive the terminal answer, so a reducer that removes quantities once per successful read removes them twice. Key the removal to the resulting order, not to the fact that a read succeeded.

That 404 is deliberately the same response for every denial — an unknown or malformed token, a token belonging to another tenant, a session that expired unpaid, and a session that was paid, confirmed, and then passed its expiresAt are indistinguishable from outside. Non-production environments can add their own test-fixture denials on top, using the same response. The 404 therefore carries exactly one piece of information: this token will not answer. It is never evidence that a purchase did not happen.

So the rule for a cart reducer is:

  • 200 is the only proof. status: "CONFIRMED" with an orderNumber and orderReference means the purchase completed; that is when you remove exactly the purchased quantities.
  • Treat 404 as "no answer", never as "not purchased". In particular, do not use a 404 to discard the marker, fingerprint, or pending record that is your only means of reconciling later — that is how a paid basket survives into a fresh checkout. A 404 retires nothing.
  • Retain the marker until you have positive proof. The checkout TTL is not a reconciliation deadline. No durable post-purchase surface carries a checkout-session identifier — the order-status page needs its separately emailed token, and the account order routes publish no checkout linkage — so once the marker is gone, the purchase cannot be tied back to this checkout from anywhere. (The session read itself is of course addressed by the checkout token; it is simply the surface that stops answering. The dormant purchase-proof read is no exception: once activated it can outlive the session, but only until your own capture deadline, at most four hours after creation, and it answers only to a key you keep with the marker, so losing the marker loses it too.) Keep it until a 200 CONFIRMED read, or another proof you can explicitly correlate to this exact checkout, retires it. If you also need a hard ceiling, set it from your own retention policy, well beyond the session window, and never from expiresAt.
  • Durable post-purchase truth lives elsewhere, and is not bounded by the checkout TTL. The authenticated customer-account order routes are always available to a signed-in shopper. The tokenized order status page is the guest route, but it reaches the shopper only through the confirmation email, and delivery is not guaranteed — environment policy can disable it and a send can fail. Treat the emailed link as the shopper's likely route, not a guaranteed one, and the account route as the one that is always there. Send a returning shopper to whichever applies, rather than back into checkout.

A hosted cartonium checkout surface applies the same rule: when a checkout link no longer reads, it offers the account continuation — alongside a return to the store whenever the tenant has a trusted recovery destination configured for that environment — and tells a shopper who already paid to check before paying again, rather than deciding for them. That wording is deliberate: the same denied link is shown for the charged-then-refund-enqueued session above, whose shopper has no order and may well need to pay again. A BYO surface should not offer a bare "try again" on an unreadable link either, nor promise that no further payment is needed.

Hosted creation and restart: tenant quota refusals (dormant, do not adopt yet). The hosted creation and restart operations are dormant contracts: do not adopt them before their separately authorized activation. This describes their quota behaviour in advance. When either answers 429 RATE_LIMIT_EXCEEDED, the tenant's daily public API quota is spent. The refusal came before the operation's own work and proves nothing about an earlier send of the same key, so keep that key and the exact body. When details.retryAfterSeconds is an integer JSON number from 1 to 86400, never retry automatically and do not count the refusal toward the bounded caller retry that both operations publish in the API reference (at most three more attempts after a transport failure or a SAME_REQUEST_LATER refusal); send the identical request again only on a new caller decision once that wait has passed, counted from receipt. Retry-After repeats the same value; if Retry-After is a whole number of seconds from 1 to 86400 and differs, wait for the larger of the two; ignore any other Retry-After, including an HTTP-date. Never derive the wait from details.resetsAt or compare it with a client clock. A RATE_LIMIT_EXCEEDED refusal whose details.retryAfterSeconds is missing or is not an integer JSON number from 1 to 86400 is still this quota refusal but has no usable wait, even with a valid Retry-After: it is never retried automatically. Any other 429, including one whose body cannot be read, is not this refusal: it is never retried automatically. Waiting never extends the checkout's lifetime, so the request may then answer the neutral 404; never rotate the key to escape it. This 429 is unrelated to the policy 503 reason RATE_LIMITED. Do not show the plan, limit or reset time to shoppers; show a conservative, rounded-up wait, offer the retry only on a shopper action, and never promise that the checkout will still be recoverable.

Hosted refresh successor purchase proof (dormant)

Dormant: do not adopt yet. This is a staged contract, not an available feature. Hosted creation, restart and both purchase-proof routes below answer 503 HOSTED_CHECKOUT_POLICY_UNAVAILABLE with reason NOT_ACTIVATED until their separately authorized activation; the proof routes refuse before the key is read. Nothing here changes what any checkout answers today, and nothing here promises when, or whether, the read becomes available. It is published in advance so that hosted adopters can plan for it: do not send a grant or call these routes before that activation. Until then, every attempt you cannot prove stays explicitly unresolved.

When a hosted checkout expires, its Refresh checkout action moves the shopper onto a successor session your storefront never created. The session read of the checkout you registered can then only answer the neutral 404, even when the shopper paid on that successor. Once activated, the purchase-proof read closes that gap for one population only: an original created through POST /api/v1/public/hosted-checkout/sessions with a purchaseProofGrant, and the successors the platform links to it through its own restart lineage, at most 32 of them. A session created without a grant never gains one, and generic creation and restart refuse the member. A successor created any other way, or since deleted, is not in that lineage, and its purchase reads as the neutral 404.

Generate and persist the key before the original creation. The grant has three members, all fixed when you prepare the original creation request:

  • capability: the secret purchase-proof key, one per checkout attempt. It is the literal v followed by 43 base64url characters that encode 32 bytes from a cryptographically secure random generator. The API checks format, not randomness: the pattern establishes neither strength nor possession, so a predictable key is accepted as readily as a random one. Never derive it from the creation recovery key, an attempt or customer identifier, or any other predictable input. The platform stores only a hash, never returns the key, and cannot recover it.
  • captureCorrelation: a non-secret, unguessable identifier, 16 to 64 base64url characters, of the basket capture (the lines and line generations) this attempt hands to checkout. It is unique per tenant and environment: a creation under a different recovery key that reuses a correlation another checkout already holds answers the generic 409. Disclosure alone causes no conflict, and an exact retry under your own recovery key converges on the original session.
  • captureDeadlineAt: the absolute UTC instant at which your storefront stops reading for this capture, in ISO-8601 ending in Z with at most millisecond precision.

Generate the key, then durably persist the prepared request (key, recovery key and exact body) in server-side custody under the controls your checkout token requires, and only then send it. The write must be acknowledged before the request leaves. If it fails, do not send the key: continue without a grant, which is today's unresolved path, or stop. Serialize preparation per attempt, so that concurrent tabs or processes cannot create different keys for one attempt. Retry with the same key, recovery key and byte-identical body, captureDeadlineAt included. Never generate a replacement for an attempt whose response was lost: an exact retry converges on the original session and its grant, and a changed member under the same recovery key is the existing 409. Keep the key out of URLs, client storage, logs and the hosted handoff, and destroy it when the attempt settles or its deadline passes. A lost key cannot be recovered or reissued, so that attempt stays unresolved and nothing is removed.

The lifetime is the capture's own, capped at four hours. The platform stores the grant's expiry once, in the transaction that creates the original session: the earlier of captureDeadlineAt and four hours after that transaction starts (T0), by its database clock. Refresh, restart, retries, reloads, re-registration and the read itself never extend it or start a fresh window, and nothing guarantees an answer for the whole window. If captureDeadlineAt is at or before T0, the session is created without a grant and the attempt stays on the unresolved path. A deadline that passes after T0 but before creation commits still leaves a grant, already expired, whose read answers the neutral 404. Stop reading at your own capture deadline; after it, leave the basket alone.

Read from your server, and remove only on an exact match. Send GET /api/v1/public/hosted-checkout/purchase-proof with x-organization-slug and the key, exactly once, in x-checkout-purchase-proof-capability. Only your storefront server can make this call: browsers cannot send that header, and the key authorizes no other route. The read writes nothing, reconciles nothing and grants no checkout, order, account or payment access, but it counts toward the tenant's daily public API quota. A 200 carries purchaseId, captureCorrelation and selections, with no price, total, order number, customer or status.

  • Remove purchased quantities only when captureCorrelation equals the capture you recorded and selections exactly equals that capture's lines as a set (order does not matter). Any other 200 keeps the attempt unresolved and removes nothing.
  • Subtract only from the line generations that capture recorded. Items added after checkout began, later increments and remove/re-add generations survive.
  • Remove inside your locked, generation-aware cart transaction, or an equivalent fence you can prove, and settle the attempt only after that transaction durably commits. Replaying the same 200 before the commit is safe, and after it is a no-op, so the purchase is consumed once. A failed read, lock or storage write consumes nothing and keeps the key.
  • Key local deduplication to the capture and its line generations. purchaseId is the same on every read of the grant and is an additional key, never a replacement. It is not the session read's orderReference, so never deduplicate across the two reads by an order key alone.

Every other outcome leaves the basket alone. The neutral 404 (RESOURCE_NOT_FOUND, Checkout session not found) is one byte-identical answer for a missing, repeated, malformed, unknown, expired, revoked or purged key; a key from another tenant or environment; no linked Order, including a purchase paid but not yet linked, a refund enqueued without an Order and an Order that has since gone; a lineage past its bound; more than one winner; and any projection failure. Like the session read's 404, it means only this key will not answer, never that nothing was bought. Refusals decided before the key is read (400, or a 404 Organization not found), 429, the dormant 503, any other 5xx and a malformed 200 are no answer either. In every one of these cases, keep the attempt unresolved, keep the key until settlement or the deadline, and remove nothing. An attempt that holds a key is also not settled by an unpaid terminal read of its root: once a successor can carry the purchase, a root that reads expired, abandoned or cancelled no longer proves that nothing was bought.

To end the read early, your server can revoke the key with POST /api/v1/public/hosted-checkout/purchase-proof/revoke and the same header. For a validly formatted key it answers 204 whether or not the key was known. A missing, repeated or malformed header answers the neutral 404 and revokes nothing, so treat any answer other than 204 as not revoked; retrying is safe. Revocation is permanent, applies to reads that start after it commits, cannot recall what a read already disclosed, and changes no purchase.

Return-path regression matrix

The same denial is reachable from every way a shopper can come back, and the tab that started checkout is frequently not the tab that returns. Two independent clocks matter — the platform's expiresAt, and whatever horizon your own pending marker has. They are not the same length, so cover both boundaries rather than assuming one implies the other.

Each row must end in one of exactly two acceptable states:

  • Reconciled — a 200 CONFIRMED read (or other explicitly correlatable proof) arrived, and exactly the purchased quantities were removed, once.
  • Explicitly unresolved — no such proof arrived. The marker is retained and nothing is removed. This is a correct, safe outcome, not a failure, and it must be distinguishable in your own state from the reconciled one.

Opening an account or status surface is not reconciliation: neither can be correlated back to the expired checkout token, so neither retires the marker. No row may remove a quantity or discard the marker without proof, no row may offer a second payment for goods already bought, and items added after checkout began survive every row.

Return pathBoth clocks liveSession expired, marker still heldMarker also gone
Original tab, return-to-store linkReconciles from CONFIRMED404 — retain marker, stay unresolvedNo correlation state; remove nothing
Original tab, account/order-history linkReconcile from the session read only — the account view itself is not proof404 — retain marker, stay unresolvedNo correlation state; remove nothing
Original tab closed, then reopenedWhatever survived tab-scoped storage404 — retain marker, stay unresolvedNo correlation state; remove nothing
Tab already open before checkout startedHolds pre-checkout state — verify it is not stale404 — retain marker, stay unresolvedNo correlation state; remove nothing
Back/forward restoration of a cached pageMay restore in-memory state from before payment404 — retain marker, stay unresolvedNo correlation state; remove nothing
Independently opened tab carrying the same c tokenCannot see tab-scoped state404 — retain marker, stay unresolvedNo correlation state; remove nothing
Opener-created tab (target="_blank")Inherits the opener's session storage404 — retain marker, stay unresolvedNo correlation state; remove nothing
Duplicated tabCopies session storage — verify, do not assume404 — retain marker, stay unresolvedNo correlation state; remove nothing
Hosted refresh successor, original created with a grant (dormant)Session read, else the proof read: an exact-match 200 reconciles onceProof read only: an exact-match 200 reconciles once; any other answer stays unresolved, nothing removedKey, capture or capture deadline gone: remove nothing
Immediate reload of the checkout URLConfirmed once finalization lands — until then it can legitimately still read OPEN or PENDING_PAYMENTOne-shot 200 if the payment self-heals on that read, else the neutral 404 surfaceNeutral 404 surface

Every "404" cell above is the expected post-expiry answer, not a guarantee: the self-healing read described earlier can turn any first post-expiry read of a bound, paid-but-unconfirmed session into a terminal 200. So never discard state because a 404 was what you predicted — but read the 200 before acting on it. Only CONFIRMED carrying orderNumber and orderReference, or another proof you can explicitly correlate to this checkout, retires the marker and removes quantities. A 200 CANCELLED with refundInitiated is terminal too, but there is no order and nothing to remove; OPEN and PENDING_PAYMENT are not terminal at all and stay unresolved. The status code alone proves nothing.

Read the rows as browser contexts the shopper comes back in, not as URLs that carry the checkout token. Only the last row revisits /session/<c-token> directly; the others land on your storefront, and it is your reducer that then performs the session read using the token it retained. That is why a context which cannot see your retained marker — an independently opened tab — reconciles nothing regardless of the clock: the columns describe what the read answers when you can still make it, and the row describes whether you can.

The hosted refresh successor row is a checkout lineage, not a browser context. It applies in any context above that can still reach your retained marker and key, whether the shopper paid on the original or on a successor in its restart lineage. Its proof read is dormant: until activation it answers only 503 with reason NOT_ACTIVATED, so the row reduces to the session read alone, and anything that read cannot prove stays explicitly unresolved.

The confirmation email's link is a different surface again — /order/<o-token>/status — which performs no checkout-session read and never produces these answers. Keep it out of this matrix, and reconcile from it only through an explicitly correlated order proof.

The third column is the state to design for deliberately: the shopper's cart may still hold purchased lines with no evidence left to prove it. Leave them as the shopper left them and let the shopper decide — silently re-offering that cart as a fresh checkout is the path that ends in a second charge.

Also cover the non-purchase outcomes against the same paths — OPEN, PENDING_PAYMENT, an unpaid terminal session, a transient transport failure, and a malformed response — and prove that items added after checkout began survive every one of them.

Access model

  • Checkout session create/reprice/read and the shipping-zones read use x-organization-slug and no bearer token.
  • Bind and confirm-free use x-organization-slug. When the checkout email belongs to a verified customer, they also require that customer's live CustomerSession as bearer auth; a guest email needs none.
  • The payment-session route needs no x-organization-slug: the opaque checkout token resolves the tenant. It takes the same optional CustomerSession bearer, required under the same verified-email rule.
  • The dormant saved-card routes use x-organization-slug plus the CustomerSession bearer that bind recorded.
  • Customer account routes use x-organization-slug plus CustomerSession bearer auth.
  • Scoped customer action links use their token plus tenant context.

Adjacent checkout modes

Bookings now use the same CheckoutSession, PaymentAttempt, webhook, and Sales rails after a booking hold or approval becomes payable, but their availability and capacity contract is separate from an ordinary retail cart. Start with Booking availability, holds & lifecycle; do not put a booking into lines on the retail session create route.

Shared-commerce quote/invoice payment actions also have their own entry routes before they reach the payment rail. Subscriptions remain capability-gated; do not invent renewal, prepaid-term, or recurring-payment APIs until they appear in the OpenAPI reference.