> ## Documentation Index
> Fetch the complete documentation index at: https://docs.craveup.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Assisted Ordering

> Drive one cart from a voice assistant and a browser at the same time without either losing a write.

Assisted ordering is the contract an AI phone agent and a customer's browser share when they are
both editing the same cart. Everything here is the ordinary Storefront API — there is no separate
assisted surface, no privileged route and no second base URL. What the contract adds is a set of
guarantees about concurrency, retries and recovery that a conversational caller needs and a single
browser session mostly does not.

Two things make assisted ordering different from a normal storefront session:

* **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.

> [!NOTE]
>
> The contract version is `2026-08-27`. Event payloads and headers are pinned to it; see
> [Outbound Webhooks](https://docs.craveup.com/guides/webhooks.md) for the delivery envelope itself.

## Base URL

```
https://api.craveup.com/api/v1/storefront
https://sandbox-api.craveup.com/api/v1/storefront
```

Cart, ordering-session, cart-item, cart-event, submission and checkout-handoff routes are all
mounted under `/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

```http theme={null}
POST /locations/{locationId}/ordering-sessions
Idempotency-Key: 6f1f7f38-4c2a-4d59-9d1e-6c9b0a6f1b21
Content-Type: application/json

{ "fulfillmentMethod": "takeout", "channel": "nomly_assisted" }
```

`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.

> [!WARNING]
>
> **An assisted channel cannot be self-declared.** `nomly_assisted` and `phone` are claims that
> someone other than this browser took the order, so they are granted only to a merchant-issued
> API key scoped to the location. Send it as `X-API-Key` alongside the request.
>
> Without it the session is refused with `403`, **not** silently downgraded to `web`. That is
> deliberate: a misconfigured integration recording assisted calls as web traffic would produce a
> plausible and wrong pilot conversion rate, and nobody would have a reason to look. An
> unassisted channel needs no key — a browser saying it is `web` is only describing itself.

The response carries the cart and a `cartAccessToken`. Send that token as `X-Cart-Token` on every
subsequent request for the cart.

| Field | Meaning |
| - | - |
| `cart.id` | The cart every later path is scoped to. |
| `cart.revision` | Monotonic. The concurrency token, also returned as the `ETag`. |
| `cart.quoteFingerprint` | Pins a submission to the prices the caller was quoted. |
| `cartAccessToken` | Purpose-limited capability. Not a customer session. |

## 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`.

```http theme={null}
POST /locations/{locationId}/carts/{cartId}/items
If-Match: "7"
Idempotency-Key: 0f2a1c44-8b57-4f0f-9a4e-1d0b2c3e4f5a
Content-Type: application/json

{ "productId": "prod_123", "quantity": 1, "itemUnavailableAction": "REMOVE_ITEM" }
```

| Status | Meaning | What to do |
| - | - | - |
| `200` | Applied. New revision in the `ETag`. | Continue. |
| `409` | Someone else moved the cart on, or the idempotency key was reused with a different payload. | Re-read the cart, tell the caller what changed, retry against the new revision. |
| `428` | `If-Match` was missing. | Read the cart first. Do not retry blind. |

A `409` from a revision conflict carries the current revision, so recovery is one read rather than a
full resynchronisation.

> [!WARNING]
>
> Never retry a `409` by dropping `If-Match`. That converts a reported conflict into a silent
> overwrite, which is the failure the header exists to prevent.

## 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.

Generate one key per logical operation, not per HTTP attempt. A caller that regenerates the key on
retry has an at-least-once operation, not an idempotent one.

## 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**:

1. Re-read the cart. If the revision advanced, the write landed.
2. For a submission, look it up by whichever identity you still hold:

```http theme={null}
GET /locations/{locationId}/carts/{cartId}/submission-lookup
GET /locations/{locationId}/carts/{cartId}/submission-lookup?attemptId=...
GET /locations/{locationId}/carts/{cartId}/submission-lookup?paymentIntentId=...
```

A voice caller who never saw the reply still knows the cart. A browser that reached payment knows
the intent. A client that read the response before losing it knows the attempt. **All three resolve
to the same canonical answer** — submission id, order id, cart revision, lifecycle, settlement state
and next action.

`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

```http theme={null}
GET /locations/{locationId}/carts/{cartId}/events
Last-Event-ID: 42
```

Server-sent events, authenticated by the same `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:

| Frame | Meaning |
| - | - |
| `resync_required` | History from your `Last-Event-ID` is gone (`reason: history_unavailable`). Re-read the cart at `currentRevision` and continue from there. |
| `reconnect` | Reconnect; the stream is being closed deliberately. |

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

```http theme={null}
POST /locations/{locationId}/carts/{cartId}/submissions
If-Match: "9"
Idempotency-Key: 3a7c2d19-5e8f-4b6a-9c0d-1e2f3a4b5c6d
Content-Type: application/json

{
  "acceptedCatalogEvidence": { "catalogRevision": 31 },
  "acceptedDiscountEvidence": { "discountRevision": 4 },
  "collectionMode": "PAY_AT_STORE",
  "customerConfirmationRevision": 9,
  "quoteFingerprint": "..."
}
```

`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:

| Event | Correlate by |
| - | - |
| `order.created`, `order.status_changed`, `order.cancelled`, `order.refunded` | `cartId` |
| `payment.succeeded`, `payment.failed` | `cartId` |
| `settlement.updated` | `orderId`, or `cartId` when it is not `null` |

`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-SHA256` over `"{timestamp}.{body}"`, base64url, in `Crave-Webhook-Signature` as
  `v1=…`. 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`, `429` and `5xx` only — up to **12 attempts**, full-jitter backoff
  from **30 seconds** to a **6 hour** cap, honouring `Retry-After` up to **1 hour**.
* **Every other 4xx is terminal.** A delivery your endpoint refuses with `400` is never retried.

> [!WARNING]
>
> If you validate payloads with a strict schema, an added field is a **breaking change on your
> side**. Crave may add fields within a contract version; a strict validator rejects the unknown
> key, answers `400`, and the delivery is dropped permanently rather than retried. Accept unknown
> fields, or track the registry and deploy your schema change first.

## Verifying an integration

Crave ships a black-box conformance suite that exercises this contract end to end — normal session,
simultaneous conflict, identical retry, changed-payload retry, response loss, replay gap, stale
evidence, ambiguous lookup, and delivery signing and routing:

```bash theme={null}
CRAVE_CONFORMANCE_CREDENTIAL=<cart capability token> \
pnpm --filter express-admin-and-storefront-api script:assisted-ordering-conformance \
  --base-url https://sandbox-api.craveup.com/api/v1/storefront \
  --location <locationId> \
  --product <productId> \
  --out conformance-report.json
```

The report is redacted before it is written: neither the credential nor the receiver URL appears in
it, so it is safe to attach to a review.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.