Skip to content

BYO checkout API

BYO checkout means the tenant owns the checkout UI, but litecommerce 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 litecheckout uses under the hood. The hosted litecheckout 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 ownslitecommerce owns
UICart, checkout form, Payment Element container, confirmation/status pagesNone
Tenant contextx-organization-slug on standard public checkout callsResolving the organization 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 litecommerce for the payment handoff and renders the returned client secret.

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.

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: "item_...", variantId: "variant_...", 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.

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, resend that value as shippingRateId on the next reprice (to preview the new total) and on bind (to charge it). litecommerce, 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 customer-confirmed option's rateId as shippingRateId. 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 persist a rate id across re-quotes, 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. When a prior selection is no longer in the refreshed set, clear it and require the shopper to explicitly reconfirm a delivery method: 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.

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.

For an ordinary one-time checkout, litecommerce 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 shipping silently dropped (bind is the commitment, reprice is a mutable preview), so a preview will not reveal the dead selection. Address validation instead rides a successful reprice as guidance. 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 → reconfirm → bind again (below).
SHIPPING_RATE_NOT_APPLICABLE_TO_DESTINATIONNo active zone serves the captured destination.Re-quote → reconfirm → bind again (below).
SHIPPING_RATE_BELOW_SUBTOTAL_MINIMUMThe cart no longer meets the selected rate's configured subtotal minimum.Re-quote → reconfirm → bind again (below).
SHIPPING_DELIVERY_PROMISE_UNAVAILABLEThe selected delivery promise can no longer be presented.Re-quote → reconfirm → 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. Clear or reconfirm the shipping selection — replace the rendered option set wholesale; when the previous choice is absent from the fresh set, clear it and require an explicit shopper choice (see rateId is quote-scoped above — never re-match by label).
  3. Bind again with the reconfirmed shippingRateId.

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, have the shopper confirm a shipping method, 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 litecommerce 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, and the handoff response does not carry it — there is no method-list field to read. 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.

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.

Recover from a prepared-payment conflict

During a checkout activation rollout, reprice, bind, payment-session, or confirm-free can instead return a retryable 503. No checkout mutation, order, PaymentAttempt, or provider operation was started by that request. The request creates no durable server state; retry with bounded backoff after the service reports ready. Do not interpret this rollout response as one of the six prepared-payment conflict reasons.

Check the HTTP status for this response before you look at code. The readiness rejection is thrown without a code of its own, so the envelope falls through to the generic mapping every unclassified 5xx shares — and what arrives on the wire is code: "INTERNAL_ERROR".

Be precise about that, because the obvious reading is wrong: the response does not omit code. Every litecommerce error body carries one. There is simply no 503-specific value to match on, and the card-only 502 above reports INTERNAL_ERROR for the same reason. A client that keys recovery off code alone reads a retryable rollout response as a server crash and strands a checkout that only needed a bounded retry.

So on the checkout routes, order the branches this way:

  1. HTTP 503 → retryable readiness. Back off and retry; ignore that the body says INTERNAL_ERROR.
  2. HTTP 502 → the card-only handoff refusal described above.
  3. Otherwise, branch on code, which is meaningful everywhere else.

This ordering applies to these two responses specifically, not to 5xx as a class. An error that carries a distinctive code keeps it on any status — do not invert this into "ignore code on 5xx".

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 server-bound total: re-bind only if needed, use confirm-free for zero, or use payment-session for positive, 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 same zero-versus-positive branch. 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 zero-versus-positive completion branch.
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 litecommerce confirmed provider cancellation of the exact prepared PaymentIntent; false means release was not proven, not that the previous intent is definitely alive. On the zero-total 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.

6. Confirm a zero-total checkout

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

When the current binding total is exactly zero, send x-organization-slug and use confirm-free instead of requesting a Stripe payment session. The server re-verifies the zero total and creates the confirmed order through the same atomic inventory, coupon, and order-link machinery used by paid confirmation.

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 server-bound total 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 litecommerce 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.) 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 litecheckout 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.

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
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 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/bind/read/confirm-free and the shipping-zones read use x-organization-slug and no bearer token.
  • The payment-session route uses only the opaque checkout token.
  • 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.