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
| Area | BYO storefront owns | cartonium owns |
|---|---|---|
| UI | Cart, checkout form, Payment Element container, confirmation/status pages | None |
| Tenant context | Current storefront origin on every call, plus x-organization-slug on standard public checkout calls | Resolving data through Domains 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 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:
| Evidence | What it establishes | Recovery |
|---|---|---|
HTTP 400 VALIDATION_FAILED, message: "Request body failed validation", details.violations array of strings identifying a UUID constraint | Request 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 ID | The 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/bind | A 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 data | The 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 estimateFor 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
shippingQuoteFailureorshippingUnavailable— 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
shippingRateIdand 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 isinvalid_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. WhenshippingQuoteFailureis 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.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 → resolve the selection → bind again (below). |
SHIPPING_RATE_NOT_APPLICABLE_TO_DESTINATION | No active zone serves the captured destination. | Re-quote → resolve the selection → bind again (below). |
SHIPPING_RATE_BELOW_SUBTOTAL_MINIMUM | The cart no longer meets the selected rate's configured subtotal minimum. | Re-quote → resolve the selection → bind again (below). |
SHIPPING_DELIVERY_PROMISE_UNAVAILABLE | The selected delivery promise can no longer be presented. | Re-quote → resolve the selection → 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. - 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 —shippingMethodisnull, including after a reprice that did not send the selection — clear it and read the fresh set (seerateIdis quote-scoped above — never re-match by label):- It lists methods — require an explicit shopper choice.
- It is empty and carries
shippingQuoteFailureorshippingUnavailable— 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
shippingRateIdand with no shipping charge.
- 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.applePayandwallets.googlePayareautoornever, as frozen when the payment was admitted.autolets the Element offer the wallet where the shopper's device and browser support it; it does not promise the wallet renders.wallets.linkis alwaysnever.- On the handoff,
basisis alwaysADMISSION: 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
walletsto 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.
state | Meaning | What your page does |
|---|---|---|
DISPLAY | basis 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. |
UNMARKED | The checkout carries no frozen payment policy. | Keep today's card entry. |
NO_PAYABLE_METHOD | The frozen payment policy admits no payment method. | Do not mount card entry; this checkout cannot be paid online. |
UNAVAILABLE | The checkout's payment policy cannot be used here; reason says why. | Do not mount card entry; the handoff would refuse. |
CHANGED | The 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
paymentPresentationare separate reads, so they can disagree when the checkout changes between them. The handoff wins. paymentGenerationnames 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 successfulpayment-entryread carriesUNMARKED.
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 operation | Recovery |
|---|---|
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_UNAVAILABLE | Follow 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 bind | The 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_ERROR | Reprice, 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 bind | A 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-session | The 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.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 current bound summary's requiresFreeConfirmation flag. 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 current bound summary's requiresFreeConfirmation flag. |
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
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:
- Sign the shopper in through the passwordless flow in Customer accounts & sign-in. Keep the session token on your backend.
- Create and reprice the checkout with that customer's verified email.
- Bind with
x-organization-slugand the session asAuthorization: Bearer <session-token>. Bind records that exact session as the checkout's verified binding. - 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 consentAVAILABLE 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:
- Request payment-session again with
supportsSameCardRetry: trueand the samepresentedSavedCardChoice. - A response that carries
sameCardRetryreturns the same PaymentIntent'sclientSecret, withstatusrequires_payment_method. KeepsameCardRetry.paymentMethodwith that secret alone. - 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:
| Answer | Meaning and remedy |
|---|---|
PREPARED_PAYMENT_SAME_CARD_RETRY_UNAVAILABLE | The 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_FLIGHT | A confirmation through an earlier secret is processing. Wait for payment status, as the shared table says. |
PREPARED_PAYMENT_RECOVERY_REQUIRED | The 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_REQUIRED | An 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_PAID | Paid truth answers first. Follow the paid posture above. |
HTTP 502 | On 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:
- 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.
- Bind the refreshed checkout.
- Let the shopper review the refreshed total and saved-card availability. The new generation carries no recorded choice.
- The shopper chooses again, explicitly, through the preference. The same saved card is allowed; never record it on the shopper's behalf.
- 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:
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. 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
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 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 literalvfollowed 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 generic409. 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 inZwith 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
captureCorrelationequals the capture you recorded andselectionsexactly equals that capture's lines as a set (order does not matter). Any other200keeps 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
200before 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.
purchaseIdis the same on every read of the grant and is an additional key, never a replacement. It is not the session read'sorderReference, 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
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 |
| Hosted refresh successor, original created with a grant (dormant) | Session read, else the proof read: an exact-match 200 reconciles once | Proof read only: an exact-match 200 reconciles once; any other answer stays unresolved, nothing removed | Key, capture or capture deadline gone: 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 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-slugand 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 liveCustomerSessionas 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 optionalCustomerSessionbearer, required under the same verified-email rule. - The dormant saved-card routes use
x-organization-slugplus theCustomerSessionbearer that bind recorded. - 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.