Carts
Build a real cart at a restaurant, show the person its total, and confirm it, over REST. A cart is built and unpaid. An order is confirmed. Layout never charges without the person's confirm.
status: "closed" with replacedBy, its question was answered and a new cart carries on: read replacedBy from then on. This is the one thing a polling loop has to follow.
async function waitForCart(id, token) {
for (;;) {
const res = await fetch("https://api.layout.link/v1/carts/" + id, {
headers: { Authorization: "Bearer " + token },
});
const { cart } = await res.json();
if (cart.status === "closed" && cart.replacedBy) { id = cart.replacedBy; continue; }
if (cart.status !== "building") return cart;
await new Promise((r) => setTimeout(r, 2500));
}
}
Who can call what
Carts act for one person, so they take a credential that names one: a connected person's access token (see Authentication), or a build grant from POST /v1/users/:id/grant. Your application secret does not open a cart. For the same steps over MCP, see Two doors, one order.
| Call | Connected person's token | Build grant |
|---|---|---|
| Build, read, answer a cart | Yes | Yes |
| Confirm, resend the code | Yes | Yes, with the person's code when a code is due. In sandbox it is a simulation. |
| Cancel | Yes | Yes, a cart your application built. |
| Read an order | Yes, that person's orders your application drove | Yes, that person's orders your application's build grants built |
| Read the food profile | Yes | No |
POST /v1/carts
Starts a build on the restaurant's own ordering site and answers at once. Nothing is charged. In sandbox the cart is built from the restaurant’s menu as Layout holds it, with no browser: see Sandbox.
curl https://api.layout.link/v1/carts \
-H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"request": "an iced oat latte, large",
"placeId": "ChIJbloomCoffee",
"timeZone": "America/Los_Angeles",
"idempotencyKey": "7f3c2a9e-cart-1"
}'
202 Accepted
{ "cart": { "id": "crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-", "status": "building" } }
| Field | Required | Notes |
|---|---|---|
request | One of these two | What the person wants, in their words, up to 200 characters. |
items | One of these two | Up to 10 entries, one per item. Sent without request, together they must fit in 200 characters. |
placeId | One way to name the store | The store, from a places search, and the best way to name one (see Finding the nearest store). If you also send restaurantName and it names a different store, the answer is 409 store_mismatch with error.store, and nothing is built. |
orderUrl | One way to name the store | The restaurant's own https ordering page. |
restaurantName with near or geo | One way to name the store | Layout resolves the store. geo is the person's coordinates, { "lat": 37.7793, "lng": -122.4193 }. It never picks a store for you: the answer is 409 store_ambiguous with the nearest open stores as candidates. Send the placeId the person picks, with the same key. You may keep restaurantName. near with a street number and name is read as the store's own address, and Layout builds at the branch there. A city or ZIP code is answered with candidates. |
timeZone | No | The person's IANA zone, so "today" and times like "6:15 PM" are read on their clock. |
fulfillment | No | "pickup", the default. See Pickup only. |
simulate | No, sandbox only | The ending: placed (the default), failed, unconfirmed, challenge, code, card_declined, price_changed, closed or pickup_later. See Choose the ending. |
latency | No, sandbox only | realistic (the default) waits about as long as a real build; instant skips every wait. See Timing. |
engine | No, sandbox only | shadow (the default) builds from the menu Layout holds. real drives the restaurant’s own site, 10 a day per developer account. See The real engine. |
idempotencyKey | Yes, or an Idempotency-Key header | One per real intent, 8 to 100 of A-Z a-z 0-9 . _ : -. The same key and body return the same cart and never build twice. The same key with a different body is 409 idempotency_conflict. You can send the Idempotency-Key header instead, by the same rule. See Idempotency. |
Send only the fields in this table. An unknown field is 400 invalid_request, here and on answer and confirm, and so is simulate, latency or engine in production. With both request and items, Layout builds items and keeps request as the person's words.
A cart id is opaque. It belongs to your application and that person: presented by another application, or for another person, it is a 404.
Finding the nearest store
Layout offers the branch nearest the location you send. Search first with GET /v1/places, using the best location you have:
- The device's coordinates, as
latandlng, withoutnear.distanceMiis measured from the person. - Their address or ZIP code, geocoded by your platform into
latandlng, when you have no location permission. - A city or town name, as
near, only when you have nothing better. Distances are then mostly measured from its centre. A ZIP code, neighbourhood or landmark sent asnearcomes back withlocalityMatched: false: send coordinates instead.
Build with the placeId the person picks, and keep it for their next order there. Send a fresh location when they order somewhere new. Never send the person's own street address as near: a cart reads it as the store's address.
GET /v1/carts/:id
Poll until the cart is ready, needs an answer, or failed. See How long a build takes.
200 OK
{
"cart": {
"id": "crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-",
"status": "ready",
"store": { "name": "Layout test kitchen", "address": "1 Market St, San Francisco, CA" },
"items": [{ "name": "Iced Oat Latte", "quantity": 1, "priceMinor": 650, "modifiers": ["Large"] }],
"currency": "usd",
"totalMinor": 763,
"fees": {
"subtotalMinor": 650, "taxAndFeesMinor": 60, "merchantTotalMinor": 710,
"serviceFeeMinor": 53, "serviceFeeWaived": false, "creditMinor": 0,
"courierFeeMinor": 0, "tipMinor": 0
},
"fulfillment": { "type": "pickup", "deliveryUnavailable": null, "dropoff": null, "courierFeeMinor": null, "tip": null },
"card": { "brand": "visa", "last4": "4242" },
"codeRequired": false,
"codeReason": null,
"confirmationCard": "Layout test kitchen\n1x Iced Oat Latte (Large)\nTotal $7.63 on your card ending 4242",
"decision": null,
"failure": null,
"order": { "id": "ord_7c21e4b9a0d3", "status": "carted" },
"expiresAt": "2026-10-01T17:15:00.000Z"
}
}
While status is building, the cart also carries build: { "stage": "menu", "etaSec": 40 }. stage is store (finding the store), menu (reading the menu), item (building the item) or cart (heading to the cart). etaSec is a prediction from past builds and can grow on a slow build. In sandbox it counts down the cart’s own wait (see Timing). It is null, or absent, once the cart is not building or when Layout cannot tell yet. Show it as progress and keep polling the cart for the result.
totalMinor is the only total. It is the restaurant's total plus Layout's service fee, unless the fee is waived, and it is exactly what confirm takes. Show it to the person and send it back unchanged. Never add up fees yourself. Layout credit, when the person has some, comes off when the order is placed: the card is charged totalMinor minus fees.creditMinor.
Cart fields are camelCase; order and webhook fields are snake_case. An order's total_minor is the restaurant's total only, the cart's fees.merchantTotalMinor. Never show it as the price or confirm with it. What the card was charged is the order's amounts.charged_minor once it is placed.
Before they confirm, show the person the amount, the items and the card. card is the card that will be billed, on a person's token and on a build grant, and null when the person has no card yet, and on every sandbox cart. On a connected person's token, confirmationCard is Layout's own wording of all three, to show as written. On a build grant it is null: show the items, the store, totalMinor and the card yourself.
store is filled whenever Layout knows it, and is null only while building or when nothing names the store. Show the address with the name so the person can tell which branch it is.
Each item’s priceMinor is the price of one of that item, with its options. Multiply by quantity for the line. It is null when Layout could not read it.
If the person changes a ready cart in Layout, the cart reads at its new totalMinor, its items lose their per-item prices, and confirmationCard is null. Show what is there now.
fulfillment is null until the cart is priced. A pickup reads as in the example, with fees.courierFeeMinor and fees.tipMinor at 0.
status | Meaning | What you do |
|---|---|---|
building | Layout is building the cart: on the restaurant's site in production, and from the menu Layout holds in sandbox. | Poll again in 2 to 3 seconds. |
ready | Priced and confirmable until expiresAt, 15 minutes after it was ready. | Show it, then confirm or cancel. |
needs_answer | The restaurant needs a choice, or the earliest pickup is not today. Nothing was spent. | Ask the person decision.question and answer it. |
failed | The cart could not be built. failure.code is stable, and failure.message is written for the person. | Tell them. Build again with a new key if they want. |
expired | Nobody confirmed it before expiresAt. failure.code is CART_EXPIRED, its order reads failed with failure_code CART_EXPIRED, and order.failed is sent within a few minutes. Nothing was charged. | Build again with a new key. You need not cancel it. |
canceling | You canceled it while it was building (see cancel). | Poll until it is canceled, or failed if the build failed. Never confirm it. |
canceled | Released. Nothing was charged. | Nothing. |
closed | No longer confirmable, usually because it was confirmed: by you, or by the person elsewhere (see Already confirmed elsewhere). An answered question cart also reads closed, with replacedBy, the cart the answer started. | Read order.status and order.confirmedVia, or GET /v1/orders/:id. |
These failure.code values come from this API. Any other value is the build's own code, such as STORE_CLOSED: show the person failure.message.
failure.code | Meaning | What you do |
|---|---|---|
build_stopped | The build stopped before a cart was ready. | Send the same request again, with the same key. |
order_in_progress | An order for this person at this store is confirmed and paying, or another cart is still building for them. failure.orderId names the paying order when it is yours. A test order never blocks a real store. | Wait for that order or build to end, then build with a new key. |
not_saved | The cart was priced but could not be saved, so it cannot be confirmed. | Build again with a new key. |
retry_blocked | Several cancels in a row, or many builds in an hour, paused new builds for this person. failure.retryInSeconds is the wait. Test restaurants never count. | Show failure.message, and build after the wait. |
no_card / card_not_orderable | The build needs a card the person does not have, such as one past the day's no-card builds, or Layout cannot use their card. failure.next is { "step": "card_required" }. | For a person you provisioned, mint a card link with POST /v1/users/:id/links, send them to it, then build with a new key. On a person's own Layout token, send them to failure.next.url as given. |
error | Layout has no reason to give. | Show failure.message. |
How long a build takes
| Store | Typical time to ready |
|---|---|
| Any store, in sandbox | About 30 seconds for one item and 10 more for each item after it, at most 90, or at once with "latency": "instant". With "engine": "real", as long as in production. A test restaurant is at once, and test_place_slow about 20 seconds (see Timing). |
| A store Layout knows, in production | Seconds. |
| A store Layout has not ordered from before, in production | Often 60 seconds to 3 minutes. Layout gives a one-item build up to 5 minutes, and a larger order more. |
- Poll every 2 to 3 seconds. Faster gains nothing, and every read counts toward your per-minute limit.
- Stop holding the person after about 4 minutes. Tell them it is still working, and read the cart every 15 seconds or so. Your timeout does not stop the build, and neither does a cancel.
- A build always ends. One that runs out of time reads
failed.
POST /v1/carts/:id/answer
When a cart needs an answer, decision says which kind and how to send it.
"decision": {
"kind": "choices",
"question": "Which milk for the Iced Oat Latte?",
"item": "Iced Oat Latte",
"groups": [{ "name": "Milk", "required": true, "options": ["Oat", "Almond"] }],
"answerWith": { "field": "choices" }
}
curl https://api.layout.link/v1/carts/crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-/answer \
-H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "choices": "oat", "idempotencyKey": "7f3c2a9e-answer-1" }'
When the earliest pickup is not today, the decision is a pickup_time:
"decision": {
"kind": "pickup_time",
"question": "The earliest pickup here is tomorrow at 7:00 AM. Order for then?",
"pickupTime": "Tomorrow 7:00 AM",
"answerWith": { "field": "acceptPickupTime", "value": true },
"alternatives": [{ "placeId": "ChIJbloomCoffee2", "name": "Bloom Coffee", "address": "500 Howard St, San Francisco, CA" }]
}
Send exactly one of choices (the person's words) or acceptPickupTime: true, which accepts the time the decision states. You never type a time. When answerWith is null, there is no time to accept: build a new cart at one of alternatives, stores open now, if the person wants. Each answer adds to the ones before it, and together they must fit in 300 characters.
An answer takes its own idempotencyKey, by the same rules as a build. It starts a new cart: the 202 carries a new id, so poll that one. The question's cart then reads closed with replacedBy. Only the first key to answer starts a cart. Another key gets 409 answer_not_needed with error.replacedBy.
Pickup only
Every cart is a pickup. A request for delivery is built as a pickup, and fulfillment.deliveryUnavailable.reason is not_enabled, with a message you can show as written. The person can still confirm it.
POST /v1/carts/:id/confirm
Run it only after the person has seen the cart and said yes. A live confirm may need the person's code (see When the answer is code_required). In sandbox the order is simulated. Send an idempotencyKey, or the header, by the same rules as a build.
curl https://api.layout.link/v1/carts/crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-/confirm \
-H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "expectedTotalMinor": 763, "idempotencyKey": "7f3c2a9e-confirm-1" }'
202 Accepted
{
"order": {
"id": "ord_7c21e4b9a0d3",
"status": "placing",
"trackUrl": "https://layout.link/c/A7bK9mQz2xR"
}
}
Confirm never answers placed. placing means the confirm was accepted. The order can still fail, so follow it with GET /v1/orders/:id or the order.placed, order.failed and order.unconfirmed webhooks. Treat unconfirmed as pending, never as failed: retrying it can charge the person twice. The pickup time can arrive later, with order.updated (see Pickup time). For the texts Layout sends the person, see Texts Layout sends.
If the confirm times out, send the same call again with the same key. Never a new key. The retry gets the first call's answer, or 409 in_progress while it is still running. The person is charged once. See Retrying a confirm.
expectedTotalMinor must be the cart's totalMinor. The restaurant's total alone, on a ready cart with a service fee, is refused with error.reason: "fee_not_included", usually as 400 invalid_request and sometimes as 409 price_changed. Any other number, including a total that moved, is 409 price_changed: build a fresh cart with the same request and a new idempotencyKey, which carries the moved total, show the person that total, and confirm the fresh cart with it. Neither error carries a figure, and nothing is confirmed or charged.
trackUrl is Layout's live status page for the order, safe to show the person. It never shows the card or the account, and nothing can be signed in to or confirmed with it. It stops answering 15 minutes after the order ends, or 3 hours after the confirm at the latest. It is absent when Layout could not make one, and on every sandbox confirm.
Already confirmed elsewhere
The person can confirm your cart without you: by replying YES to Layout's cart-ready text (see If the card is missed), in the Layout app, or on Layout's tracking page. One confirm places the order. If it was already confirmed, your confirm answers 409 already_confirmed and charges nothing again.
409 Conflict
{
"error": {
"code": "already_confirmed",
"message": "This cart was already confirmed by the person replying YES to Layout's text, so nothing was confirmed or charged again. It is placing now. Read GET /v1/orders/ord_7c21e4b9a0d3, and do not confirm or build this order again.",
"orderId": "ord_7c21e4b9a0d3",
"order": { "id": "ord_7c21e4b9a0d3", "status": "placing", "confirmedVia": "sms" },
"userMessage": "This order was already confirmed and is being placed now. Nothing extra was charged."
}
}
error.order.status is the order's state now, as GET /v1/orders/:id reports it. unconfirmed is not failed: do not build it again. error.order.confirmedVia is sms or tracking_link when Layout knows. error.userMessage can be shown as written. A retry of your own confirm with the same key still gets the answer it earned.
When the answer is code_required
A live confirm needs the 6-digit code Layout texts the person only when a code is due. Otherwise it answers 202 placing. The cart's codeRequired and codeReason, checked on each read, say what to expect. The confirm still decides, so handle code_required on every confirm. The error carries the same codeReason.
codeReason | What you can tell the person |
|---|---|
over_limit | "Larger orders need a quick code." |
code_outstanding | "Enter the code Layout texted you." |
first_order_consent | "A one-time check for your first order with us." |
risk_check | "Layout needs a quick code for this order." |
unverified | Nothing yet. Read the cart again in a moment. |
In sandbox, only a cart built with "simulate": "code" or at test_place_code asks for one: codeReason is risk_check, nothing is texted, and the code is 000000.
- Call confirm. The answer is
400 code_required, and Layout texts the person a code. - Ask the person for it.
- Send the same request again, with the same
idempotencyKey, plus"code": "123456".
A wrong code is 400 code_invalid. A code that expired, was used or replaced, or was texted for a different total is 400 code_expired: send resend-code. Eight wrong codes from your application for one person in 15 minutes refuse every code for them, the right one included, until the oldest is 15 minutes old. After three wrong codes for a number, tries are spaced apart, by up to 30 seconds. Both are 429 code_attempts_exceeded with Retry-After. A number that replied STOP to Layout is 409 texts_blocked: the person replies START to Layout's number, then you confirm again. Nothing is charged on any of these.
Confirming with a build grant
A build grant confirms what the person said yes to in your product. Whether that needs their code, including the code on a first order (first_order_consent), is set out in When a code is due.
- Show the person the cart's items, store and
totalMinor, and wait for their yes. - Call confirm with no code. When no code is due, the answer is
202 placingand you are done. When one is, the answer is400 code_required, and Layout texts the person:Layout: 482913 approves $7.63 at Layout test kitchen on your Visa ending 4242. Sharing this code places the order and lets Acme Assistant place future orders for you without a code. Disconnect it in Layout to stop that. It expires in 10 minutes. If you did not just order, ignore this text.The text names the total, the store and the card, in Layout's words. Nothing your application sends appears in it. Only afirst_order_consentcode names your application (its name in the console) and says that sharing the code lets it order without one. - Ask the person for the code, then send the same request again with the same
idempotencyKey, plus"code". The answer is202 placing.
A second confirm without a code does not text again while the last code is good, for 10 minutes; resend-code texts a fresh one. A text that could not be sent is 502 send_failed: retry shortly. Layout texts at most four codes per cart, and your application has a daily code allowance. Past either, the answer is 429 rate_limited and nothing is texted. A code works once, for this cart and this total. Every other check still runs: the price lock, the person's daily limit, Layout's risk checks, and whether the restaurant's session is still open. Only carts your application built can be confirmed; any other cart id is a 404.
Until the person has a Layout account and a card, the confirm answers 403 build_only and no code is texted. error.next.step, when present, names the missing step. See When a card is needed.
error.next.step | What you do |
|---|---|
verify_required | Text them a code with POST /v1/users/:id/verify/start, and send the code they read back to POST /v1/users/:id/verify, which makes or joins their account. |
connect_required | The person signs in to Layout on this number. Send a connect link with POST /v1/users/:id/connect for them to approve. |
card_required | Mint a card link with POST /v1/users/:id/links and send them to it. For a person you provisioned there is no url. |
phone_required | They verify their number in Layout. |
403 Forbidden
{
"error": {
"code": "build_only",
"message": "This person has not verified their number with your application yet, so the cart cannot be confirmed. Text them a code with POST /v1/users/{userId}/verify/start, send the code they read back to POST /v1/users/{userId}/verify, then confirm again. Nothing was charged.",
"orderId": "ord_7c21e4b9a0d3",
"next": { "step": "verify_required" }
}
}
Never ask the person for a Layout sign-in code, and never reuse a code from another cart. An order code signs nobody in; it approves only the one cart it names.
POST /v1/carts/:id/confirm/resend-code
Texts the person a fresh code, replacing any code already out for this cart, and answers 200 { "codeSent": true }. Use it when the code never arrived or expired.
409 code_not_needed: the cart needs no code. Nothing is texted.409 already_confirmed: the cart was confirmed, and the answer names the order.409 texts_blocked: the number replied STOP.429 rate_limited: past the code limits.Retry-Afteris set when waiting will help.502 send_failed: the text failed. Retry shortly.
In sandbox every cart answers code_not_needed, except one built with "simulate": "code" or at test_place_code, which answers 200 without texting anyone and meets production's code limits.
POST /v1/carts/:id/cancel
A connected person's token, or a build grant for a cart your application built. Releases this cart and answers 200 { "cart": { "id": "crt_…", "status": "canceled" } }. Call it when the person says no: an unreleased cart holds a live session at the restaurant until it expires. An expired cart needs no cancel; a cancel of one answers 200 with status expired.
A cart still building answers 202 { "cart": { "id": "crt_…", "status": "canceling" } }. Layout releases it the moment the build lands, never texts the person about it, and sends order.failed (USER_CANCELED), never order.carted. Read the cart until it is canceled, or failed. A confirm on a canceling or canceled cart is 409 cart_closed.
A cart already confirmed, here or elsewhere, is not canceled. The answer is 409 cart_closed with error.order, the order and its state now. If that order already ended, its status is failed. A cart that already ended answers 409 nothing_to_cancel.
409 {
"error": {
"code": "cart_closed",
"message": "This cart was already confirmed, so it was not canceled. Read its order.",
"orderId": "ord_7c21e4b9a0d3",
"order": { "id": "ord_7c21e4b9a0d3", "status": "placing" }
}
}
A cancel that releases a cart is never rate limited. See Limits.
GET /v1/users/me/profile
A connected person's token only. What Layout applies to every cart it builds for them.
200 OK
{
"profile": {
"applyDietaryRestrictions": true,
"preferences": [{ "category": "dietary", "note": "no dairy" }]
}
}
No name, contact detail, card or address is ever returned here.
Sandbox
In sandbox, a cart at a real restaurant is built from that restaurant’s menu as Layout holds it, with its real items and choices. No browser opens. An item the menu does not list fails the cart with ITEM_UNAVAILABLE, and a restaurant whose menu Layout does not hold is 409 menu_not_held, so build with a placeId from a sandbox places search. The confirm is simulated: nothing is ordered, charged or texted. It answers 202 placing with "simulated": true, and the order then reaches the ending simulate asked for, placed by default. Every detail is in Sandbox.
Errors
Every error has one shape, and every code a cart call can answer is listed in Errors and status.