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
| Area | BYO storefront owns | litecommerce owns |
|---|---|---|
| UI | Cart, checkout form, Payment Element container, confirmation/status pages | None |
| Tenant context | x-organization-slug on standard public checkout calls | Resolving the organization and enforcing tenant scope |
| Pricing | Item refs, quantities, coupon code, customer/contact/address inputs, shipping-rate selection | Live repricing, discounts, shipping rates, tax snapshot, binding total |
| Payment | Rendering Stripe Payment Element with the returned publishableKey, optional stripeAccountId, and clientSecret | Creating/reusing Stripe objects through PaymentAttempt |
| Order creation | Status polling / confirmation UX | Webhook-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 estimateFor 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.reason | Meaning | Recovery |
|---|---|---|
SHIPPING_RATE_NOT_QUOTABLE | The 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_DESTINATION | No active zone serves the captured destination. | Re-quote → reconfirm → bind again (below). |
SHIPPING_RATE_BELOW_SUBTOTAL_MINIMUM | The cart no longer meets the selected rate's configured subtotal minimum. | Re-quote → reconfirm → bind again (below). |
SHIPPING_DELIVERY_PROMISE_UNAVAILABLE | The selected delivery promise can no longer be presented. | Re-quote → reconfirm → bind again (below). |
SHIPPING_ADDRESS_REQUIRED | A rate was selected before any shipping/billing address was captured. | Capture the address, then re-quote and continue. |
SHIPPING_ADDRESS_INVALID | The 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_REQUIRED | The 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:
- Re-quote — reprice the session and read the fresh
availableShippingOptionsfrom the response. - 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
rateIdis quote-scoped above — never re-match by label). - 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:
- HTTP
503→ retryable readiness. Back off and retry; ignore that the body saysINTERNAL_ERROR. - HTTP
502→ the card-only handoff refusal described above. - 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.reason | Recovery |
|---|---|
PREPARED_PAYMENT_SESSION_MOVED | 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. |
PREPARED_PAYMENT_LINKAGE_CONTENDED | Refresh and bind if needed, then follow the same zero-versus-positive branch. Never reuse a secret from the failed request. |
PREPARED_PAYMENT_LINKAGE_CONVERGED | Benign concurrent convergence, not a shopper-facing error. Retry the matching completion flow after refresh. |
PREPARED_PAYMENT_IN_FLIGHT | Freeze checkout editing, show a processing state, and wait for durable payment status before proceeding. |
PREPARED_PAYMENT_CANCEL_AMBIGUOUS | The 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_CONTENDED | The 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:
200is the only proof.status: "CONFIRMED"with anorderNumberandorderReferencemeans the purchase completed; that is when you remove exactly the purchased quantities.- Treat
404as "no answer", never as "not purchased". In particular, do not use a404to 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. A404retires 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
200CONFIRMEDread, 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 fromexpiresAt. - 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
200CONFIRMEDread (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 path | Both clocks live | Session expired, marker still held | Marker also gone |
|---|---|---|---|
| Original tab, return-to-store link | Reconciles from CONFIRMED | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Original tab, account/order-history link | Reconcile from the session read only — the account view itself is not proof | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Original tab closed, then reopened | Whatever survived tab-scoped storage | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Tab already open before checkout started | Holds pre-checkout state — verify it is not stale | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Back/forward restoration of a cached page | May restore in-memory state from before payment | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
Independently opened tab carrying the same c token | Cannot see tab-scoped state | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
Opener-created tab (target="_blank") | Inherits the opener's session storage | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Duplicated tab | Copies session storage — verify, do not assume | 404 — retain marker, stay unresolved | No correlation state; remove nothing |
| Immediate reload of the checkout URL | Confirmed once finalization lands — until then it can legitimately still read OPEN or PENDING_PAYMENT | One-shot 200 if the payment self-heals on that read, else the neutral 404 surface | Neutral 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-slugand no bearer token. - The payment-session route uses only the opaque checkout token.
- Customer account routes use
x-organization-slugplusCustomerSessionbearer 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.
Related
- Storefront integration — how checkout fits with catalog, pricing previews, shipping, and returns.
- Orders & fulfillment — legacy order creation and merchant order management.
- Booking availability, holds & lifecycle — the booking search, capacity claim, approval, and checkout boundary.
- API reference — exact request and response schemas.