- Two writers. The caller speaks while the customer taps. Every cart mutation is therefore concurrency-checked, and the loser is told rather than silently overwritten.
- An unreliable channel. A call drops, a socket resets, a response is lost after the write landed. So every mutation is idempotent, and every outcome is readable after the fact.
The contract version is
2026-08-27. Event payloads and headers are pinned to it; see
Outbound Webhooks for the delivery envelope itself.Base URL
/api/v1/storefront. A base URL that stops at /api/v1 reaches a different route
set entirely and will answer 404 in a way that reads like a missing cart.
Opening a session
channel is what marks the order as assisted. It flows to the order record and onward to webhooks
and merchant surfaces, so set it at session creation rather than trying to attach it later.
The response carries the cart and a cartAccessToken. Send that token as X-Cart-Token on every
subsequent request for the cart.
Concurrency: If-Match and ETag
Every cart mutation requires If-Match carrying the revision the caller last saw. Every cart
response returns the current revision in the ETag.
A
409 from a revision conflict carries the current revision, so recovery is one read rather than a
full resynchronisation.
Idempotency
Idempotency-Key is required on every mutation. Within its retention window:
- The same key with the same payload returns the original result. Safe to retry as often as the transport requires.
- The same key with a different payload is refused with
409. This is deliberate: the alternative silently discards one of two writes that the caller believed were both applied.
When the response is lost
The most common assisted failure is not an error — it is a write that succeeded whose response never arrived. The contract’s answer is that you never have to guess, and you never resubmit to find out:- Re-read the cart. If the revision advanced, the write landed.
- For a submission, look it up by whichever identity you still hold:
nextAction never tells you to resubmit. A submission that cannot progress on its own is either
waiting on someone — the customer, or a merchant tender — or finished, and saying so is what stops a
lost response from becoming a second order.
Resubmitting to discover the outcome is the one thing that can turn an ambiguous result into a
duplicate order.
The cart event stream
X-Cart-Token header as everything else. The token
goes in the header, never in a query parameter.
Event frames are cart.created, cart.updated, cart.locked, cart.completed and cart.expired,
each carrying a monotonic id, the cartId, the resulting revision and occurredAt.
Two control frames matter more than the events:
A replay gap is always declared. If you resume from an id the server cannot replay from, you get
resync_required rather than silence — so a consumer never has to infer that it missed something.
Submitting
quoteFingerprint and the two evidence fields pin the submission to what the caller was actually
quoted. If the catalog or a discount moved underneath, the submission is refused and requoted rather
than committed at the old price. Read the cart again, tell the caller the new total, and submit
against the new fingerprint.
OrderSubmissionOutcome reports the lifecycle, including CUSTOMER_ACTION_REQUIRED when payment
needs the customer to act. Read the attempt rather than inferring state from the absence of a
response.
Webhook correlation
Assisted callers reconcile against outbound events rather than by polling. The correlation model:order.created is what ties a cart to an order id. settlement.updated also carries cartId, so a
settlement that arrives before order.created still resolves to its cart.
Delivery semantics, all pinned to 2026-08-27:
- Signed
HMAC-SHA256over"{timestamp}.{body}", base64url, inCrave-Webhook-Signatureasv1=…. Two comma-separated signatures may appear during secret rotation; accept either. - Timestamps outside a 300 second tolerance must be rejected.
- Retried on
408,409,425,429and5xxonly — up to 12 attempts, full-jitter backoff from 30 seconds to a 6 hour cap, honouringRetry-Afterup to 1 hour. - Every other 4xx is terminal. A delivery your endpoint refuses with
400is never retried.
