Errors & status
Layout never claims an order is placed without evidence, and a confident false failure is just as dangerous. Handle the uncertain state deliberately.
placed vs failed vs unconfirmed
Three terminal states, and they are not interchangeable:
- placed: the order reached the restaurant, with evidence. Safe to tell the person it's ordered.
- failed: it did not go through, with a reason code. Whether trying again can work depends on the code: see Failure codes. Do not tell the person “nothing was charged” on every failure: see Holds on a failed order.
- unconfirmed: Layout could not verify either way. Do not retry it, automatically or by hand, and do not treat it as failed. Wait for
order.placedor readGET /v1/orders/:id, and show it as pending meanwhile. Collapsing this into failed is what causes duplicate orders and double charges.
An unconfirmed order is not abandoned. Layout keeps working it: when the store's own confirmation email arrives, the order moves to placed and you receive order.placed, and a person at Layout is alerted to settle the rest with the store. There is no fixed deadline, so keep the order open and let the next webhook or GET /v1/orders/:id decide it. Tell the person something like: “We're confirming your order with the store. Don't order again yet; we'll let you know.”
A refund is settled between the person and Layout support. No event is sent for it, and you do not need to act on one.
Error shape
Every error the /v1 endpoints send has the same shape.
{
"error": {
"code": "price_changed",
"message": "This cart's price no longer matches the total you sent.",
"orderId": "ord_7c21e4b9a0d3"
}
}
code is always one of the list below, the Error schema in openapi.json. Switch on code, never on message. The other fields appear only where they apply:
| Field | When it is there |
|---|---|
orderId | The error is about an order. |
candidates | store_ambiguous; store_not_found and store_mismatch when Layout found other stores. |
store | store_mismatch: the store the placeId names. |
limit | A named daily or hourly ceiling. See Rate limits. |
retryInSeconds | Any 429 that carries Retry-After, with the same wait. |
next | build_only when the person is not ready to confirm. |
codeReason | code_required. |
order | already_confirmed, and cart_closed on a cart that already became an order. |
userMessage | already_confirmed: a sentence for the person. |
replacedBy | answer_not_needed and cart_closed when another answer already started a cart. |
reason | fee_not_included on a confirm whose total left out Layout’s service fee. |
Two different fields carry codes, and they never mix. error.code, lowercase (card_declined), answers a request Layout refused. failure_code, uppercase (CARD_DECLINED), is how an order ended, on the order and its order.failed webhook (see Failure codes). A card refused at confirm is the first; a card refused later, during placement, is the second.
A request no endpoint handles is 404 in the standard shape, { "error": { "code": "not_found", "message": "No route answers GET /v1/…" } }, with X-Request-Id and Layout-Version. Two answers come from in front of the endpoints, and they are flat, with error a string: a write that carries a browser Origin Layout does not allow is 403 { "error": "bad_origin" }, and an outage at Layout's edge can be 503 { "error": "unavailable" }. Read error as a string when it is not an object, and treat the status as you would the matching code below.
Every error code
| Code | Status | Sent by | What it means, and what to do |
|---|---|---|---|
invalid_request | 400, 409, 422 | Any endpoint that takes a body or a query. verify/start and verify answer 422 when the user has no usable phone number, or in sandbox no reserved test number. /confirm answers 400 when expectedTotalMinor is the restaurant's total alone on a ready cart where a fee applies, with error.reason: "fee_not_included"; the message names no figure, and nothing was confirmed or charged. POST /v1/carts answers 400 to simulate, latency or engine in production. | Fix what the message names. Sending it again unchanged gets the same answer. |
unauthorized | 401, 403 | Every endpoint. | 401: the secret or token was not recognised, is expired or revoked, or was rotated. A build grant past its expiry is 401 grant_expired instead. 403: it is real and may not do this: production on an application that is not approved, or a suspended application or organization. A 403 from your secret carries error.reason: live_not_approved, app_suspended or partner_suspended. A 401 never carries one. Refused requests, on the Credentials page in the console, lists both. A refused person token or build grant is not recorded there. |
wrong_credential | 403 | Every endpoint. | A real credential of the wrong kind: your secret key on an endpoint that acts for a person (carts, places, menus, images, the profile), or a build grant on an endpoint that takes your secret key (/v1/users, /v1/events, /v1/usage) or on GET /v1/users/me/profile. The message names what to send. A credential Layout does not recognise is still 401 unauthorized. |
forbidden | 403 | Places, menus, images and carts, on a person credential. POST /v1/sandbox/webhooks/fire with a production key. | The person's Layout account cannot order through Layout yet, or your application has no active credential to attribute a build to. Test events are fired with a sandbox key only. On MCP, forbidden is also the tool result for a call the session may never make. |
bad_code | 401 | POST /v1/users/:id/verify | The code is not the one Layout texted. Ask the person for the current code. Every try counts toward the five a text allows. |
not_found | 404 | Any endpoint that names a user, order, cart, menu, image or place. POST /v1/sandbox/webhooks/fire. A path no endpoint handles. | No such thing for this application and this credential. Another application's is a 404 too, so the two look the same. Do not retry it. On a test event it can also mean no enabled endpoint subscribes to the event: subscribe one in the console, then fire again. |
method_not_allowed | 405 | Any endpoint, for a method it does not take. | Use the method this reference lists. /v1/users answers only POST: any other method there is 404 not_found. /v1/orders answers 410 use_carts to every method. |
already_claimed | 409 | POST /v1/users/:id/verify/start, POST /v1/users/:id/grant | The person claimed their own Layout account without connecting your application. To keep ordering for them, ask them to connect it: POST /v1/users/:id/connect. See below. |
person_not_ready | 409 | POST /v1/users/:id/links | The person has not verified their number with your application, or cannot hold a card on Layout yet. Verify them first. error.next.step, when present, names the step. See Card links. |
grant_expired | 401, 409 | 401: every endpoint a build grant opens, on api.layout.link and mcp.layout.link. 409: POST /v1/users/:id/grant | 401: the build grant you sent is past its expiry. WWW-Authenticate carries error_description="grant_expired". Mint a new grant and send it instead. Every other refused grant stays 401 unauthorized. 409: the build grant sent as refresh expired, was revoked, belongs to someone else, or is 24 hours old. Mint a new one without refresh. |
use_carts | 410 | POST /v1/orders, every method | Retired. Build with POST /v1/carts, or over MCP. |
rate_limited | 429 | Every endpoint. | Wait Retry-After (also error.retryInSeconds). With error.limit it is a named ceiling that comes back at limit.resetsAt; with neither, read the message and back off for at least a minute. See Handling a 429. |
internal | 500 | Every endpoint. | Layout failed to answer, so the outcome may not be known. From a confirm, read the order in orderId before building again; anywhere else, retry only with the same idempotency key. |
send_failed | 502 | verify/start, /confirm, /confirm/resend-code | The text could not be sent. Nothing was confirmed or charged. Retry the same request shortly. |
unavailable | 503 | Every endpoint. | A check could not run, so nothing was confirmed or charged. Retry the same request, after Retry-After when it is there. |
idempotency_conflict | 409 | Any POST sent with Idempotency-Key, and POST /v1/carts, /answer, /confirm with idempotencyKey | The key was already used with a different request. Nothing ran. A new request needs a new key. |
in_progress | 409 | Any POST sent with Idempotency-Key, and POST /v1/carts, /answer, /confirm | The first request with that key is still running. Wait Retry-After (a couple of seconds when the key is in the body), then retry the same request or read the cart or order. See Idempotency. |
unsupported_version | 400 | Every endpoint. | The Layout-Version header names no version this API answers. The message lists the ones it does. See Versioning. |
store_mismatch | 409 | POST /v1/carts, /answer | placeId and restaurantName name different stores. error.store is the one the placeId names. Ask the person and send only its placeId. Nothing was built and the key is still free. |
menu_not_held | 409 | POST /v1/carts, /answer, POST /v1/menus | Sandbox only. Layout does not hold this restaurant’s menu, so no sandbox cart or menu comes from it, or the cart named no place Layout could tie to a placeId. Pick a place from a sandbox places search, which lists only restaurants whose menu Layout holds, or a test restaurant. Nothing was built and the key is still free. When Layout cannot check, the answer is 503 unavailable instead. |
store_ambiguous | 409 | POST /v1/carts, /answer | More than one store matched. Show the person candidates and send the placeId they pick, with the same key. |
store_not_found | 409 | POST /v1/carts, /answer | No orderable store matched the name, or the name you sent resolved only to a store sharing no word with it. candidates, when present, are other stores the search found; send one only if the person says it is the one they meant. Otherwise ask the person which store they mean. |
build_limit | 429 | POST /v1/carts, /answer | A daily build allowance is spent. The message names which one and its number, and error.limit names it for code. It resets at midnight UTC. |
cart_not_ready | 409 | /confirm, /confirm/resend-code | The cart is still building, or has nothing to confirm yet: it failed or is waiting on an answer, or on resend-code it has no total yet. Read GET /v1/carts/:id. A cancel never answers this: a building cart answers 202 canceling. |
cart_closed | 409 | /confirm, /confirm/resend-code, /cancel | The cart was confirmed, canceled or expired. error.order is { id, status } when it already became an order, and error.replacedBy is the cart to confirm instead when its question was already answered. A /cancel of an expired cart is not an error: it answers 200 with status: "expired". See Carts. |
answer_not_needed | 409 | /answer | The cart is not asking a question: it is still building, it was canceled, or it asks something else (choices rather than a pickup time, or the other way round). Read the cart and answer what it asks. When the question was already answered under another idempotencyKey, error.replacedBy is the cart that answer started: read that one. |
nothing_to_cancel | 409 | /cancel | Nothing is active on this cart to release. |
build_only | 403 | /confirm, /confirm/resend-code, /cancel | On a confirm, the person is not ready to confirm yet, and nothing was texted or charged. error.next.step says which step is missing: verify_required (verify their number with your application), connect_required (approve a connect link), phone_required (verify their number in Layout) or card_required (mint a card link with POST /v1/users/:id/links and send them to it). error.next.url, when present, is the Layout page where the person does it; a person you provisioned gets none on card_required. It is also the answer when this credential may not confirm or cancel this cart. On MCP the tool result is forbidden instead. sign_up_required and link_expired are no longer sent. |
price_changed | 409 | /confirm | The total you sent is not the cart’s current total. Nothing was confirmed or charged. 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. The restaurant's total alone (fees.merchantTotalMinor) on a ready cart where a fee applies is 400 invalid_request with error.reason: "fee_not_included" instead. |
over_daily_cap | 409 | /confirm | Over the person's daily spending limit. It resets at midnight UTC. |
code_required | 400 | /confirm | Layout texted the person a code. Ask them for it and send the same request with code. error.codeReason says why one is needed: see When the answer is code_required. |
code_invalid | 400 | /confirm | The code does not match. Ask the person to check it. |
code_expired | 400 | /confirm, POST /v1/users/:id/verify | The code is no longer good. On a confirm, get a fresh one with /confirm/resend-code. On verify, no code was texted in the last three minutes or the last one took five wrong tries: text a new one with verify/start. |
code_attempts_exceeded | 429 | /confirm | Too many wrong codes. The message says why and for how long; wait Retry-After (also error.retryInSeconds), then confirm again. See When the answer is code_required. |
code_not_needed | 409 | /confirm/resend-code | This cart needs no code, and nothing was texted. Every sandbox cart answers this, except one built with "simulate": "code" or at test_place_code, whose code is 000000. |
risk_denied | 403 | /confirm | Layout will not place this order. Nothing was confirmed or charged. Do not retry it. |
card_declined | 402 | /confirm | The person's card could not be charged, so the order was not placed. Mint a card link with POST /v1/users/:id/links so they can add another card. A person who signs in to Layout can also change it there. |
over_order_limit | 409 | /confirm | The order is over Layout's per-order limit, so it was not placed. |
not_placed | 409 | /confirm | Layout could not hand the order to the restaurant's site, so it was never sent. The order is failed with OUR_ERROR. The message says what became of the card hold. Build a new cart if the person wants to try again. A confirm that answered 202 placing can also end failed without reaching the restaurant: read the order. |
approval_required | 409 | /confirm | The person has to approve paying through Layout first, and Layout texted them how. Confirm again once they have. |
already_confirmed | 409 | /confirm, /confirm/resend-code | The cart was already confirmed: by the person's YES to Layout's text, in the Layout app or on Layout's tracking page, or by another of your requests. Nothing was charged again. error.order is { id, status, confirmedVia? }, and error.userMessage is a sentence for the person. Read the order; do not confirm or build it again. See Already confirmed elsewhere. |
provisioned_to_another_app | 409 | POST /v1/users | The number belongs to another of your account's applications in this environment. Use another number, or that application. |
phone_changed | 409 | POST /v1/users/:id/verify/start | The person is connected to your application, but their Layout account no longer holds the number you provisioned, so no code was texted. You stay connected under this id and can keep building. Verifying works again once their account holds this number. |
texts_blocked | 409 | verify/start, /confirm, /confirm/resend-code | The person's number replied STOP to Layout, so no code can be texted to it. They reply START to the number Layout texts from, then you try again. Nothing was charged. |
insufficient_scope, confirm_required and moved_to_mcp are reserved and not sent.
Paths starting /confirm, /answer or /cancel are on a cart, POST /v1/carts/:id/…. See Confirm for how a confirm walks through them. Every answer from an endpoint, errors included, carries X-Request-Id: quote it when you write to support.
Failure codes
A failed order carries its reason as failure_code on GET /v1/orders/:id, and an unconfirmed one carries UNCONFIRMED. It is a stable machine code, never prose, and it is the only failure field the API exposes. Switch on state first: an order still placing can carry a code before it settles. An order.failed webhook carries the same code as order.failure_code; no other event carries it. These are the values Layout sends, and Layout maps an older stored value to one of them. Treat any value you do not know as UNKNOWN. Each row says whether a new cart can work; none of them is a reason to retry an unconfirmed order.
failure_code | What happened |
|---|---|
LOGIN_REQUIRED | The restaurant's site requires its own sign-in. Retrying there will not work: try another restaurant. |
SITE_BLOCKED | The restaurant's site blocked the checkout. Retrying there is unlikely to work soon: try another restaurant. |
RATE_LIMITED | The restaurant's site was throttling requests. Trying again later can work. |
ITEM_UNAVAILABLE | An item is not available at that store right now. Build again with another item or another store. |
SITE_ERROR | The restaurant's site failed during checkout. Trying again shortly can work. |
STORE_CLOSED | The store is not taking orders right now. Try again during its hours, or another location. |
PICKUP_TIME_EXPIRED | The pickup time the site was holding lapsed before the order went through. It can be placed again at the next time. |
CHECKOUT_NOT_FOUND | The site showed the menu but never offered a working checkout. Try another restaurant. |
ORDERING_PAGE_NOT_FOUND | Layout did not find an ordering page. That is not proof the restaurant takes no online orders. Try another restaurant, or the person can call the store. |
CLOSED_NEXT_DAY | The earliest pickup the site offers is not today. Ask the person whether a later pickup works, or try another location. |
MARKETPLACE_ONLY | The restaurant takes online orders only through delivery marketplaces. Retrying there will not work. |
APP_ONLY_ORDERING | The restaurant's site sends ordering to its own mobile app. Retrying there is unlikely to work: try another restaurant, or the person can order in that app. |
ACCOUNT_VERIFICATION_REQUIRED | The checkout needed an account verification Layout could not finish. Retrying there is unlikely to work. |
CART_DRIFTED | At checkout the site's cart or total no longer matched what the person approved, so Layout did not pay. Build a new cart and confirm its total with the person. |
CARD_DECLINED | The person's card was declined. Build again once they add another card on a card link. |
CARD_REMOVED | The card was refused and removed, so the person has no card on file. Retrying cannot work until they add another card on a card link. |
PAYMENT_REJECTED | The restaurant's checkout rejected the payment without a decline from the card. The cause is not known. Trying again, or with another card, can work. |
OUR_TIMEOUT | Layout ran out of time. Trying again can work. |
CART_EXPIRED | Nobody confirmed the cart before expiresAt. Nothing was charged. Build again if the person still wants it. |
OUR_ERROR | Layout failed. Trying again can work. |
UNCONFIRMED | Placement is uncertain. The state is unconfirmed: do not retry and do not call it failed. |
UNKNOWN | Layout could not classify why it stopped. Trying again can work. |
USER_CANCELED | The cart or order was canceled before it was placed. |
STAFF_STOPPED | Layout stopped the order before it was placed. |
SESSION_EXPIRED | A change to the cart could not be made before the restaurant's checkout session ended. |
SUPERSEDED_BY_CHANGE | The cart was changed, and a new order replaced this one. |
A cart that could not be built says why in failure.code on GET /v1/carts/:id, with failure.message written for the person. It is one of the codes above when Layout classified the failure, and otherwise a lowercase word for how the build ended, such as build_stopped. Show failure.message, and treat a code you do not know as UNKNOWN.
Holds on a failed order
A failed order was not placed, but the person's card may have carried a hold for it. When an order fails, Layout voids any hold it placed. If the void cannot be confirmed, a pending hold can still show on the card. It is not a charge, and it drops off on its own if the order did not go through. In the rare case money did move, Layout support makes it right with the person. The order and its webhook do not say which of these happened, so do not tell the person “nothing was charged” on every failure. Say the order was not placed, and that any pending hold drops off on its own.
Why refusals never say why
A 401 other than grant_expired carries the same body whatever the underlying reason, and so does a 403 other than wrong_credential from anything but your own secret:
{
"error": {
"code": "unauthorized",
"message": "Invalid API credentials."
}
}
Separating “no such key” from “right key, wrong permission” would let someone measure how close a guess landed. So a 401 never varies. A 403 means the secret itself matched, so its holder is told why with error.reason, beside the same code and message:
{
"error": {
"code": "unauthorized",
"message": "Invalid API credentials.",
"reason": "live_not_approved"
}
}
You also get every refusal under Refused requests on your application's Credentials page in the console, with a count and a last-seen time, and from layout refusals in the CLI. A refused person token or build grant is not recorded there.
| Reason | What happened |
|---|---|
live_not_approved | A production key on an application that is not approved. See Environments and approval. |
credential_revoked | The secret was rotated or revoked. Something in your fleet is still holding the old one. |
app_suspended | This application is suspended. |
partner_suspended | The whole account is suspended. |
A secret that matches no application at all gets refused and logged nowhere. Otherwise anyone could fill that list with noise.
409 already_claimed
If someone you provisioned but never verified signs up to Layout on their own, the account is theirs. verify/start and POST /v1/users/:id/grant then answer 409 already_claimed.
Do not retry past it. Ordering for them is now theirs to agree to: ask them to connect your application with POST /v1/users/:id/connect. Once they approve, grants work again.