# Layout developer documentation Every page of https://docs.layout.link, concatenated, then the changelog. Base URL: https://api.layout.link/v1 # Layout for Developers Layout API and MCP documentation. ## Explore the platform ### How Layout works One order end to end, the credentials, and when a confirm needs the person's code. ### Quickstart Provision a user, build a cart, confirm it and read the order, all in sandbox. ### Provisioning users Create a person for your app, verify their number, and keep the connection. ### Card links Send the person a Layout page where they add a card. Your app never sees it. ### Carts Build a cart at a restaurant, show the person its total, and confirm it. ### Webhooks Signed order events delivered to your backend, with retries. ### Ordering over MCP Point any MCP client at mcp.layout.link and drive a full order with tool calls. ### API reference Every endpoint, with its request, answer and errors. --- # How Layout works One order end to end, the credentials, how REST and MCP map, and when a confirm needs a code. ## The order lifecycle 1. **Provision.** `POST /v1/users` with your secret, a name and a phone number. The answer's `id` is the person's `usr_` id, and [next](https://docs.layout.link/provisioning#next-step) says how to connect them. 2. **Verify.** First tell the person, in your own words, that your app uses Layout to place their orders and that Layout will text them a code (see [Introduce Layout first](https://docs.layout.link/provisioning#introduce-layout-first)). Then `POST /v1/users/:id/verify/start`, then `/verify` with the code the person reads back. That makes their Layout account. Then send them a [card link](https://docs.layout.link/card-links). Someone who already signs in to Layout approves a connect link instead. Building does not wait for this step; a production confirm does. 3. **Grant.** `POST /v1/users/:id/grant` with your secret mints an `lgb_` build grant for that person. It lasts 15 minutes, and you refresh it before it expires. 4. **Build a cart.** With the grant, `POST /v1/carts` over REST, or the `order` tool's `build` over MCP. Poll until it is ready (`ready` on REST, `carted` on MCP), needs an answer, or failed. See [Carts](https://docs.layout.link/carts). 5. **Show it and wait for yes.** Show the person the cart's `totalMinor`, items and store. Nothing is charged until they say yes. 6. **Confirm.** Confirm with that total. In production the confirm asks for the 6-digit code Layout texts the person only when [a code is due](#when-a-code-is-due). The answer is `placing`, never `placed`, except over MCP at a test restaurant that places at once, such as `test_place_carted`. In sandbox the confirm is simulated and needs no code, unless the cart was built with `"simulate": "code"` or at `test_place_code`. 7. **Follow the outcome.** The order ends `placed`, `unconfirmed` or `failed`; a refund on a placed order changes nothing in `state` and arrives as `refunded_minor` and an `order.refunded` event. [Webhooks](https://docs.layout.link/webhooks) tell your backend, and `GET /v1/orders/:id` reads it. `unconfirmed` means Layout cannot tell whether the restaurant received the order: never treat it as failed, and never build it again. 8. **Disconnect.** `DELETE /v1/users/:id` when they leave your app. See [Disconnect a person](https://docs.layout.link/provisioning#disconnect-a-person). A person who signs in with their own Layout account through [Sign in with Layout](https://docs.layout.link/sign-in-with-layout) skips steps 1 to 3: their OAuth access token builds and confirms for them. If nobody confirms or cancels a ready cart within a few seconds, Layout texts the person the cart and they can reply YES to place it, unless you turned that text off for your application. Your confirm then answers `already_confirmed` (see [Already confirmed elsewhere](https://docs.layout.link/carts#confirmed-elsewhere)). ## The credentials Every request carries exactly one bearer token. Which one decides who the call acts for and what it can do. | Credential | Acts for | Where it works | Lifetime | In examples | | --- | --- | --- | --- | --- | | Application secret, `sk_test_…` / `sk_live_…` | Your application | `/v1/users` and its verify, connect, grant and links routes, `DELETE /v1/users/:id`, `GET /v1/orders/:id`, `/v1/events`, `/v1/usage`, `/v1/sandbox/webhooks/fire`, `/v1/whoami`. Never a cart, place, menu or image route, and never MCP. | Until you rotate it | `$LAYOUT_SECRET_KEY` | | Build grant, `lgb_…` | One person you provisioned | The REST person routes except the food profile, and MCP with three tools, or four in sandbox. See [What each door can do](#what-each-door-can-do). | 15 minutes, refreshable up to 24 hours | `$LAYOUT_GRANT` | | OAuth access token | One person who approved your application on Layout's consent screen | Every REST person route, or every MCP tool. A token is for one resource: `https://api.layout.link` or `https://mcp.layout.link`. | 10 minutes, with a 30-day rotating refresh token | `$LAYOUT_PERSON_TOKEN` | | CLI token, `lcli_…` | Your developer account | `/v1/cli` only. See [Layout CLI](https://docs.layout.link/cli). | 30 days, or what you chose for a CI token | `$LAYOUT_CLI_TOKEN` | Every example on these pages reads a credential from the variable in the last column. The [Layout CLI](https://docs.layout.link/cli) reads `LAYOUT_SECRET_KEY` and `LAYOUT_CLI_TOKEN` under the same names. The webhook signing secret is not a bearer token: it verifies what Layout sends you. See [Authentication](https://docs.layout.link/authentication) for each credential in full. ## Two doors, one order REST at `https://api.layout.link/v1` and MCP at `https://mcp.layout.link` drive the same engine, with the same checks and the same money rules. The names differ. This is the whole mapping. ### What each door can do | Step | REST | MCP | Build grant | | --- | --- | --- | --- | | Find a store | `GET /v1/places` | `places` | Both doors | | One store's facts | `GET /v1/places/:placeId` | `place_facts` | REST only | | Read a menu | `POST /v1/menus`, `GET /v1/menus/:menuId` | `menu` | REST in production, both doors in sandbox | | An item's photo | `POST /v1/images/items`, `GET /v1/images/items/:imageId` | `item_photo` | REST only | | Resolve the store first | None: `POST /v1/carts` resolves it, or answers `409 store_ambiguous` | `order`, `action: "preview"` | Yes, on MCP | | Build a cart | `POST /v1/carts` | `order`, `action: "build"` | Both doors | | Follow the build | `GET /v1/carts/:cartId` | `order`, `action: "status"` with the `idempotencyKey`, or `order_status` | Both doors | | Answer a choice or a pickup time | `POST /v1/carts/:cartId/answer` | `order`, `action: "build"` again with a new `idempotencyKey`, and the answer in `choices`, or `pickupDecision.stated` as `acceptPickupTime` | Both doors | | Confirm | `POST /v1/carts/:cartId/confirm` | `order`, `action: "confirm"` with the `orderId` | Both doors | | Text a fresh code | `POST /v1/carts/:cartId/confirm/resend-code` | `order`, `action: "confirm"` with `stage: "resend"` | Both doors | | Release a cart | `POST /v1/carts/:cartId/cancel` | `order` with `action: "status"`, `cancel: true` and the `orderId`, or while it builds the build's `idempotencyKey` (`cancel_build`) | Both doors, for a cart your application built | | Follow the order | `GET /v1/orders/:orderId` | `order`, `action: "status"` with the `orderId`, or `order_status` | Both doors | | The person's food profile | `GET /v1/users/me/profile` | `profile` | Neither door | | Recommendations, a location link, Layout's help centre | None | `discover`, `get_location`, `help` | Neither door | A person's OAuth access token can make every call in its resource's column. A build grant session over MCP lists only `order`, `order_status` and `places`, and in `order` it cancels only a cart its application built, named by its `orderId` or, while it builds, by the build's `idempotencyKey`. Over REST the grant also reads place facts, menus and item photos. [mcp-tools.json](https://docs.layout.link/mcp-tools.json) lists each session's tools and actions, read off the server. ### Names and states | What | REST | MCP | | --- | --- | --- | | Still building | `cart.status: "building"` | `build.status: "building"` | | Priced and confirmable | `cart.status: "ready"` | `build.status: "carted"` | | The restaurant needs a choice | `cart.status: "needs_answer"`, `decision.kind: "choices"`, options in `decision.groups` | `build.status: "needs_choices"`, options in `choiceDecision.groups` | | The earliest pickup is not today | `cart.status: "needs_answer"`, `decision.kind: "pickup_time"` | `build.status: "pickup_next_day"`, with `pickupDecision` | | Could not be built | `cart.status: "failed"`, with `failure.code` | Any other `build.status`: relay `userMessage` | | Nobody confirmed in time | `cart.status: "expired"` | `build.status: "cart_expired"` | | Released | `cart.status: "canceled"`, or `"canceling"` then `"canceled"` when it was still building | `cancel.status: "canceling"`, then `"canceled"` | | The total to confirm with | `cart.totalMinor`, sent as `expectedTotalMinor` | The carted build's `totalMinor` (on a build session it includes Layout's service fee), sent as `expectedTotalMinor` | | A code is needed | `400` with `error.code: "code_required"` and `error.codeReason`; `cart.codeRequired` says so in advance | `confirm.status: "error"` with `confirm.error: "code_required"`, and on a build grant `codeSent` | | Confirm accepted | `202`, `order.status: "placing"` | `confirm.status: "placing"` (or `"confirmed"`), or `"placed"` with `simulated: true` at a test restaurant that places at once | | Placement outcome | The order's `state`: `placed`, `unconfirmed` or `failed` | `status` with the `orderId`: `placed`, `unconfirmed` or `failed` | Cart fields are camelCase. Order fields, from `GET /v1/orders/:id`, and every webhook field are snake_case, and they do not map one to one: an order's `total_minor` is the restaurant's total without Layout's service fee, never the total to confirm with. See [GET /v1/carts/:id](https://docs.layout.link/carts#get-v1-carts-id). ### Ids | Id | Shape | Where | | --- | --- | --- | | Person | `usr_` and 12 hex characters for a person you provisioned, 24 for one who signed in through OAuth | REST, webhooks, `whoami` | | Cart | `crt_…`, opaque | REST only. MCP has no cart id: it follows a build by its `idempotencyKey`. | | Order, public | `ord_…` | REST, webhooks, the events feed, and an MCP result's `publicOrderId` from `carted` on | | Order, MCP | A UUID, such as `0b6f2a54-…` | MCP results' `orderId`, and what every MCP call that names an order takes | | Store | `placeId` | Both doors | ## When a code is due Any one of these makes a production confirm ask for the 6-digit code Layout texts the person, which they give you. When none holds, the confirm goes ahead without a code. A cart's `codeReason` names the first that holds, in table order, or `unverified` when Layout could not check; on the confirm's `code_required`, `risk_check` comes first. | `codeReason` | When | Build grant | Person's OAuth token | | --- | --- | --- | --- | | `first_order_consent` | The person has not yet agreed to your application ordering for them. See Agreement below. | Code | Never asked for this reason | | `over_limit` | The restaurant's total (`fees.merchantTotalMinor`) is over Layout's limit for orders without a code, which can change. | Code | Code | | `code_outstanding` | Layout already texted a code for this cart. | Code | Code | | `risk_check` | Layout's risk checks ask for one on this order. Known only at confirm, so a live cart never says it. | Code | Code | | `unverified` | Layout could not check just now, so it asks. | Code | Code | | `null` | None of the above. | No code | No code | - **Agreement.** Reading back a verify code from a text that names your application, or approving the connect screen, is the person's agreement that your application places orders for them. Without either, the first order's code is the agreement, and its text says so. It lasts until the person disconnects your application or you call `DELETE /v1/users/:id`. A new connection after that needs a new agreement. - **Sandbox.** No confirm asks for a code, except on a cart built with `"simulate": "code"` and at the test restaurant `test_place_code`, whose carts read `codeReason: "risk_check"` and whose code is always `000000`. Nothing is texted. - **Reading it in advance.** Over REST, a ready cart's `codeRequired` and `codeReason` are checked each time you read it. Over MCP, a person's own connection reads `confirmation.method: "sms_code"` on the carted build. On either door the confirm decides, so handle `code_required` on every confirm. - **Before any code.** A person who has not verified, connected or added a card is refused with `build_only` and nothing is texted. See [Confirming with a build grant](https://docs.layout.link/carts#relayed-code). The code steps, errors and limits are on [Carts](https://docs.layout.link/carts#code-required) for REST and [Ordering over MCP](https://docs.layout.link/mcp#confirm-with-a-relayed-code) for MCP. --- # Quickstart Your first sandbox order over REST, end to end, in about five minutes. No order you confirm here reaches a restaurant or charges anyone. New to Layout? [How Layout works](https://docs.layout.link/how-it-works) shows the whole lifecycle on one page. ## 1. Get your credentials Create an application in the [console](https://console.layout.link/applications) and copy its sandbox secret from the [Credentials](https://console.layout.link/credentials) page. Keep the secret server-side. Never ship it in a browser or an app binary. A new application gets sandbox keys straight away. Production is reviewed by a person, and your sandbox keys keep working whatever that review decides. See [Environments and approval](https://docs.layout.link/environments). ```bash export LAYOUT_SECRET_KEY="sk_test_…" ``` The [Layout CLI](https://docs.layout.link/cli) reads the same variable. `layout test-order` runs the sandbox steps below in one command, so you can see it work before you write any code. ## 2. Provision a user A name and a phone number. That is the whole signup you have to run. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' # -> 201 { "id": "usr_4b8e9a01c2d3", "status": "provisioned", "next": "verify", … } ``` In sandbox the phone must be a reserved test number, `+1 500 555` followed by four digits. The answer's `id` is the person's `usr_` id, used below. `next` says how to connect them: see [The next step](https://docs.layout.link/provisioning#next-step). ## 3. Verify the number In production the person proves the number before you can confirm for them: Layout texts a code, they read it back to you, you send it. In sandbox nothing is texted and the code is always `000000`. Optional in sandbox. ```bash curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3/verify/start \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{}' curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3/verify \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "000000" }' # -> 200 { "id": "usr_4b8e9a01c2d3", "verified": true, "authorized": true, … } ``` In production a good code makes the person's Layout account, and the answer reads `"status": "active"`. A person who signs in to Layout on this number themselves connects through a link they approve instead. See [Someone who signs in to Layout](https://docs.layout.link/provisioning#connecting-an-existing-account). Before the first code reaches them, tell them about Layout in your own words: see [Introduce Layout first](https://docs.layout.link/provisioning#introduce-layout-first). ## 4. Add a card In production, mint a [card link](https://docs.layout.link/card-links) and send the person to it. Sandbox needs no card. ```bash curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3/links \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "purpose": "card" }' # -> 201 { "url": "https://layout.link/h/…", "expiresAt": "…", "purpose": "card" } ``` ## 5. Mint a build grant Carts act for one person, so they take that person's credential, never your secret. A build grant is one. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e9a01c2d3/grant \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" # -> 201 { "token": "lgb_…", "expiresAt": "…", "scope": "build" } export LAYOUT_GRANT="lgb_…" ``` A grant lasts 15 minutes. To keep going, send the current token back as `{ "refresh": "lgb_..." }` before it expires. See [POST /v1/users/:id/grant](https://docs.layout.link/api#post-v1-users-id-grant). ## 6. Build a cart Send what the person asked for in their own words, and the store. Use a fresh random `idempotencyKey` for every new cart. ```bash curl https://api.layout.link/v1/carts \ -H "Authorization: Bearer $LAYOUT_GRANT" \ -H "Content-Type: application/json" \ -d '{ "request": "a large latte with oat milk", "placeId": "test_place_carted", "idempotencyKey": "7f3c2a9e-cart-1" }' # -> 202 { "cart": { "id": "crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-", "status": "building" } } export CART_ID="crt_0d928d04f1iQbL1AV4iXSKw2wabMk_B-" ``` `test_place_carted` is a [sandbox test restaurant](https://docs.layout.link/sandbox#test-restaurants) with a fixed menu: it builds a ready cart at once, so this total is the same every time. A real store builds the same way in sandbox, from the menu Layout holds, with no browser. Its `placeId` comes from [GET /v1/places](https://docs.layout.link/api#get-v1-places), searched with the person's device coordinates so the nearest store comes first. See [Finding the nearest store](https://docs.layout.link/carts#nearest-store). Read the cart every 2 to 3 seconds until it is no longer `building`: ```bash curl https://api.layout.link/v1/carts/$CART_ID \ -H "Authorization: Bearer $LAYOUT_GRANT" # -> 200 { "cart": { "status": "ready", "totalMinor": 816, "items": […], "store": {…}, "codeRequired": false, … } } ``` `ready` means priced and confirmable for 15 minutes. `needs_answer` means the restaurant needs a choice, and `failed` means it could not be built. See [GET /v1/carts/:id](https://docs.layout.link/carts#get-v1-carts-id). ## 7. Confirm Show the person the cart's `totalMinor`, items and store, and confirm only after they say yes. Send `totalMinor` back unchanged as `expectedTotalMinor`: for this request the sandbox returns 816 (a $7.00 large oat-milk latte, $0.61 tax and Layout's $0.55 service fee); use the number your cart returned. ```bash curl https://api.layout.link/v1/carts/$CART_ID/confirm \ -H "Authorization: Bearer $LAYOUT_GRANT" \ -H "Content-Type: application/json" \ -d '{ "expectedTotalMinor": 816, "idempotencyKey": "7f3c2a9e-confirm-1" }' # -> 202 { "order": { "id": "ord_7c21e4b9a0d3", "status": "placing", "simulated": true } } ``` In sandbox the confirm is a simulation: no restaurant receives it, no money moves, and nobody is texted. In production the same call places a real order. The person needs a verified number and a card from a [card link](https://docs.layout.link/card-links). If [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due), the answer is `400 code_required`: send the same request again with their `code`. On a timeout, retry with the same `idempotencyKey`. ## 8. Read the order ```bash curl https://api.layout.link/v1/orders/ord_7c21e4b9a0d3 \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" # -> 200 { "id": "ord_7c21e4b9a0d3", "state": "placed", "simulated": true, … } ``` `state` ends `placed`, `failed` or `unconfirmed`. `unconfirmed` means Layout cannot tell whether the restaurant received the order: never treat it as failed, and never build it again. Order fields are snake_case, and the order's `total_minor` leaves out Layout's service fee. Pickup details can arrive later, each with `order.updated`. See [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). ## 9. Get webhooks Rather than polling, add a subscription on the console's [Webhooks](https://console.layout.link/webhooks/subscriptions) page with an HTTPS URL. Layout then POSTs a signed event as the order moves: `order.carted` when the cart is ready, and `order.placed`, `order.failed` or `order.unconfirmed` at the end. Fire one at your endpoint from sandbox to test it: ```bash curl https://api.layout.link/v1/sandbox/webhooks/fire \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "event": "order.placed" }' ``` Verify every delivery's signature with your sandbox signing secret. See [Webhooks](https://docs.layout.link/webhooks) and [Event types](https://docs.layout.link/events). ## Over MCP instead MCP is the other door to the same ordering, with the same checks. Steps 1 to 5 do not change. Then connect the same `lgb_` token to `https://mcp.layout.link` as a bearer token, with no OAuth step, and call the `order` tool: `preview` to resolve the store, `build`, `status` until `carted`, then `confirm`. A build grant's MCP session has three tools, `order`, `order_status` and `places`, and in sandbox also `menu`. See [Ordering over MCP](https://docs.layout.link/mcp), and [Two doors, one order](https://docs.layout.link/how-it-works#two-doors) for how each REST call and state maps to MCP. `DELETE /v1/users/:id` disconnects a person. See [Disconnect a person](https://docs.layout.link/provisioning#disconnect-a-person). --- # Provisioning users Someone you can order for. You vouch for a name and a phone number; they prove the phone by reading back the code Layout texts them, and that makes their Layout account. The path: provision, [verify](#verify-their-phone), [card link](https://docs.layout.link/card-links), [build](https://docs.layout.link/carts), confirm, [disconnect](#disconnect-a-person). ## Create a user ``` POST /v1/users { "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142", "email": "dana@example.com" } → 201 { "id": "usr_4b8e9a01c2d3", "status": "provisioned", "next": "verify", "session": { "scope": "build", "expiresIn": 900 } } ``` Provisioning returns **no credential**. `session` carries no token and authenticates nothing, so you can ignore it. `handoffUrl` is kept for older integrations. Do not send anyone to it. To build, mint a build grant with [POST /v1/users/:id/grant](https://docs.layout.link/api#post-v1-users-id-grant). A grant builds, reads and releases carts, and confirms a live order with the person's code when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). It cannot add a card. ### The next step `next` says how to connect this person: | `next` | What it means | | --- | --- | | `verify` | A new number, or one whose only Layout account another application's verify made. [Verify their phone](#verify-their-phone). A good code makes or joins the person's Layout account. | | `awaiting_user_approval` | The person signs in to Layout on this number, and Layout texted them the approval link. Wait for the connection. `awaitingApproval.phoneHint` is the masked number. | | `connect` | The person signs in to Layout on this number. Send them to `connect.connectUrl`. You get the link instead of a text when you asked for it (`connectDelivery: "developer"`), in sandbox, before approval, or when Layout could not text it. If `connect` is missing, the link could not be made: call [POST /v1/users/:id/connect](#connecting-an-existing-account) with `connectDelivery: "developer"`. | | `connected` | Already connected. Start building. | A Layout account on the number that is scheduled for deletion counts as one the person signs in to, until it is purged. ## Fields | Field | Notes | | --- | --- | | `firstName` | Required. A string, 1 to 100 characters. | | `lastName` | Required. A string, 1 to 100 characters. | | `phone` | Required. E.164, such as `+19165550142`. Layout keeps only the digits and reads any ten as a US number, so a number whose E.164 form is ten digits long cannot be provisioned. Spaces, parentheses, dots and hyphens are dropped; any other character is `400` `invalid_request`. Sandbox takes only `+1 500 555` test numbers, such as `+15005550142`. Production refuses them. | | `email` | Optional and unverified. Kept with the provisioning link, never written to the person's Layout account and never shown to another partner. A malformed address is `400` `invalid_request`. | | `label` | Optional. Up to 120 characters, for your own reference in the console. | | `connectDelivery` | Optional. `"developer"` returns the connect link to you instead of Layout texting it (see [Someone who signs in to Layout](#connecting-an-existing-account)). Any other value is `400` `invalid_request`. | A body that is not a JSON object, or a field above that is not a string, is `400` `invalid_request` and counts toward no limit. Provisioning is idempotent by number: the same number in the same environment returns the same `id`, with `201`, and mints no second account. A number you disconnected with [DELETE /v1/users/:id](#disconnect-a-person), or whose owner disconnected you, comes back under the same `id` as a fresh invitation that needs connecting again. A number already provisioned by another application in your developer account, in the same environment, is `409` `provisioned_to_another_app`. ## Introduce Layout first Layout texts the person from its own number, and the card link opens a Layout page. Someone who has not heard of Layout from you can take either for spam and stop there. Before the first code or link reaches them, say in your own words that your app uses Layout to place their orders, that Layout will text them a code, and that they add a card once, on Layout's page, which you never see. For example: ``` Acme Assistant uses Layout to place your orders. Layout will text you a 6-digit code to link your number: read it back to me here. Before your first order you'll add a card once, on Layout's page. Acme never sees your card. ``` Keep it short and in your own voice. When you send the [card link](https://docs.layout.link/card-links), say again what it is. ## Verify their phone The person never types their number or opens a sign-in screen. Layout texts them a code, they read it back to you, and you send it to Layout. ``` POST /v1/users/usr_4b8e9a01c2d3/verify/start → 200 { "ok": true, "id": "usr_4b8e9a01c2d3", "phoneHint": "+1 (916) •••-0142", "expiresInSec": 180 } POST /v1/users/usr_4b8e9a01c2d3/verify { "code": "246810" } → 200 { "id": "usr_4b8e9a01c2d3", "verified": true, "authorized": true, "status": "active", "claimed": true } ``` `start` texts a six-digit code, good for three minutes and five tries. The text names your application as it appears in the console: `Layout: 123456 is your code to link this number to Acme Assistant, which can then place orders for you. It expires in 3 minutes. Didn't ask? Ignore this text.` Every Layout text is on [Texts Layout sends](https://docs.layout.link/texts). Reading the code back is the person's agreement that your application places orders for them. If Layout has no name for your application, the text says `Layout: 123456 is your code to verify this number for an app you are connecting to Layout. It expires in 3 minutes and does not sign you in. Didn't ask? Ignore this text.` It only verifies the number, and the person's first order then needs a code. A new `start` replaces the last code. A wrong code is `401` `bad_code`. After five wrong tries or three minutes, `verify` answers `400` `code_expired`: call `start` again. - `start`: `429` `rate_limited` when a code went to this number too recently, with `Retry-After`, `error.retryInSeconds` and `RateLimit-*` headers, or a texting limit is reached (see [Users and texts](https://docs.layout.link/limits#users-and-texts)), `502` `send_failed` when the text could not be sent, and `422` `invalid_request` when the user has no usable number. - `verify`: `400` `invalid_request` when the code is not six digits, or is missing for someone not yet verified, and `429` `rate_limited` after too many wrong codes for the number, with `Retry-After` and `error.retryInSeconds`. `authorized: true` means you can build now. A good production code makes the person's Layout account (`status: "active"`, `claimed: true`), or joins the one another application's verify made, if the text named your application. A card already on that account stays, so your first build may not ask for one. If the person signs in to Layout on this number themselves, `authorized` is `false` with `next: "connect"`: send a [connect link](#connecting-an-existing-account). Verify never returns a session or anything about an existing account. **A returning person.** A person you are already connected to can verify again on a new device. They get the same `id`. If their account now holds a different number, `start` answers `409` `phone_changed` and texts nobody. You stay connected and can keep building. Do not provision their new number: that makes a second `id` that cannot connect. Sent with no code, `verify` on someone already verified answers `verified: true` without a check, so only a request that carried a code proves they hold the phone. If someone you provisioned but never verified signs up to Layout on their own, `start` answers `409` `already_claimed`: send them a connect link. If the person once replied STOP to Layout, no code can reach them and `start` answers `409` `texts_blocked`. Ask them to reply START to the number Layout texts from, then call `start` again. ### Reading the code on Android An Android app can read the code from the text with the SMS User Consent API, which asks the person once to share that one message with your app. It needs no app hash and no `READ_SMS` permission, and the text carries no hash. Pass the six digits to your `verify` call as you would a typed code. ### Verify in sandbox A sandbox number reaches nobody, so sandbox sends nothing. With an `sk_test_` key, `start` answers the same shape plus `"sandbox": true`, and `verify` accepts the fixed code `000000`. The same per-number spacing applies as in production: a second `start` too soon, or a sixth in an hour, is `429` `rate_limited`, and `Retry-After` is the exact wait. ``` POST /v1/users/usr_4b8e9a01c2d3/verify/start → 200 { "ok": true, "id": "usr_4b8e9a01c2d3", "phoneHint": "+1 (500) •••-0142", "expiresInSec": 180, "sandbox": true } POST /v1/users/usr_4b8e9a01c2d3/verify { "code": "000000" } → 200 { "id": "usr_4b8e9a01c2d3", "verified": true, "authorized": true, "status": "active", "claimed": true } ``` Any other six-digit code is `401` `bad_code`. The code works only for a sandbox user on a sandbox key; in production `000000` is just a wrong code. A verified sandbox user stays a test account, so its orders confirm as a simulation (see [Finishing an order in sandbox](https://docs.layout.link/environments#finishing-an-order-in-sandbox)). Sandbox does not simulate a number another application verified. Sandbox numbers belong to your developer account, not to one application, so give each application its own `+1 500 555` numbers. Two ranges behave differently. A number in `+1 500 555 0050` to `0099` is a person with no card: once verified, `card` reads `{ "onFile": false, "orderable": false, "brand": null, "last4": null }`, and a confirm answers `403 build_only` with `error.next.step` `card_required`. Open a [sandbox card link](https://docs.layout.link/card-links) and press **Add test card**, and the person reads as a Visa ending 4242 and a confirm places. Another call, `card-setup/confirm`, does not add the test card. A number in `+1 500 555 2000` to `9999` that a [console sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts) holds is a person who signs in to Layout. Provisioning it, or calling `POST /v1/users/:id/connect` for it, answers `next: "connect"` with a `connectUrl` and `"sandbox": true`, and sends no text. ## Someone who signs in to Layout When the person signs in to Layout on this number, only they can connect you, on Layout's consent screen. Headless verify will not do it. By default Layout texts them the approval link and provisioning returns `next: "awaiting_user_approval"`: wait for the connection. Layout texts only for an approved application in production, and never to a number that replied STOP. In sandbox and before approval you get the link instead. To send the link yourself, pass `connectDelivery: "developer"` when you provision, or call: ``` POST /v1/users/usr_4b8e9a01c2d3/connect { "returnUrl": "https://your.app/connected" } → 200 { "next": "awaiting_user_approval", "awaitingApproval": { "phoneHint": "+1 (916) •••-0142", "expiresInSec": 600 } } POST /v1/users/usr_4b8e9a01c2d3/connect { "returnUrl": "https://your.app/connected", "connectDelivery": "developer" } → 200 { "next": "connect", "connectUrl": "https://account.layout.link/authorize?request_id=…", "expiresInSec": 600 } ``` If the text cannot be sent, you get the `connect` form instead. A person already connected answers `connected: true`. Open `connectUrl` in the person's browser. They sign in to Layout and see *"{your app} will be able to place orders for you, paid with the card on file in your Layout account. You can disconnect {your app} anytime in Layout."* Until Layout approves your application the screen says *"This app"*. Approving is their agreement that your application places orders for them. They land back at your `returnUrl` with `connected=1`, or with `error=access_denied` if they decline. `returnUrl` is optional, `https`, and at most 2,000 characters. Without one they see a short confirmation on Layout's page. The link is good for ten minutes. This is the only way an existing account is attached to you. You get nothing about their account back. ## The connection lasts Once connected, the connection lasts. You keep building for them and confirming what they say yes to, with a code only when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). It holds when they open the Layout app, add a card or sign in elsewhere. They can disconnect you at any time from their Layout account, and your next build for them then stops. ## Disconnect a person ``` DELETE /v1/users/usr_4b8e9a01c2d3 → 200 { "id": "usr_4b8e9a01c2d3", "status": "revoked" } ``` Ends your application's link to this person. It takes your secret key. Every build grant for them stops working at once, and their standing agreement that your application places orders for them ends. Their Layout account, their cards and their orders stay theirs and are not deleted. Repeating it answers the same. An id that is not your application's is `404` `not_found`. To reconnect later, provision the same number again: you get the same `id` back as a new invitation, and the person verifies again (or approves a connect link, if they sign in to Layout themselves). After a reconnect, the person's standing agreement is gone, so the first order asks for a code again: a confirm answers `code_required` with reason `first_order_consent`. Their card survives, and the cards your application's card links added still count as the application's own. ## Building without a card A person with no card can still have carts built. Layout drives the restaurant and returns a priced cart, with nothing charged. The card is needed only to place the order. Layout sets a daily ceiling on no-card builds per application, seeded from the volume you gave when you applied. Ask us to raise it as you grow. Once it is spent, the next build for a person with no card asks for a card instead. `GET /v1/usage` lists it as `cardless_builds`, with what you have used and what remains. ## When a card is needed Before the first live confirm the person needs their Layout account and a card. A good verify makes the account. A person who signs in to Layout on this number themselves is attached only when they approve a connect link (`POST /v1/users/:id/connect`); signing in does not attach them. Until then a confirm answers `403 build_only`, names the missing step in `error.next.step`, and texts nothing: `verify_required`, verify them with the two calls above; `connect_required`, send them a connect link; `card_required`, send them a card link. A card is needed when the person is ready to order, or when the day's no-card builds are used up. A build then fails with `failure.code` `no_card` (or `card_not_orderable` when Layout cannot use their card) and `failure.next.step` `card_required`. A confirm answers `build_only` with `error.next.step` `card_required`. For a person you provisioned, neither carries a `url`. Mint a card link with [POST /v1/users/:id/links](https://docs.layout.link/card-links) and send them to it. The page takes the card and sends them back with `status=card_added` or `status=canceled`, to a return URL you registered on the console's **App details** page (see [Return URLs](https://docs.layout.link/card-links#return-urls)). You never see the card number. Until the person signs in to Layout themselves, a card link is the only way to change their card. To know before a build whether to send a card link, read `card` on the verify answer or on [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id). `card.orderable: false` means send one. After a card link returns `status=card_added`, read `GET /v1/users/:id` again to confirm it, since anyone can open your return URL. Both carry only the brand and last four of the card Layout would charge, and only once the person has authorized your application. ## Sign-up protections Layout limits how fast a code can be sent and tried, so that your sign-up cannot be used to text a stranger over and over. These limits stand in front of yours, not in place of them. - **Per number:** a second code to the same number waits 30 seconds, and a number gets 5 codes an hour. A code lasts 3 minutes and takes 5 tries, then it is spent. - **Per credential:** starts per hour, counted for each credential. See [Per-minute and per-hour limits](https://docs.layout.link/limits#per-minute-limits). - **Per developer account:** an hourly pool of texts that grows with the people you verify, a daily pool, and one pool across every developer, all on [Users, sessions and texts](https://docs.layout.link/limits#users-and-texts). Put your own gate in front of the endpoint that calls `verify/start`. Require a signed-in session, a CAPTCHA or a rate limit per visitor, so an anonymous page cannot use up your texts. Layout's limits protect Layout and the person's phone; yours protect your allowance. ## Server apps and grants An application that places orders from its own server, rather than from the person's device, mints a build grant for each order session with [POST /v1/users/:id/grant](https://docs.layout.link/api#post-v1-users-id-grant). Cache the grant per user, refresh it lazily by sending it back as `refresh` just before it expires, and when any call answers `409 grant_expired`, mint a new one and retry. Do not mint one grant for every request: [minted grants are a limit](https://docs.layout.link/limits#build-grants), and refreshes are not. ## Reconciliation A person you verified who signs in to Layout later lands on the same account. If someone you provisioned but never verified signs up on their own within 30 days, the orders you built for them move to their new account. You never see a person's own orders, their orders through another app, or their card number. --- # Card links A short Layout link where a person you provisioned adds a card, with no Layout sign-in, and then goes straight back to your app. The card page texts the person no code. Layout's own texts are on [Texts Layout sends](https://docs.layout.link/texts). ## Mint a link ``` POST /v1/users/usr_4b8e9a01c2d3/links { "purpose": "card", "returnUrl": "com.acme.assistant:/layout/card", "state": "c1f9e2" } → 201 { "url": "https://layout.link/h/3q2Xw7…", "expiresAt": "2026-10-21T17:15:00.000Z", "purpose": "card" } ``` | Field | Notes | | --- | --- | | `purpose` | Required. `"card"`, the only purpose today. | | `returnUrl` | Optional. Where the page sends the person when they finish. It must be exactly one of the [return URLs](#return-urls) registered on your application's **App details** page in the console. See [What comes back](#what-comes-back). | | `state` | Optional. 1 to 512 characters, at most 1536 once percent-encoded, with no control characters. Layout hands it back to you on the return URL, so you can match the person who comes back to the link you sent. | It takes your secret key and works for every application. The person must have verified their number with your application first (see [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone)), or approved a connect link. A person you have not verified is `409` `person_not_ready`. - **Single use.** The first open spends the link. Once open, the page keeps working for 30 minutes, reloads included. - **15 minutes to open.** `expiresAt` says when. A link that expired or was already opened shows a page that tells the person to go back to your app and try again. Mint a new one then. - **Mint it when you send it.** Layout keeps no copy of the url, so it cannot be read back later. Each call mints a new link. ## When to send one Send a card link when Layout asks for a card. A build fails with `failure.code` `no_card` or `card_not_orderable` and `failure.next.step` `card_required`, or a confirm answers `403 build_only` with `error.next.step` `card_required`. For a person you provisioned neither carries a `url`, so mint a card link. You can also send one sooner, so the first order does not stop for a card: the verify answer and [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id) carry `card.orderable`, and `false` means send one. See [When a card is needed](https://docs.layout.link/provisioning#adding-a-card). ## What the person sees A full-screen page titled Pay with Layout, with your application's logo and name, which says *"{your app} partners with Layout to pay for your orders. You're only charged for orders you confirm, and {your app} never sees your card."* They enter a card, or use Apple Pay or Google Pay when their browser offers it. They do not sign in to Layout and do not make an account: verify already made it. A card link adds a card. On an account that already holds as many cards as it can, it replaces one. The page shows the card on file above the form before anything is typed, lets the person choose which card when the account holds more than one, and asks again before it saves. The new card takes the replaced card's place everywhere Layout charges, not only in your application. When a card link replaces a card your application's card links did not add, Layout emails the person to say which card was replaced, by which application. When the card is saved, or the person goes back, the page sends them to your return URL by itself. Without a return URL it stays there and says *"Card added. Go back to {your app}. You won't need to add a card there again."*, or *"No card added. Go back to {your app}."*, with no button. The name and logo are the ones on your application's **App details** page in the console, shown once Layout approves your application. Before approval, a sandbox link shows your application's name with no logo. The card is entered on Layout's page and held by Layout. It never touches your servers, and you never see it. ## Return URLs A return URL is where the card page sends the person when they finish. It is optional. A link can name only a return URL you registered for your application first, and the link must name it exactly, byte for byte. A trailing slash or a different port is a different URL. ### Register one in the console 1. Open your application in the console and choose [App details](https://console.layout.link/app-details) in the sidebar. 2. Under **Return URLs**, choose **Add return URL**. 3. Enter the URL in **Return URL** and choose **Add**. It shows in the list at once. An application holds up to 10, and the same list serves your sandbox and production keys. The console takes: - `https` with a host, such as a universal link (`https://acme.example/layout/card`). - Your app's own custom scheme, such as `com.acme.assistant:/layout/card`. - `http` on `localhost`, `127.0.0.1` or `[::1]`. The list marks these **Sandbox only**, and a live key cannot name one. A return URL cannot carry a fragment, a wildcard, a user name or password, or a `state` or `status` parameter. It must be in its exact form, so `https://Acme.example/cb` is refused with *"Register the URL in its exact form: https://acme.example/cb"*. To remove one, choose the bin icon on its row, then **Remove** to confirm. Every link that names it stops sending people back, including links already sent. A link that names a URL you have not registered is `400` `invalid_request` when you mint it. ### What comes back The card page sends the person's browser to your return URL with `state`, when you sent one, and then `status` added to its query. Any query your URL already has stays as you registered it, in front. Returns from a sandbox link look like this: ``` http://localhost:48123/layout/card?state=abc123&status=card_added http://localhost:48123/layout/card?state=back-1&status=canceled http://localhost:48123/layout/card?status=card_added http://localhost:48123/layout/card?from=app&state=q1&status=canceled ``` | Parameter | Notes | | --- | --- | | `status` | `card_added` when the page saved a card. `canceled` when the person chose **Back** or closed the form without saving one, when an order was being paid for with the card the page would replace, or, rarely, when the account filled up while the page was open and nothing was saved. In that last case the return also carries `reason=account_full`, and the page says *"No room for another card"* with *"Your Layout account already holds all the cards it can, so nothing was added. Manage your cards at account.layout.link."* Only `status=canceled` ever carries a `reason`. | | `state` | Exactly the `state` you sent when you minted the link, percent-encoded. A space is `%20`. Absent when you sent none. | Nothing else is added, except `reason=account_full` on that one canceled return: no user id, no token, no card details and no signature. Read them with your platform's URL parser, such as `URLComponents` on iOS or `URLSearchParams` on the web, rather than matching the raw string. - **It can come more than once.** If the person opens the same link again in the same browser within 30 minutes, the page sends them back with `status=card_added` again. Handle a repeat as a no-op. - **It may never come.** The person can close the tab, or have no app installed for your custom scheme. When the page cannot leave, it shows **Back to {your app}** instead. Never wait on the return to move on. - **It is not a webhook.** It is the person's own browser opening your URL. It reaches your server only when your return URL is a page on your server, and Layout sends no webhook when a card is added. ### Check it on your side Anyone can open your return URL with any `status`, so treat it as a hint about where the person is, never as proof that a card is on file or that it will pay. 1. When you mint the link, send a fresh random `state` and keep it with the person and what they were doing, such as their cart. 2. When a return arrives, look up its `state`. Ignore one you did not send or have already handled. 3. On `card_added`, read [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id) from your server. `card.orderable: true` means the card is there and Layout can order with it, so carry on with what they were doing. `false` means it did not save or cannot be used: mint a new link. If the person never comes back to your return URL, the same read tells you next time they open your app. 4. On `canceled`, say no card was added and offer to try again. The link is spent, so mint a new one. Keep the cart: a ready cart lasts 15 minutes. ## Open it in your app - **iOS app.** Open the link as a sheet, never in your own `WKWebView`: a webview your app controls can read what the person types. Use `ASWebAuthenticationSession` with a custom scheme, or `SFSafariViewController` with a universal link. - **Web app.** Open it in the same tab or a new one, with an `https` return URL on your site. Your page reads `state` and `status` from its own URL. - **Agent, chat or SMS app.** Send the url in the conversation, with one line that says what it is. Register an `https` page on your site as the return URL, which says the card is added and to go back to the chat. Use its `state` to tell your agent the person is back. With no return URL, Layout's page tells them to go back to your app. ### iOS with a custom scheme Register `com.acme.assistant:/layout/card` as a return URL and name it on the link. Pass only the scheme, with no colon, as `callbackURLScheme`. The session closes the sheet when the page sends the person back, and hands you the URL. It needs no URL type in your `Info.plist`. If the person closes the sheet, nothing comes back and the error is `.canceledLogin`: treat it as `canceled`. ``` let session = ASWebAuthenticationSession(url: cardLinkURL, callbackURLScheme: "com.acme.assistant") { callbackURL, error in guard let callbackURL, let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems else { handleCardLinkReturn(status: "canceled", state: nil) return } let status = items.first { $0.name == "status" }?.value let state = items.first { $0.name == "state" }?.value handleCardLinkReturn(status: status, state: state) } session.presentationContextProvider = self session.start() ``` ### iOS with a universal link Register an `https` return URL on a domain your app claims in its associated domains, such as `https://acme.example/layout/card`, and open the link in `SFSafariViewController`. When iOS hands the return to your app, read it in `onOpenURL`, or in `scene(_:continue:)` for an `NSUserActivityTypeBrowsingWeb` activity, and dismiss the Safari view yourself. Serve a real page at that URL too. If iOS opens it in the browser instead, the person lands on that page, so give it a button back into your app. Before you send the first link, tell the person about Layout in your own words. See [Introduce Layout first](https://docs.layout.link/provisioning#introduce-layout-first). ## In sandbox A sandbox key mints a sandbox link. Its page collects no card and saves nothing: **Add test card** sends the person back with `status=card_added`, and **Back** with `status=canceled`. Sandbox builds and confirms never need a card, except for a person verified on a number in `+1 500 555 0050` to `0099`: that person has no card until they press **Add test card**, and then reads as a Visa ending 4242 and a confirm places. The person's own `card` reads that test Visa, while a sandbox cart's `card` is always `null`. Use it to test the round trip through your return URL, including a `localhost` one such as `http://localhost:3000/layout/card`. A sandbox build never answers `card_required`, so it cannot tell you whether a card is there. ## Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | `purpose` is not `"card"`, `state` breaks its rule, `returnUrl` is not exactly one of your registered return URLs, a `localhost` `returnUrl` was sent with a live key, or `returnUrl` with `state` and `status` added would pass 4000 characters. The message says which. | | 404 | `not_found` | No such user for this application in this environment, or you disconnected them. | | 409 | `person_not_ready` | The person has not verified their number with your application, or cannot hold a card on Layout yet. `error.next.step`, when present, names the step. Verify them, then mint the link. | | 429 | `rate_limited` | More than 10 links for one person in 10 minutes, or 600 for one credential in an hour. Wait `Retry-After`. | | 503 | `unavailable` | Layout could not check the person or the return URL just now. Nothing was minted. Retry shortly. | > The card link is how a person adds a card. If you need card entry inside your own UI, write to [developer@layout.link](mailto:developer@layout.link). --- # 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. > A cart id can hand you on to another. When a cart reads `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. ```js 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](https://docs.layout.link/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](https://docs.layout.link/how-it-works#two-doors). | 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](https://docs.layout.link/how-it-works#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](#sandbox). ```bash 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](#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](#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](https://docs.layout.link/sandbox#choose-the-ending). | | `latency` | No, sandbox only | `realistic` (the default) waits about as long as a real build; `instant` skips every wait. See [Timing](https://docs.layout.link/sandbox#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](https://docs.layout.link/sandbox#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](https://docs.layout.link/limits#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](https://docs.layout.link/api#get-v1-places), using the best location you have: - **The device's coordinates**, as `lat` and `lng`, without `near`. `distanceMi` is measured from the person. - **Their address or ZIP code, geocoded** by your platform into `lat` and `lng`, 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 as `near` comes back with `localityMatched: 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](#build-time). ``` 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](https://docs.layout.link/sandbox#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](#answer). | | `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](#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](#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](https://docs.layout.link/card-links) 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](https://docs.layout.link/sandbox#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](#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" } } ``` ```bash 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](#code-required)). In sandbox the order is simulated. Send an `idempotencyKey`, or the header, by the same rules as a build. ```bash 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](https://docs.layout.link/api#pickup-time)). For the texts Layout sends the person, see [Texts Layout sends](https://docs.layout.link/mcp#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](https://docs.layout.link/limits#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](https://docs.layout.link/mcp#if-the-person-misses-the-card)), 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](https://docs.layout.link/how-it-works#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"](https://docs.layout.link/sandbox#choose-the-ending) or at [test_place_code](https://docs.layout.link/sandbox#the-test-restaurants) asks for one: `codeReason` is `risk_check`, nothing is texted, and the code is `000000`. 1. Call confirm. The answer is `400 code_required`, and Layout texts the person a code. 2. Ask the person for it. 3. 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](#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](https://docs.layout.link/how-it-works#when-a-code-is-due). 1. Show the person the cart's items, store and `totalMinor`, and wait for their yes. 2. Call confirm with no code. When no code is due, the answer is `202 placing` and you are done. When one is, the answer is `400 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 a `first_order_consent` code names your application (its name in the console) and says that sharing the code lets it order without one. 3. Ask the person for the code, then send the same request again with the same `idempotencyKey`, plus `"code"`. The answer is `202 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](https://docs.layout.link/limits#order-codes). 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](https://docs.layout.link/provisioning#adding-a-card). | `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](https://docs.layout.link/card-links) 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-After` is 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](https://docs.layout.link/limits#per-minute-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](https://docs.layout.link/sandbox). ## Errors Every error has one shape, and every code a cart call can answer is listed in [Errors and status](https://docs.layout.link/errors#every-error-code). --- # Webhooks Layout POSTs a signed event when an order you initiated changes state. Delivery is fire-and-forget with retry, and never delays or alters an order. ## Subscribe Add a subscription in the console with a notification URL and the events you care about. HTTPS only, never a private or internal host and never a `layout.link` host. An application holds up to 10 subscriptions; another is refused with `limit_reached`. Choose what it **Receives**: `sandbox`, `live` or `both`, which is the default. A subscription set to `sandbox` never sees a production order, so a development tunnel cannot receive one. Over the API it is the optional `receives` on `POST /v1/console/apps/:appId/webhooks` and `PATCH` on the subscription; anything else is `400 invalid_receives`. Your signing secrets are on the [Webhooks](https://console.layout.link/webhooks/subscriptions) page: sandbox and production sign with **different** secrets, so hold both and verify each delivery with the one for its environment. A subscription lists the events it receives, and a new event type reaches none you already have until you add it. `order.refunded` is one of them: see [order.refunded](https://docs.layout.link/events#order-refunded). Switching a subscription off, or deleting it, stops new events at once. A delivery already queued, including one waiting on a retry, keeps its schedule and still goes to the URL it was written for, until it is delivered or dead-lettered. Events cover orders built for users you provision and orders people place through an [OAuth client you created](https://docs.layout.link/authentication#oauth-clients). See [which orders send events](https://docs.layout.link/events#which-orders-send-events). An OAuth client's orders are sandbox only for a [sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts) you create in the console; every other person, your own Layout account included, places real orders and can connect through your client only once your application is approved. ## Endpoint verification When you save a new subscription, or change its URL, Layout sends one signed `endpoint.verification` event to that URL. A new subscription stays off until your endpoint answers that event with a 2xx, then turns on by itself. Nothing in the response body is read, so there is no challenge to echo: return 2xx. ```json { "event": "endpoint.verification", "delivery_id": "dl_7f0c2a91b3e4", "subscription": { "id": "9b1e6c2a-4f7d-4e1a-8c3b-2d5f6a7b8c9d" }, "sent_at": "2026-09-30T10:14:03Z", "test": true } ``` - It is a signed `POST` with `X-Layout-Event: endpoint.verification` and `"test": true`, and names no order. Any 2xx verifies the endpoint. - It is signed first with the secret of the environment selected in the console. A 4xx or 5xx gets it once more, signed with the other environment's secret. A redirect or a timeout is not sent again. - A changed URL keeps delivering to the old one until the new one verifies. Saving the old URL again cancels the change. - Press **Verify** on the subscription to send it again. 50 verifications a day per developer account. The CLI's `layout webhooks verify ` does the same, with `POST /v1/cli/apps/:app/webhooks/:id/verify` and a body of `{ "environment": "sandbox" }` or `"live"`, which picks the signing secret tried first. ### Events held while an endpoint is unverified While a subscription's endpoint has not verified, its events are not lost. They are **held**, in the delivery log and in [the event feed](https://docs.layout.link/api#get-v1-events), and Layout tries the endpoint again on its own. The subscription's `verification.nextCheckAt` says when, and is `null` when no check is waiting. | Check | When | | --- | --- | | 1 | About 1 minute after the failed verification | | 2 | About 10 minutes later | | 3 | About 1 hour later | | 4 | About 6 hours later | | Then | Daily, for 7 days | - These checks do not count against the 50 manual verifications a day. Each manual verify restarts the schedule. - On the first 2xx the subscription turns on and the held events are delivered in order. - A held delivery has the status `held`. It becomes `expired` when it has been held for more than 72 hours, when more than 1,000 are held for the endpoint, or when the endpoint was removed, or verified while the subscription was switched off. Neither status can be replayed, and an expired event is not delivered. ## Verify the signature The `X-Layout-Signature` header is `t=,v1=`, where `v1` is `HMAC_SHA256(secret, "${t}.${rawBody}")` as a hex digest over the raw request bytes. While a rotated secret is still valid the header carries two signatures, `t=,v1=,v1=`. **Accept the request if any `v1` matches**, not only the first one. Pick the secret by the delivery's environment: the `X-Layout-Environment` header is `sandbox` or `live`, each signed with that environment's own secret. A sandbox secret can never produce a valid signature for a live delivery. **Enforce the timestamp yourself.** Layout does not refuse a late delivery on your behalf. Reject any `t` more than 5 minutes from your clock, which bounds how long a captured request can be replayed. ```js import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; export function verifyLayoutSignature(rawBody, header, secret) { if (!secret) return false; let t = NaN; const signatures = []; for (const part of String(header || "").split(",")) { const at = part.indexOf("="); const key = part.slice(0, at); const value = part.slice(at + 1); if (key === "t") t = Number(value); if (key === "v1") signatures.push(value); } const now = Math.floor(Date.now() / 1000); if (!Number.isFinite(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false; const expected = Buffer.from( crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex") ); return signatures.some((s) => { const got = Buffer.from(s); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); } ``` Wired into Express, reading the raw body and answering before the work starts. An environment whose secret you have not configured answers 400, not 500: ```js app.post("/webhooks/layout", express.raw({ type: "application/json" }), (req, res) => { const secret = req.get("X-Layout-Environment") === "live" ? process.env.LAYOUT_WEBHOOK_SECRET_LIVE : process.env.LAYOUT_WEBHOOK_SECRET_SANDBOX; const raw = req.body.toString("utf8"); if (!verifyLayoutSignature(raw, req.get("X-Layout-Signature"), secret)) { return res.status(400).end(); } res.status(200).end(); queue.push(JSON.parse(raw)); }); ``` ## Rotate the signing secret **Rotate** on the Webhooks page replaces the signing secret for the environment you have selected. For the next 24 hours every delivery in that environment carries two signatures, the new secret's first and the previous secret's second. A verifier that accepts any matching `v1` keeps passing whichever secret it holds, so you can deploy the new one any time inside that window. The page shows when the previous secret stops signing. - **Check your verifier before you rotate.** One that reads only the first `v1` fails on every delivery from the moment you rotate until you deploy the new secret. - While a previous secret is still signing, a second rotation is refused. The console offers to end the overlap instead: the older secret stops signing at once, your current secret signs beside a new one for 24 hours. Use it when a secret has leaked. - Rotating a production secret needs a sign-in from the last 15 minutes. - Sandbox and production rotate separately. ## Delivery and retries The first attempt usually arrives within seconds. Answer 2xx within 5 seconds, before doing the work; an answer not finished within 10 seconds also fails. Anything outside 2xx is retried, five attempts in total, each wait varying by up to 15%: | Attempt | Sent | | --- | --- | | 1 | A few seconds after the change | | 2 | About 30 seconds later | | 3 | About 2 minutes later | | 4 | About 10 minutes later | | 5 | About 1 hour later | - After the last attempt a delivery is dead-lettered. Replay it from the delivery log in the console, up to 200 replays a day for your developer account. A `held` or `expired` delivery cannot be replayed. - The delivery log and the [event feed](https://docs.layout.link/api#get-v1-events) keep 30 days. An older delivery is deleted and cannot be replayed or read. - **A 3xx is a failure.** Layout never follows a redirect, so point the subscription at the final URL. - **At least once.** The same event can arrive more than once: a retry of one delivery keeps its `delivery_id`, and a replay arrives under a new one. Every copy carries the same `event_id`, so skip an `event_id` you have already handled. The order id and the event together name one event for every type except `order.updated`, which can come more than once per order: de-duplicate on `event_id`. - **No ordering guarantee.** Events can arrive out of order. Read `order.state`, or `GET /v1/orders/:id`, rather than inferring state from arrival order. - Every request carries `X-Layout-Event`, `X-Layout-Delivery`, and `X-Layout-Environment` alongside the signature (User-Agent `Layout-Webhooks/1`), so you can route, deduplicate, and pick the signing secret without parsing the body. ## Test deliveries **Send test event** on a subscription fires a real, signed delivery at your endpoint, so you can prove the whole path works before a single order exists. Same signing secret, same retries, same everything. It is always an `order.placed`, and it goes to that subscription whatever events it lists, even while it is off. 50 a day for your developer account. To send any event type from a script or CI, `POST /v1/sandbox/webhooks/fire` with your `sk_test_` key. See [Fire a test webhook](https://docs.layout.link/sandbox#fire-a-test-webhook). A test payload carries `"test": true` at the top level, and so does an `endpoint.verification` event. Answer either with a 2xx and skip the work: ``` if (payload.test) return res.status(200).end(); ``` The ids inside are fixed placeholders that match nothing real: the order is `ord_test_000000000000` and the user `usr_test_000000`, unless you fire the event with the `orderId` of one of your sandbox orders. If you look one up and get nothing back, that is the right answer, not a bug. ## Payload Every order event has the same shape, and no event carries card data. See [Example payload](https://docs.layout.link/events#example-payload) for the fields. --- # Texts Layout sends Every text Layout sends a person on behalf of your application, word for word, when it goes out and whether you can turn it off. Each comes from Layout's number, and none carries anything your application wrote. In sandbox nothing is texted, except where a row says otherwise. ## Setting a person up | Text | When Layout sends it | Can you turn it off | | --- | --- | --- | | Verification code, application named | `POST /v1/users/:id/verify/start`, when Layout has a name for your application. `Layout: 123456 is your code to link this number to , which can then place orders for you. It expires in 3 minutes. Didn't ask? Ignore this text.` | No | | Verification code, existing account | `verify/start`, when the person already has a Layout account and your application is named. `Layout: 123456 is your code to connect to your existing Layout account, and can then place orders for you. It expires in 3 minutes. Didn't ask? Ignore this text.` | No | | Verification code, generic | `verify/start`, when Layout has no name for your application. `Layout: 123456 is your code to verify this number for an app you are connecting to Layout. It expires in 3 minutes and does not sign you in. Didn't ask? Ignore this text.` | No | | Connect approval | The person already signs in to Layout on this number, your application is approved, and you did not ask for the link (`connectDelivery: "developer"`). ` wants to place food orders for you through Layout. Tap to approve: ` An application that is not approved shows as `A partner app`. | Yes: ask for the link with `connectDelivery: "developer"` and send it yourself | A code is good for 3 minutes and 5 tries. A number can be sent a code every 30 seconds and 5 times an hour. A number that replied STOP receives none of these, and `start` answers `409 texts_blocked` until the person replies START. See [Sign-up protections](https://docs.layout.link/provisioning#sign-up-protections) and [Users and texts](https://docs.layout.link/limits#users-and-texts). ## Confirming an order | Text | When Layout sends it | Can you turn it off | | --- | --- | --- | | Order code | A production confirm that needs the person's code, when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). `Layout: 123456 approves $18.40 at Acme Burgers on your Visa ending 4242. Sharing this code places the order. It expires in 10 minutes. If you did not just order, ignore this text.` The total, store and card are the cart's own. While a first order also gives your application standing to order without a code, the second sentence says so and how to disconnect it. | No | ## About an order Every text about an order your application started, over REST or MCP, for a user you provision or through an OAuth client you created. | Text | When Layout sends it | Can you turn it off | | --- | --- | --- | | The cart, to reply YES | A ready cart that nothing confirmed or canceled about 15 seconds after it landed (see [If the card is missed](https://docs.layout.link/mcp#if-the-person-misses-the-card)). Layout answers the person's YES in the same conversation. | Yes, with **Cart-ready text** | | Cart reminder | Once, a few minutes later, when that cart is still waiting. | Yes, with **Cart-ready text** | | Approval link | The restaurant's checkout asks the person to approve the purchase, and `order.challenge` is sent. It names the total and the store. If the person asks for a change on that page and it cannot be made, Layout texts that nothing was ordered or charged. | No | | Order placed | The order is placed. It says so, with the pickup details Layout has at that moment. | Yes, with **Order notifications** | | Pickup time | The pickup time arrives after the order was placed (see [Pickup time](https://docs.layout.link/api#pickup-time)). One text with the time. | Yes, with **Order notifications** | | Order did not go through | The order failed, or its cart failed while it was building. | No | Both settings are on the App details page of the console, are on for every application, and cover every order your application starts. A change applies to every text not sent yet, including for orders already in flight. Turning **Order notifications** off also stops Layout's placed push notification and the Live Activity in the Layout app. The receipt email still goes. A person you provisioned who has no email on their Layout profile hears nothing from Layout about the order, so tell them yourself from `order.placed`, `order.updated` and [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). No setting stops an order code, an approval link or the text saying an order did not go through. Two orders are always texted whatever the setting: an order Layout already texted a failure for that is placed after all, and an order the person confirmed by replying YES. ## The card page A [card link](https://docs.layout.link/card-links) opens a page and texts no code. Sending the link is yours to do. --- # Ordering over MCP MCP is one of two doors to the same ordering; [REST carts](https://docs.layout.link/carts) is the other, with the same checks behind both. Mint a build session for a provisioned user, point an MCP client at our server, and drive the `order` tool. The session builds and reads a cart, and confirms it once the person says yes, with the 6-digit code Layout texts them when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). In sandbox a cart is built from the restaurant's menu as Layout holds it, with no browser, and the confirm is always simulated; it takes no code unless the cart was built to ask for one. A person with their own Layout account can instead connect through an OAuth client you created, and confirm from your app. ## Connect Mint a build session with `POST /v1/users/:id/grant` and send its `lgb_` token as a bearer credential. The grant authenticates the session for that one provisioned user, with no OAuth flow. A secret key sent to `mcp.layout.link` is refused with `401`. An expired build grant answers `401` `grant_expired` on both hosts, and its `WWW-Authenticate` carries `error_description="grant_expired"`. Mint a new grant. Any other refused grant answers `unauthorized`. Every answer that comes before the call is on [MCP tools](https://docs.layout.link/mcp-tools#before-the-call). When the person has their own Layout account and signs in themselves, connect with an [OAuth client you created](https://docs.layout.link/authentication#oauth-clients) instead. The MCP client starts the standard authorization flow at `https://mcp.layout.link`, whose metadata names `https://api.layout.link` as the authorization server, and the session acts as that person, who approved your app on Layout's consent screen. ```json { "mcpServers": { "layout": { "url": "https://mcp.layout.link", "headers": { "Authorization": "Bearer lgb_9f3a2c…" } } } } ``` ## The tools A build session has three tools: `order`, `order_status` and `places`. A sandbox build session also has `menu`. `order` is a state machine rather than separate calls: `action` walks it through `preview`, `build`, `status` and `confirm`. An OAuth connection acts as the person, so it gets every Layout tool, including `menu` and `item_photo`. | Tool | What it does | | --- | --- | | `order` | Resolve a restaurant, build a cart and confirm it. Driven by `action`, below. | | `order_status` | Long-poll a build (by `idempotencyKey`) or a placement (by `orderId`, which also takes the `ord_` id) until it settles. Read-only and free. A ready cart reads `placement.status: "carted"`: stop polling, it waits on the person. A placed order carries `placement.pickup`. | | `places` | Find restaurants by name or description, from the restaurants Layout holds. It never opens a restaurant's site. In sandbox it lists only restaurants whose menu Layout holds, and an empty list it emptied for that reason carries `emptyBecause: "no_held_menu"`: search another restaurant, kind of food or area. | | `menu` | Sandbox build sessions only. The menu Layout holds for a place, the one a sandbox cart there is priced from. It never reads a menu live, costs nothing, and can be called whenever you need to check what a place sells. `mode: "knowledge"` answers the same way. A place with none answers `status: "unreadable"` with `code: "menu_not_held"`. | Every tool's full input and output JSON Schema, and the raw JSON-RPC calls, are on [MCP tools](https://docs.layout.link/mcp-tools). How each tool, state and id maps to REST is in [Two doors, one order](https://docs.layout.link/how-it-works#two-doors). An OAuth connection also has `item_photo`: what one menu item looks like, from the restaurant's own published photo. It answers `photo` with an `imageUrl`, `pending` while Layout is still looking (ask again shortly), or why there is none. It is the same lookup as [POST /v1/images/items](https://docs.layout.link/api#post-v1-images-items), which a build session can use over REST. ### The order actions | action | What it does | | --- | --- | | `preview` | Resolve the restaurant and estimate. Pass the restaurant's name in `restaurant` (or in `query`), not the food. A name Layout cannot find answers `store_not_found` either way. | | `build` | Start building the cart. Holds for up to about 15 seconds: a build that finishes inside that window comes back with its result, `carted` when the cart is ready. Otherwise it returns `building` and you poll `status` for the rest. A build session orders pickup only: a build with `fulfillment: "delivery"`, `deliverTo` or `tipPercent` builds nothing and answers `deliveryUnavailable: { reason: "not_enabled" }`. | | `status` | Poll a build by `idempotencyKey`, or a placement by `orderId`. | | `confirm` | Place the cart the person approved. On a production build session it needs the code Layout texts the person when a code is due: the call without one then texts it and answers `confirm.error: "code_required"`. When none is due it places. On a sandbox build session for a provisioned test user it is always simulated. On an OAuth connection it places a real order, passing the code Layout texted the person in `code` when the build's `confirmation.method` is `sms_code`; for a [sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts) it is simulated and answers `simulated: true`. | A `build` that carries a `placeId` and a `restaurant` or `restaurantName` naming a different store builds nothing and answers `storeMismatch`: `store` is the store the `placeId` names, with its address, and `candidates` are stores matching the name when Layout could find them. Layout compares every word of the name you send against the store's name and address, as `POST /v1/carts` does. Ask the person which one they mean and build again with that store's `placeId`, its name as `restaurant`, a `near`, the order in `query` and a fresh `idempotencyKey`. When a build asks which branch (`needsLocation`), build with the chosen option's `placeId` and its own name as `restaurant`. A build needs `query` and a store to build on: `platform`, an https `orderUrl`, or a restaurant name in `restaurant` or `restaurantName` with `near` or `geo`. Pass the `placeId` too when you have it. A `placeId` alone is refused as `invalid_input`. When the person says no, call `order` with `action: "status"`, `cancel: true` and the cart's `orderId` to release it. The schema requires `action`. A cart still building has no `orderId` yet: cancel it with that build's `idempotencyKey`. The answer is `cancel.status: "canceling"`, Layout releases the cart the moment the build lands and never texts the person about it, and a later cancel answers `canceled`. A build session can cancel by key only a build its own application started; for any other key the answer is `no_active_order`. A cancel on a build session naming neither an `orderId` nor an `idempotencyKey` is refused as `forbidden`. A cart already confirmed answers `already_placing` or `placed`, and is not canceled. ### What a preview concluded A `preview` result carries `preview.outcome`, one word for what Layout found. Branch on it rather than working it out from the other fields. | outcome | What it means | | --- | --- | | `ready` | A store resolved and nothing stands in the way. Call `build` next. | | `needs_location` | Layout needs to know which branch. Offer the options in `preview.nearby` and preview again with the chosen `placeId`. | | `closed` | The store is closed right now. Relay `preview.sayToUser`; it names any open alternative. | | `closing_soon` | Open, but too close to closing for Layout to take the order. | | `not_orderable` | The restaurant takes no online orders Layout can place. | | `blocked` | Layout does not place orders at this restaurant. | | `store_not_found` | Layout found no restaurant by that name. `preview.storeNotFound.candidates` are restaurants search found under other names, within about 30 miles; offer them by their own names, never as branches, and build at one only if the person picks it, with its `placeId` and its own name as `restaurant`. A build with a name and no `placeId` answers the same way. | When the person names a street and exactly one open branch sits on it, the preview resolves that branch instead of asking which one, and the confirmation card still names the store before anything is bought. ### Confirm with the person's code On a build session, a person who has not yet agreed to your application ordering for them is asked for a code on their first order (`first_order_consent`), and that code is the agreement. When they have already agreed, and which other confirms ask for a code, is in [When a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). Call confirm with no code either way; it answers `placing`, or `code_required` when a code is due. When the person is not ready to confirm, it answers `build_only` with `confirm.next.step` (`verify_required`, `connect_required`, `phone_required` or `card_required`), and texts nothing. On `card_required`, mint a [card link](https://docs.layout.link/card-links) and send it to them. A build session gets no `confirmationCard`: tell the person the items, the store and `price`, the build's `totalMinor`, which includes Layout's service fee, and wait for their yes. `cart.merchantTotalMinor` is the restaurant's own total. A confirm that goes ahead answers in `confirm.status`: `placing`, `code_sent` after a resend, `code_not_needed`, `already_placing`, `placed`, `not_confirmable_cold`, or `session_gone` when the cart's checkout session ended and it must be built again. `not_confirmable_cold` carries `reason`: `canceled`, `expired`, `closed` or `not_found`. It can also answer `failed`, with a `code`, when nothing was placed; `confirm_expired` or `not_placed`, when nothing was placed and the cart must be built again; `confirmed`; or `place_error`. A confirm on a carted build more than fifteen minutes after it was ready answers `confirm_expired`, and nothing is charged. On `place_error`, relay `userMessage`, follow `assistantInstruction`, and poll `status` with the `orderId`: the outcome can be unknown. A confirm Layout refuses is still an ordinary tool result, not `isError`: `confirm.status` is `"error"` and `confirm.error` names the reason, so branch on `confirm.error`. `code_required`, `build_only`, `price_changed` and `send_failed` all arrive this way. 1. Call `confirm` with the `orderId`, the carted build's `totalMinor` (fee included) as `expectedTotalMinor`, a fresh `idempotencyKey` and no `code`. When a code is due, the answer is `confirm.error: "code_required"`, and Layout texts the person a code that names the total, the store and the card, in Layout's words. `codeSent: true` means it just went out. `codeSent: false` means a code Layout texted for this cart in the last 10 minutes is still good and nothing new was sent: ask the person for that one. A text that could not be sent answers `send_failed`: retry shortly. `texts_blocked` means the person's number replied STOP to Layout and nothing was texted. 2. Ask the person for the code. 3. Call the same `confirm` again, with the same `idempotencyKey`, plus `code`. The answer is `placing`; poll `status` with the `orderId` until it is `placed`, `failed` or `unconfirmed`. The code works once, for this cart and this total, for 10 minutes. `stage: "resend"` texts a fresh one, which replaces the last, and answers `code_sent`; when no code is due it answers `code_not_needed` and texts nothing. Layout texts at most four codes per cart: past that, or past your application's daily code allowance, the confirm answers `rate_limited` with a `reason`. A wrong code answers `bad_code`, and a code that no longer fits answers `expired`, `amount_mismatch` or `no_pending_auth`. On `too_many_attempts`, wait `retryAfterSec`, ask the person for the code again and confirm with it. On `limiter_unavailable`, when Layout could not count the tries, or a `rate_limited` with `retryAfterSec`, wait that long and send the same call again. A session can confirm only carts it built. Never invent a code, and never ask the person for a Layout sign-in code: an order code only approves the one cart it names. Other refusals carry the same `confirm.error` shape. These confirm and charge nothing: `price_changed` (the total no longer matches: build a fresh cart with the same request and a new `idempotencyKey`, which carries the moved total, tell the person, and confirm with it), `over_daily_cap` (the account's daily spending limit), `risk_denied`, `idempotency_conflict` (the key was used for a different request; retry with a fresh one) and `unavailable` (a check could not run; retry the same confirm shortly). `in_progress` means a confirm with this `idempotencyKey` is still running: poll `status` with the `orderId`, and never confirm again. `confirm_failed` can come after the order was handed off: Layout lost track of the confirm and the order may be placing. Follow `assistantInstruction`, poll `status` with the `orderId`, and never confirm again. A build session's confirm that sends the restaurant's total alone, without Layout's service fee, answers `price_changed` with `reason: "fee_not_included"`. The price did not move: confirm with the build's `totalMinor`, the total the person was shown. ### Build in sandbox A sandbox session builds at the restaurants its `places` tool lists, which in sandbox are only those whose menu Layout holds. A `build` also takes `simulate` (the ending), `latency` (`realistic` or `instant`) and `engine` (`shadow` or `real`), exactly as [Sandbox](https://docs.layout.link/sandbox) describes. A production session that sends any of them is refused with `sandbox_only`. A restaurant whose menu Layout does not hold, or a build that names no place Layout can tie to a `placeId`, answers a build refusal with `refusal.code: "menu_not_held"`: pick another place from `places`. A cart that fails `ITEM_UNAVAILABLE` named something the menu does not list, or lists as sold out: call `menu` with that `placeId` and build again with names it lists. A cart that answers `needs_choices`: ask the person, then build again with the same `query` and store, a new `idempotencyKey`, and `choices` set to every answer given so far, joined with `"; "`. How the request is matched to the menu is in [How a sandbox cart is built](https://docs.layout.link/sandbox#how-a-sandbox-cart-is-built). The tools a sandbox session lists, and its instructions, are in [MCP tools](https://docs.layout.link/mcp-tools). ### Confirm in sandbox Pass the `orderId` from the carted build, its `totalMinor`, which includes Layout's service fee, as `expectedTotalMinor`, and a fresh `idempotencyKey`. No code is needed, except on a cart built with `"simulate": "code"` or at `test_place_code`: its confirm answers `confirm.error: "code_required"` with `codeSent: false` until it carries `"code": "000000"`, and nothing is texted. Any other code answers `bad_code`. ``` order { "action": "confirm", "orderId": "0b6f2a54-…", "expectedTotalMinor": 1949, "idempotencyKey": "a7c1e0d2-…" } → { "action": "confirm", "confirm": { "status": "placed", "orderId": "0b6f2a54-…", "totalMinor": 1949, "merchantTotalMinor": 1850, "subtotalMinor": 1701, "simulated": true, "userMessage": "This is a test account, so that was a simulated order. No money moved, no card was charged, and no restaurant received it." }, "publicOrderId": "ord_7c21e4b9a0d3" } ``` A build session's confirm carries three amounts: `totalMinor`, the total you confirmed with, fee included, on every confirm about an order that went ahead, including `already_placing` and an `alreadyConfirmed` answer, where it is the order's own total; `merchantTotalMinor`, the restaurant's own total; and `subtotalMinor`, the food before tax, absent when Layout cannot read it. `simulated: true` means no restaurant received the order and nothing was charged. Never describe it as a real order. A sandbox cart at a real restaurant, and `test_place_placed`, `test_place_failed`, `test_place_unconfirmed`, `test_place_challenge`, `test_place_code` and `test_place_pickup_later`, answer the confirm with `status: "placing"` and `simulated: true`: poll `status` with the `orderId` for the ending. Every other test restaurant answers `placed` in the same call, as above. A total that no longer matches the cart is refused with `price_changed`, just as in production. A simulated order still sends an `order.placed` webhook, and its `order` object carries `"simulated": true`, as does `GET /v1/orders/:id`. A real order never carries that key. A `resend` stage on a sandbox session texts nothing and places nothing: it answers `code_not_needed`, or `code_sent` on a cart built with `"simulate": "code"` or at `test_place_code`; nothing is texted, and the code (`000000`) is not in `userMessage`. A sandbox session releases a cart it built the same way as production, with `action: "status"`, `cancel: true` and the `orderId`, or the build's `idempotencyKey` while it builds. A `preview` with a `test_place_` `placeId`, or a `restaurant` naming a Layout Test Kitchen store, resolves that [test restaurant](https://docs.layout.link/sandbox). ## Relay the card verbatim On an OAuth connection, a finished build carries a `confirmationCard` that Layout renders server-side with the store, items, total and pickup time. Relay it to the person **exactly as returned**. Never claim an order is placed until the status is `placed`. That is the only status that means the order exists. ## Ask only from Layout's data When a build comes back `needs_choices`, the restaurant is insisting on something and `choiceDecision.groups` carries their exact wording. Offer those options, nothing else, and build again with the answer in `choices`. Resist filling gaps from what you know about the brand. You have no idea what this particular store stocks today, and a question you invented costs the person a turn on an option that may not exist. ## After the confirm ### Already confirmed elsewhere The person can confirm the cart themselves, by replying YES to Layout's text, in the Layout app, or on Layout's tracking page. A `confirm` after that sends nothing to be placed and charges nothing again. Its result carries `alreadyConfirmed: true` and `orderState`, the order's real state: `placing`, `placed`, `unconfirmed` or `refunded`. `confirmedVia` is `sms` or `tracking_link` when Layout knows where. `status` is `already_placing` while it is placing, `placed` once it is, and `not_confirmable_cold` for the other two. While it is `placing` or `unconfirmed` the result also carries `complete: false` and `nextAction: "order_status"`: poll `status` with the `orderId`. Relay its `userMessage`, and never call `confirm` or build the order again. ### An unconfirmed placement When Layout submitted the order and the restaurant's site never said whether it went through, the placement poll answers `status: "unconfirmed"`. The order may exist. Do not say it failed, and do not build or confirm it again: tell the person to check their email or call the store before ordering again. Later polls of the same `orderId` answer `unconfirmed` until Layout learns the outcome, and can then answer `placed`, for instance when the restaurant's receipt arrives; `order.placed` says so too. A sandbox test order that ends unconfirmed answers `status: "unconfirmed"` too, with `outcome: "unconfirmed"` and `failureCode: "UNCONFIRMED"`. Read `GET /v1/orders/:id` with your secret, whose `state` is `unconfirmed`, or the `order.unconfirmed` webhook, for the order's state. ### The order id for REST Once a result names an order, from `carted` on, it also carries `publicOrderId`: the `ord_` id that `GET /v1/orders/:id` and your webhooks use. It appears only on orders your build session or your OAuth client drove. While a cold build is still `building` there is no order id yet; keep polling, or take it from the `order.building` webhook. ## Reading a menu On an OAuth connection, `menu` reads the restaurant's live menu. A person gets 30 live menu reads a day, and an answer Layout already has does not count. Past that, `menu` answers from general knowledge when it can (`status: "knowledge"`, `live: false`), and otherwise comes back `limit_reached`; with `retryAfterSec`, wait that long. Never quote a knowledge answer as the store's current prices. A production build session has no `menu` tool. A sandbox build session's `menu` answers only with the menu Layout holds, and with `code: "menu_not_held"` when it holds none. ## Texts Layout sends Every text Layout can send a person about an order your application started, over REST or MCP, for a user you provision or through an OAuth client you created. Each comes from Layout's number. In sandbox nothing is texted. Every other text, including verification codes and connect links, with its exact copy, is on [Texts Layout sends](https://docs.layout.link/texts). | Text | When Layout sends it | Can you turn it off | | --- | --- | --- | | The cart, to reply YES | A ready cart that nothing confirmed or canceled about 15 seconds after it landed (see [If the card is missed](#if-the-person-misses-the-card)). Layout answers the person's YES in the same conversation. | Yes, with **Cart-ready text** | | Cart reminder | Once, a few minutes later, when that cart is still waiting. | Yes, with **Cart-ready text** | | Order code | A production confirm that needs the person's code. It names the total, the store and the card (see [Confirm with the person's code](#confirm-with-a-relayed-code), or [code_required](https://docs.layout.link/carts#code-required) over REST). | No | | Approval link | The restaurant's checkout asks the person to approve the purchase, and `order.challenge` is sent. It names the total and the store. If the person asks for a change on that page and it cannot be made, Layout texts that nothing was ordered or charged. | No | | Order placed | The order is placed. It says so, with the pickup details Layout has at that moment. | Yes, with **Order notifications** | | Pickup time | The pickup time arrives after the order was placed (see [Pickup time](https://docs.layout.link/api#pickup-time)). One text with the time. | Yes, with **Order notifications** | | Order did not go through | The order failed, or its cart failed while it was building. | No | Both settings are on the App details page of the console, are on for every application, and cover every order your application starts. A change applies to every text not sent yet, including for orders already in flight. Turn **Order notifications** off when your product tells the person their order is placed and when to pick it up, and you want them to hear it only from you. It also stops Layout's placed push notification and the Live Activity a person with the Layout app sees on their Lock Screen. Your application then owns both: read them from `order.placed`, `order.updated` and [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). It never stops Layout's receipt email. A person you provisioned who has no email on their Layout profile then hears nothing from Layout about the order, so tell them yourself. Turning off the **Cart-ready text** also stops the Layout app's Live Activity from buzzing the person to review and place a ready cart. Two orders are always texted, whatever the setting: - An order Layout already texted a failure for, that is placed after all (the store's email confirms it later). The person has to hear that it went through, or they may order again. - An order the person confirmed by replying YES to Layout's cart-ready text. They are already in a text thread with Layout, and its reply told them a text would follow. No setting stops an order code, an approval link or the text saying an order did not go through. A person who never hears that an order failed may order again and be charged twice. Texts that set a person up, such as a verification code or a connect link, are on [Provisioning](https://docs.layout.link/provisioning). ### If the card is missed If nothing confirms or cancels a ready cart about 15 seconds after it lands, Layout texts the person the same cart: the store's name and address, each item, the total, and the card it goes on. For a cart your application built, the text opens with your application's name as it is in the console: `Acme Assistant built your order at Layout test kitchen, 1 Sandbox Way, San Francisco, CA 94107:`. They reply YES to place it; once a code is out for the cart, a YES points them to the code instead. If the cart is still waiting a few minutes later, Layout sends one reminder. A cart already confirmed or canceled, even while it was still building, gets no text, and neither does a number that opted out of Layout texts. An order that needs an extra confirmation step tells them to open the Layout app instead of replying. If your own chat carries the confirm and you want the person to hear only from you, turn off **Cart-ready text** on the App details page of the console. A build session then confirms itself, with the person's code when one is due. --- # Sign in with Layout One button that signs a person in with their Layout account and lets your app place orders for them, paid with the card they keep in Layout. ## Two ways in There are two ways to connect a person to your app. This page covers the first. | Way | What the person sees | Use it when | | --- | --- | --- | | [Sign in with Layout](#hosted) | Layout's own sign-in and approval page, in the system browser sheet. | You want the shortest build. Layout handles sign-in, the code and consent. | | [Provisioning](https://docs.layout.link/provisioning) | Your own screens. Layout texts the person a code and they read it back to your app. | You already collect the person's phone number and want to stay on your screens. | The two ways give the same person different ids, so pick one way for each person. ## Hosted ### 1. Create an OAuth client On the console's [OAuth clients](https://console.layout.link/oauth-clients) page, add your redirect URIs. You get a `client_id`. It is a public client: authorization code with PKCE (S256), no client secret. See [OAuth clients](https://docs.layout.link/authentication#oauth-clients). ### 2. Add the button Use the button as Layout ships it. Link it to your authorize URL. ``` Continue with Layout ``` On iOS and Android, use the PNGs at `@2x` and `@3x` as an image button. See [Button rules](#button-rules) and [Brand assets](#brand-assets). ### 3. Send them to Layout ``` https://api.layout.link/v1/oauth/authorize ?response_type=code &client_id=$CLIENT_ID &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback &code_challenge=$CODE_CHALLENGE &code_challenge_method=S256 &scope=order &resource=https%3A%2F%2Fapi.layout.link &state=$STATE &login_hint=%2B19165550142 ``` | Parameter | Notes | | --- | --- | | `redirect_uri` | Must exactly match one you registered on the client. | | `code_challenge` | Required. S256 only. | | `code_challenge_method` | Required. `S256`. Leaving it out is `invalid_request`. | | `scope` | `order`. | | `resource` | `https://api.layout.link` for the REST API. Add `https://mcp.layout.link` as a second `resource` to cover both in one approval (see [One authorization for both](https://docs.layout.link/authentication#one-authorization-for-both)). | | `state` | Comes back unchanged. Check it on your callback. | | `prompt` | Optional. Leave it out and a person who already approved your app is sent straight back (see [Returning people](#returning-people)). `consent` always shows the approval page. `none` never shows a page: you get a code, or an `error` on your redirect URI (see [Returning people](#returning-people)). | | `login_hint` | Optional. The person's phone number in E.164 (`+19165550142`), URL-encoded. Layout fills it in on its sign-in step when the person is not signed in yet. Only a `+1` number is filled in today; another E.164 number is accepted and left blank. It never sends a code by itself; the person still taps to get one. Anything that is not an E.164 number is ignored. | **Open it in the system browser sheet, never an embedded web view.** On iOS that is `ASWebAuthenticationSession`, on Android a Custom Tab, on the web a full-page redirect. An embedded view cannot share the person's Layout sign-in, and the person cannot tell the page is really Layout's. The person signs in with their phone and a code from Layout, and approves your app on Layout's page. It says, in Layout's words: *"{your app} will be able to place orders for you, paid with the card on file in your Layout account. You can disconnect {your app} anytime in Layout."* {your app} is the host of the redirect URI the request uses, such as `yourapp.example`, unless Layout has verified your client, when it is the client's name. Signing in does not ask for a card. Layout asks for one when the person confirms their first order, and the confirm shows the card that will be billed. ### 4. Exchange the code Layout redirects to your `redirect_uri` with `code` and `state`, or with `error` when the person cancels (`access_denied`) or the request is refused. Exchange the code from your server: ```bash curl https://api.layout.link/v1/oauth/token \ -d grant_type=authorization_code \ -d code=$CODE \ -d redirect_uri=https://yourapp.example/callback \ -d code_verifier=$CODE_VERIFIER \ -d client_id=$CLIENT_ID \ -d resource=https://api.layout.link ``` You get an access token (ten minutes) and a rotating refresh token (thirty days, renewed on every refresh). See [Tokens and refresh](https://docs.layout.link/authentication#tokens-and-refresh). ### 5. Learn who signed in ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" 200 OK { "kind": "user", "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "live", "user": { "id": "usr_4b8e9a01c2d3" }, "scopes": ["order"] } ``` `user.id` is stable: the same person gets the same id in your app every time, on every device. Key your own account on it. It is yours alone; another app knows the same person by a different id. It is not the id `POST /v1/users` gives the same person (see [Two ways in](#two-ways-in)). ### Returning people Send a returning person through the same authorize URL. If they are signed in to Layout in that browser and already approved your app for the same scope and resources, Layout sends them straight back with a code and shows nothing. Exchange it as before; `whoami` answers the same `user.id`. If they signed out of Layout everywhere, disconnected your app, or your request asks for more than they approved, they see the page again. To check silently, for instance when your app starts, send `prompt=none`. You get a code, or one of these on your redirect URI, and the person sees no page: | `error` | What to do | | --- | --- | | `login_required` | They are not signed in to Layout in this browser. Show the button. | | `consent_required` | They are signed in but have not approved your app, or not for this. Show the button. | | `temporarily_unavailable` | Layout could not check. Show the button, or retry. | | `unauthorized_client` | Your application or developer account is suspended, or, for the MCP resource, the application was deleted. Showing the button will not help until that is fixed. | | `invalid_target` | The `resource` is not one of Layout's, or this `client_id` does not belong to an application in your console. Check both. | `prompt=none` works only with an `https` redirect URI: with a `localhost` one it always answers `consent_required`. Combined with another `prompt` value it is `invalid_request`. ### Sign out When the person signs out of your app, revoke their connection with either token: ```bash curl https://api.layout.link/v1/oauth/revoke \ -d token=$REFRESH_TOKEN \ -d client_id=$CLIENT_ID 200 OK ``` This disconnects your app, the same as the person doing it in Layout, so the next sign-in shows the approval page again. If you only want to forget the session in your app and keep the connection, drop your tokens and do not revoke. See [Revoking a connection](https://docs.layout.link/authentication#revoking-a-connection). ## Your own screens To keep the person on your screens, provision them with `POST /v1/users` and verify the code Layout texts them. For a new number, a good code makes the person's Layout account, so there is no sign-up page. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone). A returning person on a new phone is verified the same way and keeps the same id. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone) for `phone_changed` and `already_claimed`. ## Button rules The button is Layout's, so a person recognises it in every app. Use it exactly as shipped. - **Black or white.** Black on light backgrounds, white on dark or photographic ones. No other colour, including Layout blue. - **Two labels.** *Continue with Layout* to sign in. *Order with Layout* where the tap starts an order. No other wording, and no translation of the word Layout. - **Four sizes.** Pills 56, 48 and 40 pixels tall, and a 48-pixel circle with the symbol alone where space is tight. Scale nothing. - **Nothing changed.** Not the colour, font, shape, corner radius or wording. No border, shadow or extra icon. ## Brand assets SVG and PNG at `@1x`, `@2x` and `@3x`, at `https://docs.layout.link/brand/sign-in-with-layout/`. The label is drawn as shapes, so it looks the same with no font installed. | File | Size | Button | | --- | --- | --- | | [continue-black-56](https://docs.layout.link/brand/sign-in-with-layout/continue-black-56.svg) | 258 × 56 | | | [continue-black-48](https://docs.layout.link/brand/sign-in-with-layout/continue-black-48.svg) | 227 × 48 | | | [continue-black-40](https://docs.layout.link/brand/sign-in-with-layout/continue-black-40.svg) | 206 × 40 | | | [continue-white-56](https://docs.layout.link/brand/sign-in-with-layout/continue-white-56.svg) | 258 × 56 | | | [continue-white-48](https://docs.layout.link/brand/sign-in-with-layout/continue-white-48.svg) | 227 × 48 | | | [continue-white-40](https://docs.layout.link/brand/sign-in-with-layout/continue-white-40.svg) | 206 × 40 | | | [order-black-56](https://docs.layout.link/brand/sign-in-with-layout/order-black-56.svg) | 231 × 56 | | | [order-black-48](https://docs.layout.link/brand/sign-in-with-layout/order-black-48.svg) | 203 × 48 | | | [order-black-40](https://docs.layout.link/brand/sign-in-with-layout/order-black-40.svg) | 184 × 40 | | | [order-white-56](https://docs.layout.link/brand/sign-in-with-layout/order-white-56.svg) | 231 × 56 | | | [order-white-48](https://docs.layout.link/brand/sign-in-with-layout/order-white-48.svg) | 203 × 48 | | | [order-white-40](https://docs.layout.link/brand/sign-in-with-layout/order-white-40.svg) | 184 × 40 | | | [circle-black-48](https://docs.layout.link/brand/sign-in-with-layout/circle-black-48.svg) | 48 × 48 | | | [circle-white-48](https://docs.layout.link/brand/sign-in-with-layout/circle-white-48.svg) | 48 × 48 | | Each name has a `.svg`, a `.png`, an `@2x.png` and an `@3x.png`, for example `continue-black-48@3x.png`. Give the image the size in the table and an `alt` of its label. --- # Environments and approval Sandbox works the minute you create an application. Production needs a human yes from us, and while you wait nothing about your sandbox changes. ## Two environments, two keys You get the sandbox key the moment you create an application. The production key is created with it but stays hidden until the application is approved; then you reveal it on the console's [Credentials](https://console.layout.link/credentials) page. The prefix picks the environment, so there is no flag to set and no way to aim a sandbox key at production by accident. | Environment | Secret prefix | What happens | | --- | --- | --- | | Sandbox | `sk_test_` | A cart at a real restaurant is built from the menu Layout holds for it, with no browser, and reaches the ending you ask for. A confirm is simulated: no restaurant receives it, nothing is charged, and the person is never texted. | | Production | `sk_live_` | Real orders, real money, real people. Requires approval. | ## Finishing an order in sandbox A sandbox user can be taken all the way to a placed order, so you can test the whole flow before you have production keys. | Step | In sandbox | | --- | --- | | Provision | The phone must be a reserved test number, `+1 500 555` followed by four digits. Nobody holds these numbers. | | Verify | Optional in sandbox. `verify/start` texts nobody, and `verify` accepts the code `000000`. | | Build | A priced cart from the restaurant's menu as Layout holds it, with its real items and choices, and no browser. `simulate` picks the ending and `latency` the wait. See [Sandbox](https://docs.layout.link/sandbox). | | Confirm | Simulated. No code unless the cart was built with `"simulate": "code"`, or at `test_place_code`; the code is `000000`. REST answers `202 placing` with `simulated: true`. No restaurant receives it and nothing is charged. | The total you confirm with must still match the cart. A stale total is refused with `price_changed` in sandbox exactly as it is in production, so a simulated order still proves the cart you built. ## Sandbox for OAuth clients An [OAuth client](https://docs.layout.link/authentication#oauth-clients) has no key to pick the environment, so the person picks it. A [sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts) you create on the console's OAuth clients page is sandbox: it connects before approval, only to that application's clients, and its confirms are simulated. Every other person, your own Layout account included, is production. ## How approval works A new application starts under review. Someone here reads it, usually within a business day, and you get an email either way. Nothing to chase, nobody to book a call with. | Status | Sandbox | Production | What it means | | --- | --- | --- | --- | | Under review | Works | Refused | The default for a new application. Build against sandbox while you wait. | | Approved | Works | Works | Production keys are live. Sandbox keeps working alongside them. | | Sandbox only | Works | Refused | We reviewed it and did not enable production. Your integration keeps running; nothing you built stops. | | Suspended | Refused | Refused | Both environments are closed. We will have contacted you. | An approved application is listed in Connectors, the directory of apps built on Layout inside the Layout app. The listing shows the application's name and logo, your company name and website, and the month it was approved, all from what you entered in the console. A person who connected your app can disconnect it from its listing. ## A refused production call A production key on an application that is not approved is **403** `unauthorized`, the same body as every refusal. Refused requests, on the Credentials page, shows the reason. See [Why refusals never say why](https://docs.layout.link/errors#why-a-refusal-never-says-why). ## Going to production 1. Build and test against `sk_test_`. Nothing about the request shape changes later. 2. Wait for the approval email, or check the application in the console. 3. Reveal the `sk_live_` secret on the console's [Credentials](https://console.layout.link/credentials) page and put it in your production config only. 4. Verify production webhooks with the production signing secret, which the console's Webhooks page shows. Sandbox and production deliveries are signed with different secrets; `X-Layout-Environment` says which one to use (see [Verify the signature](https://docs.layout.link/webhooks#verify-the-signature)). Read the secret from config and never branch your code on which one you have. The requests are the same. What answers them is not: | What | Sandbox | Production | | --- | --- | --- | | Phone numbers | Only `+1 500 555` test numbers. Nothing is texted. | Real numbers only; a test number is refused. Verifying texts the person a code. | | Test restaurants | `test_place_` ids script an outcome. | Find nothing. Every `placeId` is a real restaurant. | | Confirm | Simulated, `"simulated": true`. No code, except at `test_place_code`. | A real order, charged to the person's card. The person needs a verified number and a card, and a code when one is due. | > Keep `sk_live_` out of development, test and CI. The same code that runs a simulation with `sk_test_` places real orders and charges real cards with `sk_live_`. A webhook endpoint receives sandbox events, production events or both, and the default is both. An endpoint left on both also receives production events once the application is approved, so set a development tunnel to sandbox only, or remove it, before then. ## Deleting an application Delete application, in the danger zone of the console's Application details page, removes the application and its webhooks, and every credential stops working with it. It is a soft delete. To end only a credential, revoke it from the same zone instead. Revoking an environment, or the whole application, also ends the 24-hour overlap of a rotated secret at once. --- # Sandbox In sandbox, a cart at a real restaurant is built from that restaurant's menu as Layout holds it. No browser opens, nothing is charged, no restaurant receives anything, and the order reaches whichever ending you ask for, so you can test every path your integration handles. ## Who can use it Sandbox credentials only: your `sk_test_` key, a sandbox build grant, or a connected person's token where the person is a sandbox test user, such as a [sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts). Over MCP that means a session started from a sandbox build grant. Production never simulates. `simulate`, `latency` and `engine` on a production cart are refused with `400 invalid_request`. ## Places and menus A sandbox `GET /v1/places` search or browse lists only restaurants whose menu Layout holds with at least 3 items, and the test restaurants, so every place it returns can take a sandbox cart. When it finds matching places but holds the menu of none of them, the list is empty with `emptyBecause: "no_held_menu"`: search another kind of food or another area, or use a [test restaurant](#test-restaurants). Reading places by their `ids` is not filtered. Over MCP, the `places` tool lists the same. Any menu read Layout holds for a place counts, however old, so a place a sandbox search lists keeps taking sandbox carts until its menu is wiped. A sandbox `POST /v1/menus` answers only with the menu Layout holds, the same menu a sandbox cart there is priced from. It never reads a menu live, never starts a read, and never answers with a usual menu for the chain. `"mode": "knowledge"` answers with the same held menu, never a web answer. A restaurant with none is `409 menu_not_held`. Over MCP, a sandbox session has the `menu` tool, which answers the same way, is free, and can be called whenever you need to check what a place sells. - An item marked sold out carries `soldOut: true`. A cart that asks for it fails with `ITEM_UNAVAILABLE`. - An option with a price reads like `Oat Milk (+$0.75)`, or `No Rice (-$1.00)` when it takes money off. Send it back as written or as the name alone (`Oat Milk`): both match. - Each item lists at most 12 option groups, and each group at most 24 options. `moreOptions` counts the options left out, and a cart cannot choose them. ## How a sandbox cart is built Send `POST /v1/carts` as you will in production, with a `placeId` from a sandbox places search. Layout prices the request against that restaurant's menu: - Words match whole, plural or singular. A short form such as `"guac"` counts only on a later word of an item name with more than one word (`"chips and guac"` is Chips & Guacamole), or when it names exactly one option on that item (`"oat"` is Oat Milk). It never names a one-word item: `"lime"` is not Limeade. - An item is named by its whole name. Failing that, by the last word of its name and one more word it shares with the request. Failing that, by its last word, when only one item on the menu ends with that word: `"a coffee"` is Drip Coffee when that is the only coffee. - The quantity is a number before the item (`"2 lattes"`), or `x2`, `2x` or `Qty 2` after it. - One `items` entry that names two items is two lines. - `no`, `without`, `hold`, `remove`, `skip` or `minus` before an option says no to it. An option the menu names `No Pickles` is chosen as it is. - A group takes every option named in it, required groups too, so every topping named on a bowl lands. A group with nothing named takes its default. A required group with neither asks: the cart answers a `choices` decision naming only the groups still missing, with the restaurant's own options. - When one option is in two groups, the group named just before it decides: `"Extra Protein: Roasted Chicken"` is the extra. After `extra`, `double`, `add` or `more`, it is the group for extras. - The answer to a `choices` decision goes to every item still owing a choice, and each item takes what fits its own groups. On REST, send it with `POST /v1/carts/:id/answer`, and each answer adds to the ones before. Over MCP, build again with `choices` set to every answer given so far, joined with `"; "`. - An item the menu does not list, or lists as sold out, fails the cart with `ITEM_UNAVAILABLE`, as a real build does. Read the menu for that `placeId` and build again with names it lists. - An item the menu shows no price for is $5.00. Tax is 8.75%. The service fee is the same as production, and `totalMinor` includes both. Each line's `priceMinor` is the price of one of that item with its options. A restaurant whose menu Layout does not hold is refused with `409 menu_not_held` before anything is built or counted. So is a sandbox cart that names no place: a cart sent with `restaurantName` and `near` is built only when Layout resolves them to a `placeId` whose menu it holds. When Layout cannot check for a held menu, the answer is `503 unavailable`: retry with the same `idempotencyKey`. The cart, its confirm, the order, the event feed and your webhooks behave as in production. Every order and every event carries `"simulated": true`, and `card` is `null` on every sandbox cart, whatever card the person reads as holding (see [Card links](https://docs.layout.link/card-links)). The confirm needs no code unless the cart was built with `"simulate": "code"`. ## Choose the ending Send `simulate` on `POST /v1/carts`, or on the MCP `order` tool's `build`. Without it the order is placed. | `simulate` | What happens | Events, in order | | --- | --- | --- | | `placed` | The confirm answers `placing`, and the order is placed. | `order.building`, `order.carted`, `order.placing`, `order.placed` | | `failed` | The confirm answers `placing`, and the order fails with `CARD_DECLINED`. | `order.building`, `order.carted`, `order.placing`, `order.failed` | | `unconfirmed` | The confirm answers `placing`, and the order ends `unconfirmed`. Handle it as in production: never build it again, and never tell the person it failed. | `order.building`, `order.carted`, `order.placing`, `order.unconfirmed` | | `challenge` | The confirm answers `placing` and sends `order.challenge`. The challenge clears by itself and the order is placed. While it waits, `GET /v1/orders/:id` carries `challenge`, with `sms_sent: false`. | `order.building`, `order.carted`, `order.placing`, `order.challenge`, `order.placed` | | `code` | The cart reads `codeRequired: true` (`codeReason: "risk_check"`), and the confirm answers `400 code_required` until it carries `"code": "000000"`. Nothing is texted. Then the order is placed. | `order.building`, `order.carted`, then `order.placing` and `order.placed` once the confirm carries the code | | `card_declined` | The confirm is refused at once with `402 card_declined`, and the order ends `failed` with `CARD_DECLINED`. Nothing is placed. | `order.building`, `order.carted`, `order.placing`, `order.failed` | | `price_changed` | At the first confirm the total goes up $1.00, item and tax together, so that confirm is refused with `409 price_changed`. Build a fresh cart with the same request and a new `idempotencyKey`: it carries the new total. Show the person the new total and confirm the fresh cart with it. | `order.building`, `order.carted`, then for the fresh cart `order.building`, `order.carted`, `order.placing`, `order.placed` | | `closed` | The cart fails with `STORE_CLOSED`. | `order.building`, `order.failed` | | `pickup_later` | The order is placed with no pickup details, as a real order often is. The name (`Sandbox`), the order number (`SIMULATED`) and the instructions arrive with `order.updated`, then the pickup time (`Ready in about 15 minutes`) with a second `order.updated`, whose `event_id` ends `_updated_2`. | `order.building`, `order.carted`, `order.placing`, `order.placed`, `order.updated`, `order.updated` | A question writes no order and sends nothing. A ready cart nobody confirms expires as it does in production. A second cart at a restaurant while the person's order there is confirmed and placing fails with `order_in_progress`. ## Timing `latency` sets how long each step waits. | Step | `realistic` (the default) | `instant` | | --- | --- | --- | | Building the cart | 30 seconds for one item and 10 more for each item after it, within 15% either way, at most 90 | None | | Confirm to `placed` or `failed`, and a confirm with the code | 20 seconds | None | | Confirm to `unconfirmed`, or a challenge clearing | 30 seconds | None | | Placement to the `pickup_later` name, and to its pickup time | 15 seconds, and 30 seconds | None | While a sandbox cart builds, `GET /v1/carts/:id` carries `build` with its stage and `etaSec` counting down this wait. With `instant`, the next read of the cart or the order has moved on. Reading the order, with `GET /v1/orders/:id` or `order_status` over MCP, moves it on the moment it is due; otherwise it moves on within a few seconds, and your webhooks follow. Use `realistic` to test your polling and `instant` in CI. ## The real engine Send `"engine": "real"` to build on the restaurant's own site, as production does. It opens a browser, takes minutes, and its confirm is still simulated. It works at any restaurant Layout supports, held menu or not. `simulate` and `latency` do not apply to it. ## Limits | Builds | A day, per developer account | `GET /v1/usage` | | --- | --- | --- | | Sandbox carts with no browser, at real restaurants and test restaurants | 10,000 | `simulated_builds` | | Sandbox carts with `"engine": "real"` | 10 | `builds` | Both count across all your applications, reset at midnight UTC, and are refused with `429 build_limit`, naming the number. To raise either, open [Usage](https://console.layout.link/usage) in the console and choose **Request a higher limit**, or email [developer@layout.link](mailto:developer@layout.link). ## Check your integration [layout check](https://docs.layout.link/cli#check-your-integration) grades your integration from its last 24 hours of sandbox: a cart at a real restaurant, an answered question, a confirm with `totalMinor`, an order your application confirmed that reached each of `placed`, `failed` and `unconfirmed`, a confirm with a code, and a fresh cart confirmed after `price_changed`. With `--forward-to` it also sends your webhook handler four test events. Each check that does not pass says what to do. Run it until every check passes. ## Test restaurants Test restaurants have a fixed menu, so a total is the same on every run, and each one scripts one ending. They ignore `simulate` and `latency`. Use them when a test needs exact numbers. Name one by its `placeId`, which starts `test_place_`. A production credential that names one gets the answer it would get for any place that does not exist. A sandbox search that names them, such as `layout test`, lists every one of them. Add a word to narrow it: `layout test slow` finds `test_place_slow`. Search never lists `test_place_closed`; read it by its id. `GET /v1/places/test_place_carted` reads one, and `POST /v1/menus` with a test `placeId` returns the test menu. Over MCP, build with the `placeId`, a `query` naming the food, `restaurant` set to the store name in the table below, `near` (any place, such as `San Francisco`) and an `idempotencyKey`. ### The test restaurants | placeId | Store | What it does | | --- | --- | --- | | `test_place_carted` | Layout Test Kitchen | Builds a ready cart priced from the test menu. Its confirm places the order at once, simulated: the order reads `placed` straight away. Over REST the confirm itself still answers `placing`. | | `test_place_needs_choices` | Layout Test Kitchen Choices | The cart asks a `choices` decision: Size (required: Small, Medium, Large) and Extras (optional: Oat milk, Extra shot, Vanilla). It asks again until the answer names a size, then builds a ready cart for a latte. | | `test_place_needs_location` | Layout Test Kitchen Two Branches | Matches two branches. `POST /v1/carts` answers `store_ambiguous` with both as `candidates`; over MCP the preview and build ask which branch. Build with the branch's `placeId` and its own name. | | `test_place_branch_north`, `test_place_branch_south` | Layout Test Kitchen North, Layout Test Kitchen South | The two branches. Each builds a ready cart like `test_place_carted`. | | `test_place_closed` | Layout Test Kitchen Closed | Is closed. The cart fails with failure code `STORE_CLOSED`. | | `test_place_price_changed` | Layout Test Kitchen Price Change | Builds a ready cart. At the first confirm its total goes up $1.00, item and tax together, so that confirm is refused with `price_changed`. Build a fresh cart with the same request and a new `idempotencyKey`: it carries the new total. Confirm it with that total. | | `test_place_placed` | Layout Test Kitchen Placing | Builds a ready cart. Its confirm answers `placing`, and the order is placed about 5 seconds later. | | `test_place_failed` | Layout Test Kitchen Declined | Builds a ready cart. Its confirm answers `placing`, and the order fails about 5 seconds later with failure code `CARD_DECLINED`. | | `test_place_unconfirmed` | Layout Test Kitchen Unconfirmed | Builds a ready cart. Its confirm answers `placing`, and about 5 seconds later the order ends `unconfirmed`. Handle it as you would in production: never build it again, and never tell the person it failed. Over MCP, `order_status` reports `status: "unconfirmed"` with `outcome: "unconfirmed"`, `failureCode: "UNCONFIRMED"` and an instruction not to retry it. Treat it as [An unconfirmed placement](https://docs.layout.link/mcp#an-unconfirmed-placement) says. | | `test_place_challenge` | Layout Test Kitchen Challenge | Builds a ready cart. Its confirm answers `placing` and sends `order.challenge`. About 15 seconds later the challenge clears by itself and the order is placed. While it waits, `GET /v1/orders/:id` carries `challenge`, with `sms_sent: false`. | | `test_place_code` | Layout Test Kitchen Code | The confirm answers `400 code_required` (`codeReason: "risk_check"`) until it carries `"code": "000000"`. Nothing is texted. Code limits apply as in production. With the code, the order is placed about 5 seconds later. | | `test_place_pickup_later` | Layout Test Kitchen Pickup Later | Builds a ready cart. Its confirm answers `placing`, and about 5 seconds later the order is placed with no pickup details, as a real order often is: `ready_eta` and every `fulfillment` detail but `type` and `address` are null. About 5 seconds after placement the name (`Sandbox`), the order number (`SIMULATED`) and the instructions arrive with `order.updated`. About 10 seconds after placement the pickup time (`Ready in about 15 minutes`) arrives with a second `order.updated`, whose `event_id` ends `_updated_2`. Use it to test a late [pickup time](https://docs.layout.link/api#pickup-time) and [more than one order.updated](https://docs.layout.link/events#more-than-one-order-updated). | | `test_place_delivery_far` | Layout Test Kitchen Far | Behaves like `test_place_placed`. | | `test_place_card_declined_confirm` | Layout Test Kitchen Card Declined | Builds a ready cart. Its confirm is refused at once with `402 card_declined`, as a real card refused at confirm is, and the order ends `failed` with failure code `CARD_DECLINED`. Nothing is placed. | | `test_place_slow` | Layout Test Kitchen Slow | The cart stays `building` for about 20 seconds, then becomes ready. | Every store is at a numbered address on Sandbox Way, San Francisco, CA 94107. Any other id that starts `test_place_` is reserved and finds nothing. Over MCP, a name or `test_place_` id that no test restaurant carries answers `store_not_found`, with the test restaurants as candidates. A test restaurant ignores `simulate` and `latency`: it scripts its own ending and timing. ### The test menu Every test restaurant shares one menu, so a total is the same every time you build it. | Item | Price | Options | | --- | --- | --- | | Drip coffee | $3.50 | None | | Latte | $5.25 | Size: Small, Medium (+$0.50), Large (+$1.00). Extras: Oat milk (+$0.75), Extra shot (+$1.00), Vanilla (+$0.50). | | Blueberry muffin | $3.75 | None | | Avocado toast | $9.00 | None | | Breakfast burrito | $10.95 | None | - Each `items` entry is one line, and a leading number is its quantity (`"2 lattes"`). An item matches by its whole name or its last word: `"a muffin"` is the Blueberry muffin. - Options on the menu are priced. Anything else you say about an item is kept on its line at no charge. A latte with no size is Small, except at `test_place_needs_choices`, which asks. - Unlisted items are $5.00, under the name you sent. - Tax is 8.75%, and the service fee is the same as production. `totalMinor` includes both. ### Test restaurant events | placeId | Events, in order | | --- | --- | | `test_place_carted` | `order.building`, `order.carted`, then `order.placed` at confirm | | `test_place_closed` | `order.building`, `order.failed` | | `test_place_placed` | `order.building`, `order.carted`, `order.placing`, `order.placed` | | `test_place_failed` | `order.building`, `order.carted`, `order.placing`, `order.failed` | | `test_place_unconfirmed` | `order.building`, `order.carted`, `order.placing`, `order.unconfirmed` | | `test_place_challenge` | `order.building`, `order.carted`, `order.placing`, `order.challenge`, `order.placed` | | `test_place_code` | `order.building`, `order.carted`, then `order.placing` and `order.placed` once the confirm carries the code | | `test_place_pickup_later` | `order.building`, `order.carted`, `order.placing`, `order.placed`, `order.updated`, `order.updated` | The times in the table above are approximate, and move on the same way as a sandbox cart's. Like every `order.updated`, the pickup details reach only an endpoint that subscribes to `order.updated`. ## A whole order in CI Give every run its own idempotency keys, for example from your CI run id. A key reused from an earlier run replays that run's cart and confirm instead of building a new one. Send `"latency": "instant"` so nothing waits. ```bash curl https://api.layout.link/v1/carts \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "placeId": "'"$PLACE_ID"'", "items": ["harvest bowl"], "simulate": "unconfirmed", "latency": "instant", "idempotencyKey": "ci-'"$RUN_ID"'-cart" }' ``` ``` 202 Accepted { "cart": { "id": "crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe", "status": "building" } } ``` Read the cart until it is `ready`, and confirm it with its `totalMinor`. The total depends on the restaurant's menu, so read it from the cart, never write it into the test. Then read the order or wait for `order.unconfirmed`. ``` TOTAL=$(curl -s https://api.layout.link/v1/carts/crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" | jq -r '.cart.totalMinor') ``` ```bash curl https://api.layout.link/v1/carts/crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe/confirm \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "expectedTotalMinor": '"$TOTAL"', "idempotencyKey": "ci-'"$RUN_ID"'-confirm" }' ``` ``` 202 Accepted { "order": { "id": "ord_7c21", "status": "placing", "simulated": true } } ``` ## Fire a test webhook `POST /v1/sandbox/webhooks/fire` sends one signed test event to every enabled endpoint of your application that subscribes to it, so you can test a handler without building anything. It goes through the real delivery pipeline: the sandbox signing secret, the same headers, the delivery log and the retries. Call it with your `sk_test_` key. ```bash curl https://api.layout.link/v1/sandbox/webhooks/fire \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "event": "order.unconfirmed", "orderId": "ord_7c21" }' ``` ``` 200 OK { "event": "order.unconfirmed", "order": { "id": "ord_7c21", "state": "unconfirmed", "store": "Layout Test Kitchen Unconfirmed" }, "deliveries": [ { "id": "dl_9f3a2c", "url": "https://example.com/layout/webhooks", "outcome": "delivered" } ] } ``` - `event` is any event type except `order.needs_approval`, which is reserved and never sent. - `orderId` is optional. With one of your sandbox orders, the event carries that order's real id, store, item count, total and person. Without it, the event describes a test order whose ids match nothing: `ord_test_000000000000` at Layout test kitchen. `order` in the response shows which order the event described and the state it carried, as a real delivery of that event would: the state the event names, `placed` for `order.updated`, and for `order.challenge` the order's state at the time (`placing` for the test order). A fired `order.updated` carries `ready_eta` and `fulfillment`: the order's own with `orderId`, placeholders otherwise. - Every fired event carries `"test": true`, like the console's **Send test event**. - `outcome` is `delivered` when your endpoint answered 2xx, `retry` when it did not and the ordinary retry schedule takes over, and `dead` when retries are exhausted. - A `sk_live_` key is refused with 403 `forbidden`. No endpoint subscribed to the event, or an `orderId` that is not one of your sandbox orders, is a 404 `not_found` that says which. - While [layout listen](https://docs.layout.link/cli#forward-events-with-listen) runs for your application, an event no endpoint subscribes to is not a 404: it answers 200 with empty `deliveries`, and the event goes to `listen` and nowhere else. - 1,000 a day for each developer account and 30 a minute for each key, refused with 429 `rate_limited`. --- # Authentication Every request carries one bearer token, and there are four kinds: your application secret, a build grant, a person's OAuth access token and a CLI token. Which one you send decides who the call acts for. What each one opens is in [The credentials](https://docs.layout.link/how-it-works#the-credentials). ## Authorize every request Send your secret as a bearer token. We read the `Authorization` header and nothing else, so a secret in a query string or a cookie gets ignored rather than quietly working. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' ``` ## What the console gives you | Credential | Shape | Notes | | --- | --- | --- | | Client id | `lp_test_…` / `lp_live_…` | Identifies the application. Not a secret, but not useful on its own. | | Client secret | `sk_test_…` / `sk_live_…` | The credential. Reveal it again on the Credentials page. A production secret can be revealed or rotated only once Layout approves the application, and asks you to sign in again first if your last sign-in was more than fifteen minutes ago. Reveals have an hourly and a daily limit. | | Webhook signing secret | On the Webhooks page | Different secret, different job. See [Webhooks](https://docs.layout.link/webhooks). | The prefix picks the environment. An `sk_test_` secret reaches sandbox and only sandbox, no matter what the rest of the request claims. > Keep the secret server-side. It provisions users and mints build grants, so anything holding it can act as your application. Never ship it in a browser bundle or an app binary. ## Rotating a secret Rotate on the application's Credentials page. The client id stays the same and the old secret keeps working for 24 hours, so you can deploy the new secret to your fleet without a gap. The page shows when the old secret stops. Tick **Revoke the previous secret now** when the old one leaked: it then stops working the moment the new one exists. Revoking an environment, or the whole application, also ends any overlap at once. `layout keys create --replace` does the same for a sandbox key and prints when the old secret stops. Over the console API, `POST /v1/console/apps/:appId/credentials/rotate` takes an optional `revokePrevious: true` and answers `clientId` (unchanged), `secret` and `previousSecretExpiresAt`, which is `null` when the old secret was revoked. Anything but a boolean is `400 invalid_input`. Once the overlap ends, a rotated secret gets **401**, with the same body as every other refusal, so a rotation that outran your deploy looks exactly like a bad key. Refused requests, on the [Credentials](https://console.layout.link/credentials) page, is how you tell them apart: a stale key shows up as *The key was rotated or revoked*, with a count of how often it was sent. ## OAuth clients When people sign in with their own Layout account, from an assistant or an app of yours, create an OAuth client on the console's [OAuth clients](https://console.layout.link/oauth-clients) page with your redirect URIs. You get a `client_id` that starts `mcp_`. It is a public client: authorization code with PKCE (S256), no client secret. - **Redirect URIs** are absolute `https` URLs, or `http` on `localhost`, `127.0.0.1` or `[::1]`. Up to 10 for each client. The authorize request must match one exactly. - **Up to 10 clients** for each application. - **The consent screen names the host of the redirect URI** the request uses, such as `yourapp.example`, not the client's name, unless Layout has verified the client. | What | Value | | --- | --- | | Discovery | `https://api.layout.link/.well-known/oauth-authorization-server` | | Authorize | `https://api.layout.link/v1/oauth/authorize` | | Token | `https://api.layout.link/v1/oauth/token` | | Revoke | `https://api.layout.link/v1/oauth/revoke` (see [Revoking a connection](#revoking-a-connection)) | | Resource | `https://mcp.layout.link` for MCP, `https://api.layout.link` for the REST API, or both in one authorization (see [One authorization for both](#one-authorization-for-both)) | | Resource metadata | `https://api.layout.link/.well-known/oauth-protected-resource` for the REST API, `https://mcp.layout.link/.well-known/oauth-protected-resource` for MCP | | Scope | `order` | To put this behind a button in your app, see [Sign in with Layout](https://docs.layout.link/sign-in-with-layout), which also covers returning people (`prompt`) and prefilling the phone number (`login_hint`). The person approves your app on Layout's consent screen. Orders they place through the client reach your [webhooks](https://docs.layout.link/webhooks) while they stay connected. `GET /v1/orders/:id` and the console's Orders page keep showing those orders after they disconnect. The environment follows the person, not the client. An order is sandbox only when the person is a [sign-in test account](#sign-in-test-accounts), which you create on the same console page. The test accounts on the console's Sandbox test accounts page are for users you provision and cannot sign in through OAuth. Every other person is production, and production starts once Layout approves your application. > **Your own Layout account is a production account.** Until Layout approves your application, it cannot connect to your client: approving sends you back with `error=unauthorized_client` and no code. Once the application is approved, confirming with it charges your card and sends a real order to the restaurant. Test with a [sign-in test account](#sign-in-test-accounts). A client you register yourself through dynamic registration still connects, but it belongs to no application, so its orders never reach you. There is no way to claim one: create the client in the console. Deleting a client closes it to new connections and ends every connection it holds: refresh tokens stop working, and an access token already issued is refused on its next request, on the API and on MCP. Every order the client placed stays readable. ## Return URLs A [card link](https://docs.layout.link/card-links) sends the person back to your app only through a return URL you registered. Add one under **Return URLs** on the console's [App details](https://console.layout.link/app-details) page with **Add return URL**. They are not OAuth redirect URIs, and an OAuth client's redirect URIs do not count as return URLs. - `https` with a host, your app's own custom scheme such as `com.acme.assistant:/layout/card`, or `http` on `localhost` for sandbox only. - Up to 10 for each application, shared by your sandbox and production keys. A link must name one exactly. See [Return URLs](https://docs.layout.link/card-links#return-urls) on the card links page for what comes back and how to check it. ## Sign-in test accounts To test an OAuth client end to end with no real order, choose **New test account** under Sign-in test accounts on the console's [OAuth clients](https://console.layout.link/oauth-clients) page. Layout gives the account a reserved phone number and a six-digit sign-in code. | What | Value | | --- | --- | | Phone number | Picked by Layout from `+1 500 555 2000` to `+1 500 555 9999`. Nobody holds these numbers, and nothing is ever texted to one. | | Sign-in code | Six digits, shown once, when you create the account. Layout keeps only a hash. If you lose it, delete the account and create another. `000000` does not sign one in. | | Environment | Sandbox, for the life of the account. Every confirm is simulated and carries `"simulated": true`: no restaurant receives it, no card is used, nothing is charged. Over REST the confirm answers `"status": "placing"`, as every REST confirm does; read the order for the outcome. | | Connects to | Only the OAuth clients of the application that created it. Any other client, including an assistant no application owns and your other applications' clients, is refused: `invalid_target` when the authorization includes the REST API, `unauthorized_client` for MCP alone. Other developers cannot see, use or delete it. It can also approve a sandbox [connect link](https://docs.layout.link/provisioning#connecting-an-existing-account) from any application in your developer account, for a user provisioned with its number. | | Approval | Not needed. A sandbox person connects to an application that is still under review. | | Limits | 10 at a time for each developer account, across its applications, and 30 created a day. | | Deleting | It can no longer sign in, and every connection it holds ends at once. Orders it placed stay readable. The number may later go to a different account. | To sign in, start the authorization as usual. On Layout's consent screen, sign in with the account's phone number, enter its code where Layout asks for the texted code, and approve. Its orders reach your webhooks with your sandbox signing secret. ## CLI sessions The [Layout CLI](https://docs.layout.link/cli) signs in with a code a person approves on the console's [CLI](https://console.layout.link/cli) page, and gets an `lcli_` token for that developer account. It works in sandbox. With `layout login --live` it can also read an approved application's production for 12 hours, and never changes it. A live read for an application that is not approved is refused with `403 not_approved` (see [Production](https://docs.layout.link/cli#production)). | A CLI token can | A CLI token cannot | | --- | --- | | Read the account, its applications, usage and recent sandbox deliveries | Create, reveal or rotate a live key | | Create applications | Read a live signing secret | | Create, read and revoke sandbox keys | Add, change or delete a webhook endpoint, or change any production setting | | List webhook endpoints and read the sandbox signing secret | Approve anything | | Fire sandbox test events and read the sandbox event feed | Place a live order or move money | | Ask for production review | Use any API route outside `/v1/cli`. Every other route refuses a CLI token, and `/v1/cli` refuses a client secret or a build grant. | | With production access, for 12 hours, on an approved application: read live events, deliveries, key details (client id and last four) and endpoints | Write anything in production | Layout stores the token hashed. A session from `layout login` lasts 30 days; a token made on the console's CLI page lasts 30 days, 90 days or a year (see [CI tokens](https://docs.layout.link/cli#ci-tokens)). Every action it takes is recorded in your account's audit trail like a console action. Revoke a session from the console's CLI page or with `layout logout`. See [CLI security](https://docs.layout.link/cli#security). ## Calling as a person Two credentials act for one person: a build grant, for a person you provisioned, and an OAuth access token, for a person who approved your application. Places, menus, images and carts take one of them, never your secret: your secret there is `403 wrong_credential`. `GET /v1/whoami` takes any of the three (see [Who a token acts for](#check-who-a-token-acts-for)). Neither person credential can add a card. See [The credentials](https://docs.layout.link/how-it-works#the-credentials). ### Get an API token Run the same authorization code flow with PKCE as for MCP, with `resource=https://api.layout.link` on the authorize request. The `resource` parameter is required. Only an OAuth client your application created in the console can ask for this resource; a client registered through dynamic registration is refused with `invalid_target`, and a suspended application with `unauthorized_client`. ``` https://api.layout.link/v1/oauth/authorize ?response_type=code &client_id=$CLIENT_ID &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback &code_challenge=$CODE_CHALLENGE &code_challenge_method=S256 &scope=order &resource=https%3A%2F%2Fapi.layout.link &state=$STATE ``` An access token is for one resource. A token for `https://mcp.layout.link` is refused by the API, and a token for the API is refused by MCP. Refreshing keeps the resources the person approved: asking a refresh token for one it was not granted is `invalid_target`. See [Tokens and refresh](#tokens-and-refresh) for lifetimes. The environment follows the person. A Layout sandbox account is sandbox; anyone else is production, and production needs your application approved. A production person who approves an application that is not approved yet is sent back to your redirect URI with `error=unauthorized_client` and no code. ### One authorization for both A server that calls the REST API and drives MCP for the same person can ask for both in one authorization, so the person sees one consent screen. Send `resource` twice on the authorize request: ``` https://api.layout.link/v1/oauth/authorize ?response_type=code &client_id=$CLIENT_ID &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback &code_challenge=$CODE_CHALLENGE &code_challenge_method=S256 &scope=order &resource=https%3A%2F%2Fapi.layout.link &resource=https%3A%2F%2Fmcp.layout.link &state=$STATE ``` The same rules apply as for the API alone: only an OAuth client your application created in the console can ask, and a production person needs your application approved. One refresh token covers both resources. Every access token is still for exactly one, so every token request names the one you want with a single `resource`: ```bash curl https://api.layout.link/v1/oauth/token \ -d grant_type=authorization_code \ -d code=$CODE \ -d redirect_uri=https://yourapp.example/callback \ -d code_verifier=$CODE_VERIFIER \ -d client_id=$CLIENT_ID \ -d resource=https://api.layout.link ``` For the other resource, refresh with the refresh token that came back and `resource=https://mcp.layout.link`. There is one refresh token for the person, never one per resource, and the rules in [Tokens and refresh](#tokens-and-refresh) apply to it whichever resource you ask for. A token request for a two-resource grant with no `resource`, or with two different ones, is `invalid_target`; an authorization code refused that way is not spent, so send it again with a `resource`. ### Who a token acts for ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" ``` ``` 200 OK { "kind": "user", "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "live", "user": { "id": "usr_4b8e9a01c2d3" }, "scopes": ["order"] } ``` With a build grant, `kind` is `"grant"`, `scopes` is `["build", "confirm"]` (it confirms with the person's code whenever Layout asks for one), and the response adds the grant's `expiresAt`. A person's OAuth token answers `kind: "user"` with the scopes its authorization granted, which today is always `["order"]`. `user.id` is the id this application knows the person by, the same one its webhooks carry. A person who signed in through OAuth and a person you provisioned get ids from different series: if the same person does both, your application knows them by two different ids. With your secret, `whoami` describes your application instead: `kind` is `"app"`, with `app.id`, `environment`, `approvalStatus` and `scopes`, and no `user`. That `scopes` is the same fixed list for every application, `["order:preview", "order:build", "order:confirm", "order:status", "places"]`. It is not a scope any token carries, and it does not list everything a build grant can do, such as reading menus: see [What each door can do](https://docs.layout.link/how-it-works#what-each-door-can-do). ### Tokens and refresh | What | Lifetime | | --- | --- | | Authorization code | Ten minutes, and it works once. | | Access token | Ten minutes. It also stops at once when the person disconnects your app, you revoke it, or you delete the client. | | Refresh token | Thirty days from when it was issued. | Every refresh rotates. The refresh token you send stops working and the response carries a new one, with a fresh thirty days, so a connection you refresh at least once a month never expires. Store the new refresh token before you use the new access token. ```bash curl https://api.layout.link/v1/oauth/token \ -d grant_type=refresh_token \ -d refresh_token=$REFRESH_TOKEN \ -d client_id=$CLIENT_ID \ -d resource=https://api.layout.link ``` `client_id` is required. `resource` must be a resource the grant covers: required when it covers both, otherwise optional. Every token response carries `access_token`, `token_type` (`Bearer`), `expires_in` (`600`), `refresh_token` and `scope`. Keep only the newest refresh token, and send one refresh at a time per person. A retry within 20 seconds, after a lost response, gets a new pair. Reusing an old refresh token more than 20 seconds after it was replaced ends the connection, and the person approves again. A revoked refresh token, or a connection that no longer holds, is `invalid_grant`. `503` `temporarily_unavailable` means Layout could not check, and the refresh token still works. ### Revoking a connection When a person signs out of your product or deletes their account with you, revoke their connection (RFC 7009). Send either their refresh token or an access token, with your `client_id`: ```bash curl https://api.layout.link/v1/oauth/revoke \ -d token=$REFRESH_TOKEN \ -d token_type_hint=refresh_token \ -d client_id=$CLIENT_ID 200 OK ``` Either token ends the whole connection for that person: every refresh token your client holds for them stops working, and every access token is refused on its next request, on the API and on MCP. It is the same as the person disconnecting your app in Layout. `token_type_hint` is optional. An access token still revokes after it has expired, for up to thirty days, so revoking at sign-out with the access token you have cached works; the refresh token works too. `200` means revoked, or nothing to revoke. Retry `429` (after `Retry-After`) and `503` until you get a `200`. Anything else is a request error: a `400` names it. Errors from the authorize, token and revoke endpoints use the OAuth shape, `{ "error": "…", "error_description": "…" }`, not the API's error envelope. ### Refusals | Status | Code | What to do | | --- | --- | --- | | `401` | `unauthorized` | The token is not recognised, expired, for another resource, or its connection ended (a disconnect, a sign-out everywhere, a deleted account or a deleted OAuth client). Refresh, or send the person through authorization again. A revoked build grant, or one whose application is suspended, is this too: mint a new grant once the application can act again. `WWW-Authenticate` points at `https://api.layout.link/.well-known/oauth-protected-resource`. | | `401` | `grant_expired` | The build grant expired. Mint a new one. | | `403` | `unauthorized` | The token is real, but your application may not use it right now: it or your organization is suspended, or this is a production person and the application is not approved yet. | | `403` | `wrong_credential` | A real credential of the wrong kind, such as your secret on a person route, or a build grant on a route only a person's token can call. The message names what to send. | - **429** `rate_limited`: more than 3,000 requests a minute with one credential of an approved application in production, or 600 in sandbox or before approval. A credential Layout does not recognise counts against the calling address instead, at 300 a minute. A credential Layout issued and refused counts against itself, at 600 a minute. Back off and retry. See [Requests per credential](https://docs.layout.link/limits#requests-per-credential). - **503** `unavailable`: we could not check the credential. Nothing about it is wrong. Retry shortly. ### MCP refusals MCP refuses with the same envelope: **401** `unauthorized` with `WWW-Authenticate` pointing at the resource metadata, and **401** `grant_expired` for an expired build grant. **503** `unavailable` means retry. A secret key is never an MCP credential. Tool results inside the protocol keep their own shape. ``` 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://mcp.layout.link/.well-known/oauth-protected-resource", scope="order" { "error": { "code": "unauthorized", "message": "Missing, invalid or disconnected access token. Authorize with Layout again." } } ``` ## When a call is refused A refused secret is **401** when it was not recognised or was rotated, and **403** when it is real but the application may not do that, almost always production before approval. A 401 does not say which; a 403 carries `error.reason` (`live_not_approved`, `app_suspended` or `partner_suspended`), and Refused requests, on the [Credentials](https://console.layout.link/credentials) page, lists both. Every code is on [Errors](https://docs.layout.link/errors). --- # Layout CLI The Layout CLI signs in to your developer account. In sandbox it does the whole build: create an application and a key, call any endpoint, forward webhook events to your machine, fire test events, and run a whole test order. In production it reads: live events, deliveries and key details. It never changes an approved application's production settings, and never moves money. Every command takes `--json`, so a coding agent can drive it. ## Install Install it from npm. It needs Node 20 or later, and the command is `layout`. ``` npm i -g @layoutplatforms/cli ``` Or run it once without installing: `npx @layoutplatforms/cli login`. ## Sign in ``` layout login ``` It prints a code like `BCDF-GHJK` and opens the console. Check that the code matches your terminal, then choose **Approve**. The code lasts 10 minutes. The session lasts 30 days, and `layout logout` ends it. The console's [CLI](https://console.layout.link/cli) page lists every signed-in CLI, each with **Revoke**. | Flag | What it does | | --- | --- | | `--device-name ` | The name the console shows for this computer. The default is its host name. | | `--no-browser` | Prints the link without opening it. With `CI` set, `login` never opens a browser. | | `--live` | Also asks to read production for 12 hours. See [Production](#production). | The token is kept in the macOS Keychain or the Linux Secret Service (`secret-tool`). With neither, it goes in `~/.config/layout/credentials.json` with mode 0600, and the CLI warns you. `LAYOUT_CREDENTIAL_STORE` (`keychain`, `secret-tool` or `file`) picks the store. ### CI tokens For CI, a container or a coding agent, create a token on the console's [CLI](https://console.layout.link/cli) page (**New token**) and set it as `LAYOUT_CLI_TOKEN`. Every command then runs without `layout login`. ```bash export LAYOUT_CLI_TOKEN=lcli_... layout init --name "My app" ``` - Creating one needs a console sign-in from the last 15 minutes. It is shown once, and Layout keeps only a hash of it. - It lasts 30 days, 90 days (the default) or a year, and the CLI page lists it with **Revoke**. Signing out everywhere revokes it too. - It is sandbox only. Production reads still need `layout login --live`. - When it is set it wins over a keychain session. It is only ever sent to `https://api.layout.link`, and the CLI never writes it to disk. `LAYOUT_API_BASE` points the CLI at another API. The default is `https://api.layout.link`. A token is only ever sent to the API that issued it. ## Commands | Command | What it does | | --- | --- | | `layout init [--name \| --app ] [--skip-test] [--place ]` | Set up in one step: pick or create the application, keep its sandbox key, run a test order, and print the `LAYOUT_APP` and `LAYOUT_SECRET_KEY` to save. `--place` takes a `test_place_` id, or a `placeId` from a sandbox places search. Safe to run again. | | `layout login [--live]` | Sign in. `--live` adds 12 hours of production reads. | | `layout logout` | Revoke this CLI session and forget it locally. | | `layout whoami` | The developer account, the CLI session, and whether it can read production. | | `layout apps list` | Your applications. | | `layout apps create ` | A new application. Prints its sandbox key once. | | `layout keys list [--live]` | The application's key: client id and last four. | | `layout keys create [--replace]` | A new sandbox key, printed once. `--replace` retires the one in place: the client id stays the same and the old secret works for another 24 hours. It prints `New sandbox key, same client id. The old secret works until UTC.` From CLI 0.4.3. | | `layout keys revoke --yes` | Revoke the sandbox key and its sandbox build grants. | | `layout webhooks list [--show-secret] [--live]` | Endpoints, in columns ID, STATE, RECEIVES, VERIFICATION, LAST ERROR, URL and EVENTS, and the sandbox signing secret. From CLI 0.4.2 the secret is masked unless you add `--show-secret`. | | `layout webhooks add [--events ] [--name ] [--environment sandbox\|live\|both]` | Add an endpoint, for every event unless you name some. `--environment` says which environment's events it receives, and defaults to `both`. From CLI 0.4.2; `--environment` from CLI 0.4.3. | | `layout webhooks verify [--live]` | Send an endpoint the verification event now. Exit 0 when it answers with a 2xx. Otherwise exit 1 with `endpoint_not_verified` and the `subscriptionId`. Works on an approved application too. From CLI 0.4.3. | | `layout webhooks remove --yes` | Remove an endpoint. From CLI 0.4.2. | | `layout listen --forward-to [--secret ] [--events ] [--from-start] [--live]` | Forward events to a local URL. See [Forward events with listen](#forward-events-with-listen). | | `layout trigger [--order ]` | Fire a signed test event. See [Fire a test event](#fire-a-test-event). | | `layout refusals [--live]` | Why the application's keys were refused recently: when, the reason, how often, the client id and what it means. The same list as Refused requests in the console. From CLI 0.4.3. | | `layout usage` | Today's sandbox use. From CLI 0.4.2 it lists every allowance `GET /v1/usage` lists, and whether each counts for the application or your developer account. | | `layout deliveries [--limit ] [--live]` | Recent webhook deliveries. `--limit` is 1 to 100, and 20 by default. | | `layout api ` | Call any REST endpoint. See [Call the API](#call-the-api). | | `layout test-order [--place ] [--simulate ] [--latency ]` | Run a whole sandbox order. See [Run a test order](#run-a-test-order). | | `layout check [--forward-to [--secret ] \| --no-webhooks] [--since <30m\|2h>]` | Grade your integration from its last day of sandbox, and test your webhook handler. See [Check your integration](#check-your-integration). | | `layout request-live-access` | Print where to request production. Production is requested from the console, where a person answers a few questions about the app, and a person at Layout approves it. | | `layout docs [topic]` | Open these docs. Topics: `cli`, `quickstart`, `sandbox`, `webhooks`, `events`, `api`. | Destructive commands do nothing without `--yes`. `webhooks add` and `webhooks remove` work only while the application is not approved, and exit 4 after; the [console](https://console.layout.link/webhooks/subscriptions) changes endpoints at any time. An endpoint receives sandbox events, live events or both, and the default is both, so one added under review without `--environment sandbox` also receives live events once the application is approved. `webhooks add` turns the endpoint on when it answers `endpoint.verification` with a 2xx. Otherwise it is added but off, and the command exits 1 with `endpoint_not_verified` and the `subscriptionId` on stderr, or 6 if Layout was rate limited and sent no verification. The production signing secret is shown only in the console. ### Global flags | Flag | What it does | | --- | --- | | `--json` | Stable machine output on stdout. Errors go to stderr as JSON. | | `--app ` | The application to act on, by id, or from CLI 0.4.2 by a name only one application has. `LAYOUT_APP` sets it too. | | `--help`, `--version` | Help for any command, and the CLI's version. | `NO_COLOR`, or output that is not a terminal, turns colour off. Once a day the CLI checks npm and tells you on stderr when a newer version is out, except with `--json`. `CI` or `LAYOUT_NO_UPDATE_CHECK` turns that off. ### Choosing an application Commands that act on one application take `--app`. If the account has exactly one application, it is used. With several, the CLI lists them and exits with code 2. With `--json`, the error carries `apps`, each with `id`, `name` and `status`. From CLI 0.4.2, a name shared by several applications also exits with code 2 and lists them, and a name no application has exits with code 5. With none, it exits with code 5, except `init`, which exits with code 2 and asks for `--name`. ## Forward events with listen ``` layout listen --forward-to http://localhost:3000/layout/webhooks ``` `listen` reads the application's sandbox event feed every 2 seconds and POSTs each event to `--forward-to`. Each request is signed like a real webhook, with a secret made for this run and printed when it starts (`whsec_cli_...`), or with `--secret `, at least 16 characters, so your handler keeps one secret across runs. Pair it with `layout check --forward-to --secret `. | Header | Value | | --- | --- | | `X-Layout-Signature` | `t=,v1=`, HMAC-SHA256 over `.` | | `X-Layout-Event` | The event type | | `X-Layout-Delivery` | The delivery id | | `X-Layout-Environment` | `sandbox`, or `live` with `--live` | | `X-Layout-Forwarded-By` | `layout-cli` | Verify it with the same code as a real webhook, using the printed secret. See [Webhooks](https://docs.layout.link/webhooks). Without `--secret`, the secret is new on every run and is not your application's sandbox signing secret, so give your server the one this run printed (with `--json`, the `secret` of the first line) each time you start `listen`. `listen` runs until you stop it: a coding agent should start it in the background. - Events arrive in order. An event is retried with backoff until your server answers 2xx, so nothing is skipped. - The position is saved after each event, separately for sandbox and production. A restart or a dropped connection resumes after the last event forwarded and never forwards one twice. - It starts from now, not from history, unless you pass `--from-start` or a saved position exists. - No webhook endpoint is needed in sandbox. While `listen` runs, the sandbox feed records your application's sandbox events even when it has no enabled endpoint; they are forwarded to you and sent nowhere else. Events from `layout trigger` are included. - If Layout cannot start recording for this run, `listen` says why on stderr and asks again every 10 seconds; `recordsWithoutEndpoint` on the first line is false until it does. Ctrl-C stops it at once and exits 130. - With `--live`, the feed holds only the production events an enabled endpoint subscribes to. The CLI warns you when the application has no enabled endpoint. - `--events` forwards only the events you name. The ones it skips still move the saved position, so a later run without `--events` does not send them. With `--json`, the first line is `{"event":"listening","app","environment","forwardTo","secret","resumed","recordsWithoutEndpoint"}`, then one line per forwarded event: ```json {"cursor":"...","event":"order.placed","eventId":"evt_...","status":200,"forwardedAt":"2026-10-02T17:04:11Z"} ``` ## Fire a test event ``` layout trigger order.unconfirmed --order ord_7c21 ``` `trigger` does what `POST /v1/sandbox/webhooks/fire` does: any event except `order.needs_approval`, and `--order` makes it describe one of your sandbox orders. A triggered `order.updated` carries `ready_eta` and `fulfillment`: the order's own with `--order`, placeholders otherwise. It goes to every enabled endpoint subscribed to the event, and to a running `layout listen`. 1,000 a day for each developer account, and 30 a minute. See [Fire a test webhook](https://docs.layout.link/sandbox#fire-a-test-webhook). ## Call the API ``` layout api get /v1/whoami layout api post /v1/users -d firstName=Dana -d phone=+15005550123 layout api get "/v1/places?query=coffee" --token lgb_... layout api post /v1/carts --token lgb_... --data @cart.json ``` Places, menus, item images and carts act for one person, so they take that person's build grant or OAuth access token in `--token`. Sent with only your key, they answer `403 wrong_credential`. See [Calling as a person](https://docs.layout.link/authentication#calling-as-a-person). `api` sends one request to the Layout API and prints the JSON answer on stdout, with the status and `X-Request-Id` on stderr. With `--json` it prints one object on stdout instead: `{"status","requestId","replayed","body"}`. The path can leave out `/v1`. It exits 0 for a 2xx and otherwise with the [exit code](#exit-codes) for the status. | Flag | What it does | | --- | --- | | `-d name=value` | A body field. Repeat it. A JSON value (a number, `true`, an array) stays JSON; anything else is a string. | | `--data ` | The whole body as a JSON object. | | `--token ` | Send this bearer instead of your key, such as a build grant (`lgb_...`) or an OAuth access token. | | `--idempotency-key ` | Each POST gets a new `Idempotency-Key`, starting `cli-`, unless you give one. | | `--live` | Production. GET only, with your own live secret. | Which key it uses: - `--token`, when you give one. - Otherwise `LAYOUT_SECRET_KEY`, when it is set. This works without `layout login`, as in CI. - Otherwise the application's sandbox key. After `layout login`, the CLI reads it once and keeps it in the same keychain as its token, and reads it again if it was rotated. Logging out deletes it. With `LAYOUT_CLI_TOKEN`, it reads the key on every run and stores nothing. Each read counts against your developer account's 40 key reads a day, so in CI also set `LAYOUT_SECRET_KEY`. Before a POST or DELETE, the CLI asks `GET /v1/whoami` whether that credential is sandbox, and sends nothing if Layout says production or cannot say. A live secret works only with `--live`, and only for GET. ## Run a test order ``` layout test-order --place test_place_placed ``` `test-order` runs a whole sandbox order and prints each step: 1. Uses `LAYOUT_SECRET_KEY` when it is set, so it runs in CI without signing in. A live key there is refused with exit code 2. Without it, it uses the application's sandbox key from the keychain, as `api` does: the first run reveals that key once, from your signed-in session, and keeps it in the keychain for later runs. 2. Provisions a sandbox test user with a `+1 500 555` number and verifies it with `000000`. 3. Mints a build grant and builds a cart, at `test_place_placed` unless you name another place. At a real restaurant it first reads the menu Layout holds and orders its first item. 4. Waits for the cart to be ready, confirms it (simulated), and waits for the order's outcome. From CLI 0.5.0, `--place` takes a `placeId` from a sandbox places search, with `--simulate` for the ending (`placed` by default) and `--latency` for the wait (`instant` by default). It exits 0 when the order ends as `--simulate` says: `failed` for `failed`, `card_declined` and `closed`, `unconfirmed` for `unconfirmed`, and `placed` for the rest. A test restaurant takes neither flag. An id starting `test_place_` that no test restaurant carries exits with code 2. From CLI 0.4.2 it also runs `test_place_needs_choices` (it answers the required choice), `test_place_needs_location` (it builds at the first candidate), `test_place_pickup_later` (it waits for the pickup time) and `test_place_delivery_far`. It exits 0 only when the order reaches the outcome the test restaurant scripts: - `placed` for `test_place_placed`, `test_place_carted`, `test_place_branch_north`, `test_place_branch_south`, `test_place_challenge`, `test_place_slow`, `test_place_price_changed`, `test_place_needs_choices`, `test_place_needs_location`, `test_place_pickup_later` and `test_place_delivery_far`. A `price_changed` refusal is shown as its own step with the old and new totals, and the confirm shows the total it confirmed. - `failed` for `test_place_failed`, and for `test_place_closed`, whose cart fails before any confirm. From CLI 0.4.3, also `failed` for `test_place_card_declined_confirm`, whose confirm answers `402 card_declined`. - `unconfirmed` for `test_place_unconfirmed`. - From CLI 0.3.1, `placed` for `test_place_code`: when the confirm answers `code_required`, the CLI sends the sandbox code `000000`. Earlier versions refuse `test_place_code` with exit code 2. `layout --version` prints yours. ```json {"app":"lyt_app_...","place":"test_place_placed","steps":[{"step":"Read the sandbox key","status":"ok","detail":"..."}],"order":{"id":"ord_7c21","status":"placed","confirmedTotalMinor":1019},"ok":true} ``` ## Check your integration ``` layout check --forward-to http://localhost:3000/webhooks/layout ``` From CLI 0.5.0, `check` grades your integration from the last 24 hours of your application's sandbox, and prints each check with what to do when it does not pass. `--since` grades a shorter window instead, such as `--since 30m` or `--since 2h`, up to `24h`. | Check | Passes when | | --- | --- | | Builds a cart | The application built a sandbox cart. | | Builds a cart at a real restaurant | One of them was at a real restaurant, from a sandbox places search. | | Builds a ready cart | One of them reached `ready`. | | Answers a choices question | The application answered a `choices` decision. | | Confirms with the cart's totalMinor | The application confirmed a cart. | | Reaches placed | An order the application confirmed reached `placed`. | | Reaches failed | An order the application confirmed reached `failed`, such as one built with `"simulate": "failed"`. A confirm refused with `402 card_declined` confirmed nothing, so it does not count. | | Reaches unconfirmed | An order the application confirmed reached `unconfirmed`. | | Confirms with a code | An order built with `"simulate": "code"`, or at `test_place_code`, was placed: the confirm answered `code_required`, and the application confirmed again with the person's code (`000000` in sandbox). | | Confirms again after price_changed | An order built with `"simulate": "price_changed"`, or at `test_place_price_changed`, was placed: after the confirm answered `price_changed`, the application built a fresh cart and confirmed it with its total. | | Does not build again after unconfirmed | No cart for the same person at the same restaurant within 10 minutes of an `unconfirmed` ending, unless that cart was built with `simulate` set to another ending. A cart built again with `"simulate": "unconfirmed"` counts. Skipped until an order has ended `unconfirmed`. | | Webhook deliveries succeed | No delivery to your sandbox endpoints failed after the latest one delivered. This is the check that grades how your handler takes real events. Skipped with no sandbox endpoint, or when nothing was delivered. | Only orders your application confirmed count toward an ending. Carts the CLI built are left out: every POST that `layout api` and `layout test-order` send carries an `Idempotency-Key` starting `cli-` unless you give one, and `check` leaves out every test person a cart with such a key was built for. So use a different test person for your application's own carts than for CLI runs, and never start your own idempotency keys with `cli-`. With `--forward-to`, it also sends your handler a test `order.placed` event four ways. The URL is https, or http on `localhost`. They test how your handler checks signatures and that it answers a repeat with a 2xx. Each carries `"test": true` and its own `event_id`, except the repeat, so a handler that skips test events must still answer them as below. | Check | Passes when your handler | | --- | --- | | Accepts a signed event | Answers 2xx to a correctly signed event. | | Refuses a bad signature | Answers 4xx to an event signed with the wrong secret. | | Refuses an old timestamp | Answers 4xx to a signature 10 minutes old. | | Answers 2xx to a repeated event_id | Answers 2xx when the first event comes again with the same `event_id`. | It signs with your application's sandbox signing secret. When your handler verifies with the secret `layout listen` used, pass that one as `--secret`. Without `--forward-to`, the check **Your webhook handler** fails. An integration that reads `GET /v1/orders` instead of taking webhooks passes `--no-webhooks`, which skips it. `check` exits 0 when nothing failed and 1 otherwise, so an agent can run it until it passes. ``` $ layout check --forward-to http://localhost:3000/webhooks/layout --json {"app":"lyt_app_...","windowHours":24,"checks":[{"name":"Builds a cart","result":"pass"},...,{"name":"Reaches unconfirmed","result":"fail","fix":"Build with \"simulate\": \"unconfirmed\", confirm it, and tell the person to check with the restaurant."},...,{"name":"Does not build again after unconfirmed","result":"skip","fix":"Runs once an order has ended unconfirmed."},...],"passed":14,"failed":1,"skipped":1,"ok":false} ``` ## JSON output With `--json`, every command prints one JSON object on stdout. Two print more than one line: `login` prints a code line and then a signed-in line, and `listen` prints a listening line and then one line per event. An error is `{"error":{"code","message"}}` on stderr. `api` is the exception: it prints the answer of any status on stdout as `{"status","requestId","replayed","body"}`, and only the CLI's own errors go to stderr. When `test-order` or `init` fails partway, stdout also gets the steps so far, with `"ok":false`. ```json {"event":"code","userCode":"BCDF-GHJK","verificationUri":"https://console.layout.link/cli","verificationUriComplete":"https://console.layout.link/cli?code=BCDF-GHJK","expiresIn":600,"live":false} {"event":"signed_in","account":{...},"session":{...}} ``` ``` $ layout whoami --json {"kind":"cli","environment":"sandbox","account":{"id":"...","name":"Acme","status":"active"},"session":{"id":"...","kind":"device","deviceName":"dana-mbp","createdAt":"...","expiresAt":"...","liveUntil":null},"capabilities":["whoami","listen:sandbox","apps:read","apps:create","keys:sandbox","webhooks:read","webhooks:sandbox","events:sandbox","trigger:sandbox","usage:read","deliveries:sandbox","live_access:request","refusals:read","check:sandbox"]} ``` ``` $ layout apps create "Acme Ordering" --json {"app":{"id":"lyt_app_...","name":"Acme Ordering","status":"pending","createdAt":"..."},"sandboxKey":{"clientId":"lp_test_...","secret":"sk_test_..."}} ``` ``` $ layout keys create --replace --json {"app":"lyt_app_...","environment":"sandbox","clientId":"lp_test_...","secret":"sk_test_...","replaced":true,"previousSecretExpiresAt":"2026-10-23T17:00:00.000Z"} ``` ### Unknown flags and places From CLI 0.4.3, a flag or a test place the CLI does not know ends with `This is @layoutplatforms/cli 0.4.3; newer versions may support this: npm i -g @layoutplatforms/cli`, since a newer CLI may. ### Exit codes | Code | Meaning | | --- | --- | | `0` | Success | | `1` | Error | | `2` | Usage: a bad flag or argument, several applications and no `--app`, or a `400` or `422` from Layout, and from CLI 0.4.2 a `405` | | `3` | Not signed in, or the session expired or was revoked | | `4` | Refused: the CLI may not do that, for example a production action | | `5` | Not found, including an account with no applications | | `6` | Rate limited | | `7` | Layout or the network is unavailable. Safe to retry. | | `8` | Conflict, for example a key already exists | | `130` | Interrupted | ### Retries Network errors, `502`, `503` and `504` are retried with backoff, up to four tries, and a read also retries `429`. Every create, revoke, trigger and review request, and every POST from `api`, carries an `Idempotency-Key`, so a retry returns the first answer instead of doing it twice. ## Production ``` layout login --live layout listen --live --forward-to http://localhost:3000/layout/webhooks layout deliveries --live ``` `keys list --live`, `webhooks list --live` and `refusals --live` need no `layout login --live`. `listen` and `deliveries` still do. From CLI 0.4.3. With production access, a session can read production as well as build in sandbox. The console marks the request as production and asks you to sign in again if your last sign-in was more than 15 minutes ago, the same check as showing a live secret. The access lasts 12 hours from sign-in. After that, `--live` exits 4 with `live_session_required` and sandbox keeps working for the rest of the 30 days. The application must be approved for production, or it exits 4 with `not_approved`. | Works with `--live` | Never from the CLI | | --- | --- | | `listen`: the live event feed, forwarded and signed like sandbox | Create, show, rotate or revoke a live key | | `deliveries`: live webhook deliveries | Show the live signing secret | | `keys list`: the live client id and last four | Fire a test event in production | | `webhooks list`: endpoints | Change any production setting | | `refusals`: why the live key was refused | | | `api get`, with your own `sk_live_` in `LAYOUT_SECRET_KEY` | POST or DELETE in production, or place an order | Layout enforces the left column on its side: a CLI token reaches only the read routes in production, and only while its access lasts. ## Security | A CLI token can | A CLI token cannot | | --- | --- | | Read the account, its applications, usage and recent sandbox deliveries | Create, read, rotate or revoke a live key | | Create applications | Read a live signing secret | | Create, read and revoke sandbox keys | Change a webhook endpoint of an approved application, or change any production setting | | List webhook endpoints, read the sandbox signing secret, and (from CLI 0.4.2) add and remove endpoints of an application not yet approved | Approve anything | | Fire sandbox test events and read the sandbox event feed | Place a live order or move money | | With production access, for 12 hours: read live events, deliveries, key details and endpoints | Write anything in production | | Ask for production review | Use any API route outside `/v1/cli`. Every other route refuses a CLI token, and `/v1/cli` refuses a client secret or a build grant. | - Layout stores the token hashed and records every action it takes in your account's audit trail. - Only a signed-in console user can approve a sign-in code, and the token belongs to that account. Production access also needs a sign-in from the last 15 minutes. - The CLI keeps only a sandbox key, never a live secret. If a computer is lost, revoke its session and replace the sandbox key. - With `--live`, `listen` forwards only to an `https` URL or to this computer. --- # Coding agents A coding agent can integrate Layout into your product and prove it against sandbox on its own. A person does the first step, because only a person can create the developer account. ## What a person does first 1. Sign up at [console.layout.link](https://console.layout.link/auth/signup) and verify your email. 2. On the console's [CLI](https://console.layout.link/cli) page, choose **New token** and give the agent the `lcli_` token as `LAYOUT_CLI_TOKEN`. It works in sandbox only, so it cannot reach production or move money. Creating one needs a console sign-in from the last 15 minutes. 3. If your product will use [Sign in with Layout](https://docs.layout.link/sign-in-with-layout), also create an OAuth client with your redirect URIs, and a sign-in test account, on the console's [OAuth clients](https://console.layout.link/oauth-clients) page. Give the agent the `client_id`, the test account's number and its sign-in code. Everything after that runs from the [Layout CLI](https://docs.layout.link/cli) and the API. ## What the agent does 1. Installs the CLI with `npm i -g @layoutplatforms/cli` and runs `layout init --name "" --json`. That finds or creates the application, prints `env.LAYOUT_APP` and `env.LAYOUT_SECRET_KEY` (an `sk_test_` key), and runs a simulated test order. It is safe to run again: it finds the same application and never rotates its key. Use it rather than `layout apps create`, which makes a new application on every run. 2. Picks the way in that fits the product, before writing any code, and says which it chose. [Provisioning](https://docs.layout.link/provisioning): your product collects the phone number, then, with your secret, provisions the person (`POST /v1/users`), verifies their number (`/verify/start`, then `/verify` with the code they read back) or, when the number already has a Layout account, sends a connect link (`POST /v1/users/:id/connect`), sends a [card link](https://docs.layout.link/card-links) (`POST /v1/users/:id/links`), and mints a build grant (`lgb_`, `POST /v1/users/:id/grant`). [Sign in with Layout](https://docs.layout.link/sign-in-with-layout): the person signs in on Layout's page and your product holds their OAuth access token. The same person gets a different id each way. See [Two ways in](https://docs.layout.link/sign-in-with-layout#two-ways-in). 3. Finds a first place to build at with the person's grant: `layout api get "/v1/places?query=coffee&near=San%20Francisco" --token lgb_... --json`. In sandbox it lists only restaurants whose menu Layout holds. An empty list with `emptyBecause: "no_held_menu"` means none of the matches can take a sandbox cart: search another kind of food or another city, or build at a [test restaurant](https://docs.layout.link/sandbox#the-test-restaurants) such as `test_place_placed`. 4. Builds the order flow on [Carts](https://docs.layout.link/carts): search `GET /v1/places`, `POST /v1/carts` with a `placeId` and the person's grant or token, poll `GET /v1/carts/:id` until it is `ready`, show the person the cart, then `POST /v1/carts/:id/confirm` once they say yes. In sandbox the cart is built at the real restaurant from the menu Layout holds, with no browser, so the same code runs in sandbox and production. 5. Handles [webhooks](https://docs.layout.link/webhooks) on your server, and tests them locally with `layout listen --forward-to --secret --json`, run in the background. `--secret`, at least 16 characters, keeps one signing secret across runs; without it each run signs with a new one, the `secret` of its first line. 6. Tests every ending at a real restaurant by sending [simulate](https://docs.layout.link/sandbox#choose-the-ending) on the cart, with `"latency": "instant"` in its tests so nothing waits. 7. Runs [layout check --forward-to --json](https://docs.layout.link/cli#check-your-integration) and fixes what it reports until every check passes. When the handler verifies with the secret `layout listen` used, it adds `--secret `. An integration that reads `GET /v1/orders` instead of webhooks runs it with `--no-webhooks`. `check` leaves out carts the CLI built, so the application's own tests use a different test person than `layout test-order`, and never start an idempotency key with `cli-`. ## Sandbox limits The numbers a test run can reach. Daily numbers count across your developer account and reset at midnight UTC. See [Limits](https://docs.layout.link/limits). - **People provisioned:** 200 a day, and 600 calls to `POST /v1/users` an hour per credential. Provisioning a number the application already provisioned does not count toward the 200, so tests can reuse a fixed test number. - **Build grants:** 500 a day, and 120 an hour per credential, from `POST /v1/users/:id/grant`. A refresh counts toward neither. - **Sandbox key reads:** 40 a day. With `LAYOUT_CLI_TOKEN`, each command that needs the key reads it, so also set `LAYOUT_SECRET_KEY`. - **Sandbox builds:** 10,000 a day, at no cost. A build with `"engine": "real"` drives the restaurant's own site and is limited to 10 a day, so tests never send it. ## Rules for the agent - Use only `sk_test_`. An `sk_live_` secret places real orders and charges real cards with the same code. It never belongs in development, tests or CI. - Keep the secret on your server. Places, menus, item images and carts take the person's build grant or OAuth access token, never your secret. - Confirm with the cart's `totalMinor`, exactly as the cart gives it. The webhook's `total_minor` is the restaurant's total without Layout's service fee, and confirming with it is refused. - Only `placed` means the order exists. A confirm answers `placing`, which can still end `failed`. `unconfirmed` is never a failure: never build it again, and never tell the person it failed. - Send a new `idempotencyKey` for each new cart and confirm, and the same one when you retry the same request. A confirm sent again with the person's code is the same request: keep its `idempotencyKey`. - In sandbox, people have `+1 500 555` numbers and the code is `000000`. Nothing is texted. - An order with `"simulated": true` never reached a restaurant. Never show it to anyone as a real order. - Verify every webhook's signature over the raw body, and skip an `event_id` you have already handled. Never de-duplicate on the order id and event type: `order.updated` can come more than once for one order, each with its own `event_id`. - The pickup time can arrive after `placed`. On every `order.updated`, read `GET /v1/orders/:id`. Show `ready_eta` word for word, never parse it, and show no time while it is null. - Orders are pickup only today. Read `fulfillment.type` rather than assuming it. - When a person leaves your product, call `DELETE /v1/users/:id`. - `POST /v1/orders` is retired and answers `410`. Orders start as carts. ## A prompt to start with Hand this to your agent, or save it in the file your agent reads for project instructions, such as `AGENTS.md`, or `CLAUDE.md` for Claude Code. ``` Integrate Layout into this project so people can order pickup food from it. Read https://docs.layout.link/coding-agents.md first, then https://docs.layout.link/llms.txt for the rest. The OpenAPI spec is https://docs.layout.link/openapi.json. LAYOUT_CLI_TOKEN is set. Run `layout init --name "" --json` and keep the LAYOUT_APP and LAYOUT_SECRET_KEY it prints in our development environment, never in source control. Pick the way in that fits this product (provisioning, or Sign in with Layout) before writing code, and tell me which you chose. Work in sandbox only. Follow the rules on that page, test every ending with `simulate`, and run `layout check --forward-to ` until every check passes. Tell me what you could not do. ``` ## What the agent cannot do These stay with a person, on purpose: - **Production.** Your person requests it from the console, where a form asks about the company, its policies and the app's listing, and a person at Layout decides it. `layout request-live-access` prints that page's link for them. The `sk_live_` secret is revealed only in the console, once the application is approved, and the production signing secret is shown only there too. - **Webhook endpoints.** Added and removed on the console's [Webhooks](https://console.layout.link/webhooks/subscriptions) page. `layout webhooks add` and `layout webhooks remove` also do it while the application is under review, from CLI 0.4.2. `layout listen` needs none. - **OAuth clients and sign-in test accounts.** Created on the console's OAuth clients page, as above. - **Return URLs.** A [card link](https://docs.layout.link/card-links) sends the person back only to a return URL registered on the console's [App details](https://console.layout.link/app-details) page. A person adds it there; the CLI cannot. For local tests register `http://localhost:/`, which works with the sandbox key only. - **Real orders.** A real person confirms a real order, with a verified number and a card on their Layout account. --- # API reference Users, places, menus, menu item photos, orders, events and the readiness check. Carts have their own page. Base URL `https://api.layout.link/v1`, bearer auth on every call, JSON in and out. The machine-readable contract is [openapi.json](https://docs.layout.link/openapi.json), generated from the shapes the API validates with. ## Every request | Header | Direction | Notes | | --- | --- | --- | | `Idempotency-Key` | You send | On any `POST`. 1 to 255 visible ASCII characters, or 8 to 100 letters, digits, `.`, `_`, `:` or `-` when it stands in for a cart's `idempotencyKey`. A retry with the same key and body gets the stored response and never runs twice. See [Idempotency](https://docs.layout.link/limits#idempotency). | | `Layout-Version` | Both | Optional on a request: the dated version you code against. Without it you get the current version, `2026-10-01`. Every answer from an endpoint names the version that answered. See [Versioning](https://docs.layout.link/versioning). | | `X-Request-Id` | Layout sends | Layout's id for the request, e.g. `req_3f9c2a7e1b4d8f6a0c5e9b27`, on every answer from an endpoint, errors included. Log it, and quote it when you write to [developer@layout.link](mailto:developer@layout.link). | | `Idempotent-Replayed` | Layout sends | `true` when the response is the stored answer to an earlier request with the same `Idempotency-Key`. Absent otherwise. | Users, events, usage and the sandbox route take your application secret. Places, menus, images and carts take a person credential, a build grant or an OAuth token, never your secret: your secret there is `403 wrong_credential`. See [The credentials](https://docs.layout.link/how-it-works#the-credentials). ## GET /v1/whoami A readiness check, so flipping to production is something you verify rather than guess. It returns what the key you called with resolves to and reads nothing else, so it is safe to call from a deploy script. ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" ``` ``` 200 OK { "kind": "app", "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "sandbox", "approvalStatus": "pending", "scopes": ["order:preview", "order:build", "order:confirm", "order:status", "places"] } ``` `environment` is decided by the key prefix. `approvalStatus` is the application's real status, so a **sandbox** key returns `pending` for an unapproved app. Check it before minting a live key: production is refused until approval, and this says so up front instead of surfacing a 403 in prod. `scopes` lists what a build grant from this application may do, each scope once. Called with a person's OAuth access token or a build grant instead of your secret, it says who that credential acts for, with `kind` set to `"user"` or `"grant"`, the `app` and `environment`, and `user.id`. Its `scopes` are the credential's own: `["order"]` for a person's token, `["build", "confirm"]` for a grant (it confirms with the person's code whenever Layout asks for one), which also carries `expiresAt`. A person who connected through Layout's consent screen has a `usr_` id of 24 hex characters; one you provisioned has 12. See [Calling as a person](https://docs.layout.link/authentication#calling-as-a-person). ## POST /v1/users Create someone you can order for. You send a name and a phone number; you get back an id to use everywhere else, and `next`, the step that connects them. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' ``` ``` 201 Created { "id": "usr_4b8e9a01c2d3", "status": "provisioned", "next": "verify", "session": { "scope": "build", "expiresIn": 900 } } ``` The id field is `id`, not `userId`. Provisioning returns no credential: `session` carries no token, authenticates nothing, and you can ignore it. Mint a build grant, for REST or MCP, with [POST /v1/users/:id/grant](#post-v1-users-id-grant). `handoffUrl` is deprecated and kept for older integrations; do not use it. This answer carries a `Deprecation` header and a `Link` header with `rel="deprecation"` (see [Deprecation](https://docs.layout.link/versioning#deprecation)), also on an `Idempotency-Key` replay. `next` is the person's next step: `verify`, `connect`, `awaiting_user_approval` or `connected`. Branch on it. See [The next step](https://docs.layout.link/provisioning#next-step) for what each one means. | Field | Required | Notes | | --- | --- | --- | | `firstName` | Yes | 1 to 100 characters. Kept on the account Layout holds for the person. | | `lastName` | Yes | 1 to 100 characters. Kept on the account Layout holds for the person. | | `phone` | Yes | E.164. In **sandbox**, use a reserved test number in the `+1 500 555 xxxx` range (e.g. `+15005550142`); a real number is refused there. A live key cannot use a `+1 500 555` number. | | `email` | No | Kept with the provisioning link, unverified; never written to the person's own Layout account and never shown to another partner. A malformed address is a `400 invalid_request`. | | `label` | No | Your own name for this person, shown in the console. Up to 120 characters. | | `connectDelivery` | No | `"developer"`, for a person who signs in to Layout on this number themselves: you get the consent link back instead of Layout texting it. See [POST /v1/users/:id/connect](#post-v1-users-id-connect). | A field out of range is a `400 invalid_request` whose message says what was wrong. Provisioning is idempotent per phone number: calling again with the same number gives you the same `id` back rather than a duplicate. A disconnected person comes back under the same `id` as a fresh invite: read `next`. A number already provisioned by another application in your developer account, in the same environment, is a `409 provisioned_to_another_app`. A provisioned person's `id` is `usr_` and 12 hex characters. Someone who connects your application through Layout's consent screen instead gets a `usr_` id of 24 hex characters, so the same person reached both ways can have two ids. Treat ids as opaque, and do not expect one route's id to match the other's. ## POST /v1/users/:id/verify/start Texts the person a six-digit code, good for three minutes and five tries. A new start replaces the last code. In sandbox nothing is texted and the code is `000000`. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone) for the limits and every error, and [Texts Layout sends](https://docs.layout.link/texts) for the text itself. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e9a01c2d3/verify/start \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" 200 OK { "ok": true, "id": "usr_4b8e9a01c2d3", "phoneHint": "+1 (916) •••-0142", "expiresInSec": 180 } ``` ## POST /v1/users/:id/verify Send the code the person read back. Read `authorized` before building: `false` with `next: "connect"` means the person signs in to Layout on this number and approves a [connect link](#post-v1-users-id-connect) instead. Once `authorized` is `true`, `card` says whether their Layout account holds a card Layout can order with, so you know whether to send a [card link](#post-v1-users-id-links) before the first build (see [GET /v1/users/:id](#get-v1-users-id) for the fields). A wrong code is `401 bad_code`, and an expired one is `400 code_expired`. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone). ```bash curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3/verify \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "246810" }' 200 OK { "id": "usr_4b8e9a01c2d3", "verified": true, "authorized": true, "status": "active", "claimed": true, "card": { "onFile": false, "orderable": false, "brand": null, "last4": null } } ``` ## POST /v1/users/:id/connect A consent link for a person who signs in to Layout on this number. By default Layout texts it to them and answers `next: "awaiting_user_approval"`. Send `connectDelivery: "developer"` to get `connectUrl` back instead. `returnUrl` is optional and `https` only. A person already connected answers `connected: true`. See [Someone who signs in to Layout](https://docs.layout.link/provisioning#connecting-an-existing-account). ## POST /v1/users/:id/grant Mint a build grant for one person you provisioned. It authenticates the REST person routes (places, menus, images, carts) and an MCP session. Ask for it deliberately, when you are about to build. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e9a01c2d3/grant \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" ``` ``` 201 Created { "token": "lgb_9f3a2c…", "expiresAt": "2026-09-12T17:15:41Z", "scope": "build" } ``` The token builds carts, reads state and releases carts it built. It confirms orders the person said yes to, with the 6-digit code Layout texts them when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due) (see [Confirming with a build grant](https://docs.layout.link/carts#relayed-code)). It cannot add a card: that happens on Layout's own page, in front of the person whose card it is. Mint that page with [POST /v1/users/:id/links](#post-v1-users-id-links). A grant lasts 15 minutes. To keep a session going, send the current grant back before it expires. You get the same token with a later `expiresAt`, at most 15 minutes from the refresh and never more than 24 hours from when it was minted, and the refresh does not count toward your build-session limits. A grant that cannot be refreshed answers `409 grant_expired`. The body is optional: without a `refresh` token, or with `"refresh": null`, the call mints a new grant. A `refresh` that is not a grant token is a `400`. A person who claimed their own Layout account without connecting your application answers `409 already_claimed`. An id this application did not provision in this environment, a consent-screen id, or a revoked link is a `404`. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e9a01c2d3/grant \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "refresh": "lgb_9f3a2c…" }' 200 OK { "token": "lgb_9f3a2c…", "expiresAt": "2026-09-12T17:28:12Z", "scope": "build", "refreshed": true } ``` ## POST /v1/users/:id/links Mint a card link for one person you provisioned and verified: a single-use Layout page, good for 15 minutes, where they add a card without signing in to Layout and then return to your app. ```bash curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3/links \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "purpose": "card", "returnUrl": "com.acme.assistant:/layout/card", "state": "c1f9e2" }' ``` ``` 201 Created { "url": "https://layout.link/h/3q2Xw7…", "expiresAt": "2026-10-21T17:15:00.000Z", "purpose": "card" } ``` `purpose` is required and is `"card"`. `returnUrl` is optional and must be exactly one of the return URLs registered for your application in the console. `state` is optional, up to 512 characters, and comes back to you on the return URL. When the person finishes, their browser opens your return URL with `state` and then `status=card_added` or `status=canceled` added, and nothing else, such as `com.acme.assistant:/layout/card?state=c1f9e2&status=card_added`. It is not a webhook and not proof of a card: read [GET /v1/users/:id](#get-v1-users-id), whose `card` comes from Layout. Register return URLs on the console's **App details** page (see [Return URLs](https://docs.layout.link/card-links#return-urls)). A person who has not verified with your application is `409 person_not_ready`. See [Card links](https://docs.layout.link/card-links) for the page, return URLs, opening it in an app, and every error. ## GET /v1/users/:id Where one person stands with your application, read now: whether you may build for them, and whether their Layout account holds a card Layout can order with. Read it after verify to decide whether to send a card link first, and after a card link returns `status=card_added`, since only this answer comes from Layout. Takes your secret key, never a build grant. ```bash curl https://api.layout.link/v1/users/usr_4b8e9a01c2d3 \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" 200 OK { "id": "usr_4b8e9a01c2d3", "status": "active", "authorized": true, "card": { "onFile": true, "orderable": true, "brand": "visa", "last4": "4242" } } ``` | Field | Notes | | --- | --- | | `authorized` | `true` when you may build for this person. | | `card` | `null` until `authorized` is `true`: before that, Layout says nothing about the person's account. | | `card.onFile` | `true` when the person's Layout account holds a card. | | `card.orderable` | `true` when Layout can order with the card it would charge. `false` with no card, when that card's issuer refuses orders like Layout's, or when it cannot be charged yet. Either way, send a [card link](#post-v1-users-id-links). | | `card.brand`, `card.last4` | The card Layout would charge, or `null` with no card. Nothing else about a card is ever returned. | | `textsBlocked` | `null` until `authorized` is `true`, then `true` when the person replied STOP to Layout. A confirm that needs a code then answers `texts_blocked` until they reply START, so ask them to before you build. | In sandbox every verified person reads as holding a test Visa ending 4242, because a sandbox build never needs a card, except a number in `+1 500 555 0050` to `0099`: that person reads `card: { "onFile": false, "orderable": false, "brand": null, "last4": null }` until you press **Add test card** on a sandbox [card link](https://docs.layout.link/card-links) (see [Verify in sandbox](https://docs.layout.link/provisioning#verify-in-sandbox)). A sandbox cart's `card` is `null`, since nothing is charged. An id this application did not provision in this environment, or a revoked link, is a `404`. A read Layout cannot complete is a `503` to retry, never `onFile: false`. Reads are limited per person and per application each hour; a `429` carries `Retry-After`. ## DELETE /v1/users/:id Disconnect a person from your application. Every application can call it, with its secret key. ```bash curl -X DELETE https://api.layout.link/v1/users/usr_4b8e9a01c2d3 \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" 200 OK { "id": "usr_4b8e9a01c2d3", "status": "revoked" } ``` Every build grant for the person stops working at once, and their standing agreement that your application places orders for them ends. Their Layout account, cards and orders stay theirs and are not deleted. Repeating it answers the same. An id that is not your application's is a `404`. Provisioning the same number again later gives the same `id` back as a new invitation, and the person verifies again. See [Disconnect a person](https://docs.layout.link/provisioning#disconnect-a-person). ## GET /v1/places Restaurants open right now, by name or kind of food, from what Layout holds, ranked by relevance, then by distance from the person, or from the centre of `near` when you send it. Send up to three names at once, such as every chain your app offers, and they come back as one list. Send `include=closed` to also list the places that are shut. Or send the `ids` of places you already picked to read them back with their status now. Send only `lat` and `lng` to [browse nearby](#browse-nearby). In sandbox, a search and a browse list only restaurants whose menu Layout holds, since a sandbox cart is built from that menu. ```bash curl "https://api.layout.link/v1/places?query=coffee&lat=38.79&lng=-121.24&limit=3" \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" ``` ``` 200 OK { "places": [ { "placeId": "ChIJbloomCoffee", "slug": "layout-test-kitchen-rocklin", "name": "Layout test kitchen", "address": "1194 Roseville Pkwy, Rocklin, CA 95677, USA", "city": "Rocklin", "region": "CA", "cuisine": "Coffee shop", "rating": 4.6, "priceLevel": 2, "website": "https://example.com", "openNow": true, "openStatus": "open", "ordering": "confirmed", "closesAt": "5:00 PM", "opensAt": null, "closingSoon": false, "closesAtIso": "2026-10-23T00:00:00.000Z", "opensAtIso": null, "lat": 38.7907, "lng": -121.2358, "distanceKm": 1.2, "distanceMi": 0.7, "photoUrl": "https://api.layout.link/v1/public/places/layout-test-kitchen-rocklin/photo" } ], "searched": true, "localityMatched": true } ``` | Parameter | Required | Notes | | --- | --- | --- | | `query` | Yes, or `ids`, or `lat` and `lng` to browse | A name, a kind of food, or a common drink (latte, cold brew, boba), which finds the shops that serve it. Up to 200 characters. Up to three names separated by commas, such as `sweetgreen,chipotle,cava`, search each one and answer them as one list: each place once, open places first, then nearest first. Each name counts as one place read toward your [daily limit](https://docs.layout.link/limits) and answers at most 10 places. | | `ids` | Yes, or `query`, or `lat` and `lng` to browse | Up to 25 `placeId`s separated by commas, from earlier results: the places you picked, in the order you sent them, with their status now. Closed places are included. Layout reads them from what it holds and never searches. Each id counts as one place read, as [GET /v1/places/:placeId](#get-v1-places-id) does. An id Layout no longer holds comes back in `missing`. With `ordering=confirmed`, places not confirmed are left out. Send `query` or `ids`, not both. | | `near` | No | A place the person named: a city or town, up to 200 characters. Results come from that place, and most distances are measured from its centre, not from the person, so the first result is not necessarily the closest to them. Check `localityMatched`. A neighbourhood, landmark or ZIP code is not matched as an area and comes back with `localityMatched: false`; a ZIP code still gives a rough centre to measure from. | | `lat`, `lng` | No | Where the person is, from their device. Send both or neither. With `near` as well, most distances are measured from the centre of `near`, not from these coordinates, so leave `near` out when you want the stores nearest the person. See [Finding the nearest store](https://docs.layout.link/carts#nearest-store). | | `include` | No | `closed` also lists places that are shut now or whose hours Layout does not hold, after the open ones, with `opensAt` and `opensAtIso`. It lists only places Layout already holds and never looks beyond them, so a thin answer carries `searchLimit`. To find more, search without it. | | `ordering` | No | `confirmed` lists only places where Layout has found an online ordering route. | | `limit` | No | 1 to 25. Default 5. One name answers at most 10; several names fill up to 25. With `ids`, every place you sent is answered. | | `sort` | No | `relevance` (the default) puts a single name's best match first, and several names open first, then nearest. `distance` lists what the search found nearest first, open or not. A browse is always nearest first. | | `cursor` | No | A browse's `nextCursor`, for the next page. See [Browse nearby](#browse-nearby). | An empty list is not proof there is nothing there. Read these before saying so. | Field | Notes | | --- | --- | | `searched` | `false` when Layout did not look this time. | | `emptyBecause` | `all_closed`, `not_searched`, `nothing_to_search` (nothing named and nowhere to look), `no_match` (Layout looked and holds no open place matching it) `none_confirmed` (with `ordering=confirmed`: Layout holds matching places but has confirmed online ordering at none of them) or `no_held_menu` (sandbox: Layout holds matching places but holds the menu of none of them). `not_searched` can come with the places Layout already holds. | | `localityMatched` | `false` when Layout could not place `near`, or some places come from a weaker match (San Jose for San Francisco). The places are real, but not where the person asked. | | `searchLimit` | Sent when a limit is why Layout did not look further. `pool` is `registry_only` in sandbox and before approval (only places Layout already holds), or `approved`, with `scope` `developer` (your daily limit) or `platform` (the pool's). Also `perDay` and a `message`. | | `stillChecking` | `true` while Layout is still finding out whether more places there take online orders, with `stillCheckingCount` when known. Ask again in a minute. | | `matchedOn` | On a `query` search, what the query matched: `name` (every word that matched is a whole word of the name), `name_partial` (a word only begins or sits inside a word of the name, so `cava` finds Cavaña), `cuisine` or `kind` (the kind of place it is listed as). `null` when the place came from a search beyond Layout's registry, which cannot say. Use it to drop a `name_partial` row you do not want. | | `cuisines` | Finer cuisine words than `cuisine`, sorted, from this list: `american`, `bakery`, `bbq`, `bowls`, `breakfast`, `bubble_tea`, `burger`, `cafe`, `chicken`, `chinese`, `dessert`, `fast_food`, `greek`, `indian`, `italian`, `japanese`, `juice`, `korean`, `mediterranean`, `mexican`, `middle_eastern`, `noodles`, `pizza`, `poke`, `ramen`, `salad`, `sandwich`, `seafood`, `steak`, `sushi`, `thai`, `vegan`, `vegetarian`, `vietnamese`. Empty when Layout holds none. New words may be added. | | `openStatus` | `open` in a search. With `include=closed` or `ids`, and on [GET /v1/places/:placeId](#get-v1-places-id), it can be `closed`, or `unknown` when Layout holds no hours, which never means closed. | | `closesAtIso`, `opensAtIso` | When an open place closes, and when a closed one next opens, as instants. Compare them to your own clock rather than trusting `closingSoon` on a cached row, and sort on `opensAtIso`, not on `opensAt`. `null` when Layout does not know. | | `lat`, `lng` | Where the store is, for a map. `null` when Layout does not hold it. | | `missing` | With `ids`: the ids Layout holds no place for, or no longer shows. Present only when there is one. | | `ordering` | `confirmed` (Layout found an online ordering route), `unconfirmed` (not decided yet) or `unavailable` (Layout looked and there is none). | ### Browse nearby Send `lat` and `lng` and no `query` or `ids` to list what Layout holds around the person, nearest first, a page at a time. A browse reads Layout's registry only and never searches beyond it, and each page counts as one place read. `near` is refused in a browse: it is around the coordinates. ```bash curl "https://api.layout.link/v1/places?lat=38.79&lng=-121.24&limit=25" \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" 200 OK { "places": [ … ], "searched": true, "localityMatched": true, "nextCursor": "pcur_…" } ``` `limit` is at most 25. `nextCursor` is an opaque signed string; send it back as `cursor` with the same `lat`, `lng`, `include` and `ordering`, from the same application and person, for the next page. It is `null` on the last page, and a browse only goes so deep. A cursor that does not fit what you sent, or has expired, is `400 invalid_request`: start again without it. Sending a `cursor` with a `query` or `ids` is the same error. Each person gets 30 requests a minute across `GET /v1/places` and `GET /v1/places/:placeId`; past that the answer is `429` with `Retry-After`. The first query in an area can take several seconds while Layout checks which places there take online orders: it answers `stillChecking: true`, so ask again after a minute rather than reading a short list as the whole area. ## GET /v1/places/:placeId One place by the `placeId` a places result, or a menu or image `needs_location` candidate, gave you: the place, its dietary, service and good-for facts, and whether it can be ordered from. Send `lat` and `lng` for a distance. ``` 200 OK { "place": { "placeId": "ChIJbloomCoffee", "name": "Layout test kitchen", "ordering": "confirmed", "openStatus": "open" }, "facts": { "dietary": { "vegetarian": true, "breakfast": true, "lunch": true, "dinner": null, "brunch": null }, "service": { "dineIn": true, "takeout": true, "delivery": null, "curbside": false, "reservable": null }, "goodFor": { "children": null, "groups": true }, "asOf": "2026-09-30T18:00:00.000Z" }, "orderability": { "verdict": "confirmed" } } ``` The place is the same row the search returns, shortened here. Every fact is `true`, `false`, or `null`, and `null` means Layout does not know, never no. `service` is what the restaurant itself offers, not what Layout orders: Layout places pickup orders only. `facts` is `null` when Layout holds none. `orderability.verdict` is `confirmed`, `unconfirmed` or `unavailable`, with a `reason` when Layout knows one: `no_online_ordering`, `not_a_restaurant`, `not_offered`, or `rechecking`. An unknown id is a `404`. ## POST /v1/menus A restaurant's live menu, read from its own site: the same read the MCP `menu` tool runs. Name the restaurant by `placeId`, or by `query` with `near` or `lat` and `lng`. Read-only: nothing is built or charged to the person. In sandbox it answers only from the menu Layout holds, with `mode: "knowledge"` too, and never reads one live; a restaurant with none is `409 menu_not_held`. See [Sandbox](https://docs.layout.link/sandbox#places-and-menus). ```bash curl https://api.layout.link/v1/menus \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "placeId": "ChIJbloomCoffee" }' ``` A first read takes up to a minute, so a read that is not done answers `202`: ``` 202 Accepted Retry-After: 5 { "status": "reading", "menuId": "mnu_Q2hJSmJsb29t…", "restaurant": { "name": "Layout test kitchen", "address": "1194 Roseville Pkwy, Rocklin, CA 95677, USA" }, "assumedNearest": false, "retryAfterSec": 5 } ``` Poll `GET /v1/menus/:menuId` until it answers `200`. Polling is free: it never starts a read and never draws on an allowance. Poll every few seconds, as `Retry-After` says. A `menuId` is opaque, works only for your application and that person, and lasts an hour; a `404` means start again. A `503` means Layout could not check the read just then: it is not lost, so poll again after `Retry-After` rather than starting a new one. ``` 200 OK { "status": "menu", "restaurant": { "name": "Layout test kitchen", "address": "1194 Roseville Pkwy, Rocklin, CA 95677, USA" }, "assumedNearest": false, "items": [ { "name": "Latte", "image": { "url": "https://cdn.layout.link/items/d/example.com/latte-0e9d083ec191.jpg", "webpUrl": "https://cdn.layout.link/items/d/example.com/latte-0e9d083ec191.webp", "width": 1000, "height": 1000, "source": "menu", "checkedAt": "2026-10-01T22:40:00.000Z" }, "priceMinor": 525, "category": "Drinks", "optionGroups": [{ "name": "Milk", "required": true, "options": ["Whole", "Oat"], "defaults": ["Whole"] }] } ], "confidence": "high", "pricesUnavailable": false } ``` Switch on `status`. Only `menu` is the restaurant's live menu. An item carries `image` when Layout already holds the restaurant's own photo of it, in the shape [the Images API](#post-v1-images-items) returns. No `image` means none is held yet, not that none exists: ask `POST /v1/images/items` for that item. An option group's `defaults` are the options the item comes with, written exactly as in `options`, when the restaurant preselects any. A group with `defaults` is already chosen: show it chosen, and change it only when the person asks. A required group without `defaults` needs an answer before the item can be ordered. An item marked sold out carries `soldOut: true`. When Layout already read this store's whole menu recently, the answer is a `200` at once, with no read started and nothing drawn from an allowance; `readAt` says when that read happened, and is absent on a menu read just now. `pricesUnavailable: true` means the site gave no prices: show the items and never fill a price in. When Layout has not read this location's menu yet but knows the restaurant's usual menu, the answer is that usual menu at once, marked `scope: "chain"` with `pricesUnavailable: true`. Its items carry their `optionGroups`, with no price on any option. This location may not carry every item. The same applies when this location's menu cannot be read. Asking still counts toward Layout reading this location, so a later ask can return its own priced menu. | `status` | Meaning | | --- | --- | | `menu` | The live menu. `confidence: partial` means it may not be all of it. Prices are minor units. | | `categories` | The site splits its menu into sections. Send one back as `category`. | | `reading` | Still reading. Poll the `menuId`. | | `knowledge` | General knowledge, labeled `live: false`. Prices are not current. Ask for it with `mode: "knowledge"`, or it comes back in place of `unreadable` or `not_found` when the live read could not be made sense of or found no ordering site, unless your developer account's knowledge allowance is spent. A poll never returns one; it answers `unreadable`. Sandbox never returns one. | | `unreadable` | Layout could not read this menu cleanly. The person can still order by naming what they want. | | `not_found` | No online ordering site was found for that name. | | `needs_location` | Send `near`, coordinates, or one of `candidates` as `placeId`. | When a name matches several branches, Layout reads the nearest one and says so with `assumedNearest: true`; tell the person which. A menu read moments ago is a `429` with `Retry-After`. A spent daily allowance of live menu reads (the person's, your developer account's, or the pool's ceiling across every developer) is a `429` with no `Retry-After`, because it resets at midnight UTC. So is a spent allowance of knowledge answers when you sent `mode: "knowledge"`. The message names which. Starting reads is limited to 20 per person in 10 minutes, a `429` with `Retry-After: 60`, and polling to 120 a minute per person. An allowance Layout cannot check just then is a `503` with `Retry-After`, and nothing was read. ## POST /v1/images/items What one menu item looks like, using the photo the restaurant itself published: the same lookup the MCP `item_photo` tool runs. Layout screens every photo (safe, food, and this item), strips its metadata and serves it from its own CDN. It is never a stock or generated image. Name the item in the person's words; spelling and phrasing need not match the menu. Name the restaurant by `placeId`, or by `query`. A chain needs no branch and no open store, because its photos are the same everywhere. Read-only: nothing is built or charged to the person. ```bash curl https://api.layout.link/v1/images/items \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "item": "iced latte", "query": "Layout test kitchen" }' ``` A photo Layout already holds answers at once: ``` 200 OK { "status": "photo", "restaurant": { "name": "Layout test kitchen", "address": null }, "item": "Iced Latte", "priceMinor": 545, "image": { "url": "https://cdn.layout.link/items/d/example.com/iced-latte-b096c08aac3f.jpg", "webpUrl": "https://cdn.layout.link/items/d/example.com/iced-latte-b096c08aac3f.webp", "width": 1000, "height": 1000, "source": "menu", "checkedAt": "2026-10-01T22:40:00.000Z" } } ``` Otherwise Layout reads the restaurant's own site. When the photo turns up within about 12 seconds the answer is the same `200`. When it does not, the answer is `202` with an `imageId`: ``` 202 Accepted Retry-After: 2 { "status": "finding", "imageId": "img_Q2hJSmJsb29t…", "restaurant": { "name": "Layout test kitchen", "address": null }, "retryAfterSec": 2, "giveUpAt": "2026-10-01T23:00:35.000Z" } ``` Poll `GET /v1/images/items/:imageId` every couple of seconds, as `Retry-After` says. Polling is free: it never starts a search and never draws on an allowance. Layout stops looking 35 seconds after your request; a poll after `giveUpAt` answers `no_photo` with reason `not_found_in_time`, so tell the person there is no photo rather than keep them waiting. An `imageId` works only for your application and that person, and lasts 10 minutes; a `404` means start again. Starting searches is limited to 20 per person in 10 minutes, a `429` with `Retry-After: 60`, and polling to 120 a minute per person. | `status` | Meaning | | --- | --- | | `photo` | Show `image.url`. `item` is the name as the restaurant writes it. | | `finding` | Still looking. Poll the `imageId`. | | `no_photo` | `reason: not_published`: the item is on the menu with no photo. `reason: not_found_in_time`: no photo Layout could trust turned up within 35 seconds. Asking again later may find one. | | `not_on_menu` | Layout read the whole menu and the item is not on it. | | `needs_item` | `item` named no dish, only the restaurant or a word like `food`. Ask the person which item. Layout never picks one for them. | | `needs_location` | A restaurant with branches that differ. Send `near`, coordinates, or one of `candidates` as `placeId`. | | `not_found` | No restaurant site was found for that name. | | `removed` | The restaurant asked Layout not to show its photos. | Show the `cdn.layout.link` URL rather than copying the file to your own storage. The photos are the restaurant's, and when one asks Layout to take theirs down, the URLs stop serving everywhere at once. A search that opens the restaurant's site draws on a daily allowance (see [limits](https://docs.layout.link/limits#place-and-menu-reads)); a photo Layout already holds never does. A spent allowance is a `429` that names which. ## Carts `POST /v1/carts` builds a cart, `GET /v1/carts/:id` reads it, and `POST /v1/carts/:id/confirm` places it. See [Carts](https://docs.layout.link/carts). ## GET /v1/orders The orders your application caused in this secret's environment, newest first, each in the shape of [GET /v1/orders/:id](#get-v1-orders-id). It takes your secret key, never a build grant or a person's token. A sandbox secret never sees a live order. ```bash curl "https://api.layout.link/v1/orders?userId=usr_4b8e9a01c2d3&limit=20" \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" 200 OK { "orders": [ { "id": "ord_7c21e4b9a0d3", "state": "placed", … } ], "nextCursor": "…" } ``` | Parameter | Notes | | --- | --- | | `userId` | Optional. Only this person's orders. A person who is not your application's in this environment is `404 not_found`. | | `cursor` | Optional. The `nextCursor` of the previous page, opaque. `nextCursor` is `null` on the last page. | | `limit` | Optional. 1 to 100. Default 20. | A malformed `limit` or `cursor` is `400 invalid_request`. A build grant is `403 wrong_credential`, a key Layout does not recognise is `401`, a spent per-minute ceiling is `429` (it shares the ceiling of `GET /v1/orders/:id`), and a read Layout cannot complete is `503` to retry. ## GET /v1/orders/:id Everything Layout will tell you about one order. Your application secret reads every order your application caused. A connected person's token reads only that person's orders your application drove, in the same shape. A build grant reads an order a build grant from your application built for that same person, in the same environment. Any other order is a `404` to a grant, the same answer as an order that does not exist. ```bash curl https://api.layout.link/v1/orders/ord_7c21e4b9a0d3 \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" ``` ``` 200 OK { "id": "ord_7c21e4b9a0d3", "state": "placed", "store": "Layout test kitchen", "store_address": "1 Sandbox Way, San Francisco, CA 94107", "items": 2, "total_minor": 2140, "currency": "usd", "created_at": "2026-09-12T17:00:41Z", "placed_at": "2026-09-12T17:03:02Z", "ready_eta": "Ready by 10:15 AM", "ready_at": "2026-09-12T17:15:00.000Z", "refunded_minor": 0, "failure_code": null, "fulfillment": { "type": "pickup", "name": "Sam", "address": "1 Sandbox Way, San Francisco, CA 94107", "ready_text": "Ready by 10:15 AM", "merchant_order_number": "A47", "instructions": "Pick up at the mobile order counter" }, "lines": [ { "name": "Latte", "quantity": 1, "unit_price_minor": 1325, "modifiers": ["Oat milk"] }, { "name": "Blueberry muffin", "quantity": 1, "unit_price_minor": 675, "modifiers": [] } ], "amounts": { "subtotal_minor": 2000, "tax_minor": 140, "merchant_total_minor": 2140, "service_fee_minor": 111, "service_fee_waived": false, "credit_minor": 0, "courier_fee_minor": 0, "tip_minor": 0, "charged_minor": 2251 } } ``` `ready_eta` is the pickup time in the store's own words, when the store states one. It can be null when the order is `placed` and fill in later. Show it as written; it is not a timestamp. See [Pickup time](#pickup-time). `ready_at` is the same pickup time as an instant, set only when Layout could place the store's words on the store's clock, and null otherwise. `ready_eta` stays the text to show; use `ready_at` to schedule or sort. `refunded_minor` is the total refunded on the order so far, in minor units, and `0` when nothing was. A refund does not change `state`: each one sends an [order.refunded](https://docs.layout.link/events#order-refunded) event. `fulfillment` is how the person gets the order, and what they need at the counter. `type` is `pickup`: orders on Layout's developer platform are pickup only today. Read `type` rather than assuming it. `name` is the name the store has the order under, `merchant_order_number` is the store's own number for it (not Layout's id), and `instructions` are the store's pickup instructions, each word for word as the store stated them. `ready_text` is the same text as `ready_eta`. These four are null until the order is `placed`, and stay null when the store does not state them. `address` is where to collect it, the same as `store_address` for pickup. Details the store states after placement arrive with an [order.updated](https://docs.layout.link/events#more-than-one-order-updated) event, which can come more than once: read the order again on each one. A sandbox order answers the name `Sandbox` and the order number `SIMULATED`. `lines` is what was ordered, with each line's modifiers. Layout's own tax-and-fees line is not a line. `items` is the total quantity across the lines, so two lattes and a muffin is `3`. `total_minor` is the store's total and leaves out Layout's fee. `amounts` is the money, in minor units: | Field | Notes | | --- | --- | | `subtotal_minor`, `tax_minor` | The store's own, when it stated them. Null otherwise. | | `merchant_total_minor` | The store's total, the same as `total_minor`. | | `service_fee_minor` | Layout's service fee. | | `service_fee_waived` | `true` when the person's plan waives the fee. | | `credit_minor` | Layout credit taken off. | | `courier_fee_minor`, `tip_minor` | `0` on a pickup order. | | `charged_minor` | What the person's card was charged: the store's total, plus the fee unless waived, less credit. Null until the order is `placed`, and on a simulated order. A later refund is not netted from it. Show this when you mean what the person paid. | `store_address` is the address of the store `store` names, or null when Layout does not know it. Field names match the webhook payload (`id`, `store_address`, `total_minor`, `created_at`, `placed_at`, `failure_code`), not the camelCase some SDKs default to. Totals are minor units: `2140` is $21.40. A sandbox order built from the menu Layout holds, or at a test restaurant, carries `"simulated": true` from `order.building` on, and one built with `"engine": "real"` from its simulated confirm on; the key is absent on a real order. While `state` is `placing` and the payment waits on the person approving Layout's texted link, the order carries `"challenge": { "sms_sent": true }` (`false` at `test_place_challenge`, which texts nobody). It is absent otherwise. The orders you can read are the ones your application caused: built for a user you provisioned, or placed through an [OAuth client you created](https://docs.layout.link/authentication#oauth-clients). An order belonging to another application is a `404`, never a `403`. ### Pickup time Layout gives the pickup time when the store provides it. To stay reliable it reads the time from more than one place, the store's confirmation screen and then the store's confirmation email, so the time can arrive after the confirm's first answer and after `order.placed`. When it does, you get it too. | What the store did | `ready_eta` | Event | | --- | --- | --- | | Stated a time on its confirmation screen | Set when the order is `placed` | `order.placed` | | Stated a time later, for instance in its email | Null at `placed`, then set | `order.updated` when it arrives | | Never stated a time | Stays null | None for the time | - Show `ready_eta` word for word, such as `Ready by 6:15 PM`. It is not a timestamp: do not parse it, convert it or count down from it. `fulfillment.ready_text` is the same text. - A null `ready_eta` is not an error and not a failure. Say nothing about a time rather than inventing one; the person goes by the store's own confirmation. - On every `order.updated`, read the order again: the time may be what arrived. Several can come for one order (see [More than one order.updated](https://docs.layout.link/events#more-than-one-order-updated)). - When the store's confirmation printed the time in another time zone and Layout could prove which, only the time is corrected to the store's clock. A time Layout cannot check is exactly as stated. - When the time arrives after the order is placed, Layout also texts it to the person, unless you turned off **Order notifications** for your application (see [Texts Layout sends](https://docs.layout.link/mcp#texts-layout-sends)). - A sandbox order answers `Ready in about 15 minutes`. A cart built with ["simulate": "pickup_later"](https://docs.layout.link/sandbox#choose-the-ending) places the order with no time and sends it later, with its own `order.updated`. ### States | State | Meaning | | --- | --- | | `building` | Layout is on the restaurant's site assembling the cart. | | `carted` | Priced and ready. Show the person the total, then confirm it or let them approve it. | | `placing` | They confirmed. Submission is in flight. | | `placed` | It reached the merchant, with evidence. The only state that means the food is coming. | | `failed` | It was not placed. `failure_code` says why: see [Failure codes](https://docs.layout.link/errors#failure-codes). A pending card hold can still show until it drops off, so do not tell the person "nothing was charged": see [Holds on a failed order](https://docs.layout.link/errors#holds-on-a-failed-order). | | `unconfirmed` | Layout cannot tell either way. Do not retry and do not say "failed". | | `refunded` | Deprecated and never sent. A refund leaves `state` as it was: read `refunded_minor`. | To show a person their history, list their orders with [GET /v1/orders?userId=usr_…](#get-v1-orders). To follow what changed, the events feed is `GET /v1/events?user_id=usr_…`; it holds events only for an application with an enabled webhook subscription, and keeps them 30 days. See [GET /v1/events](#get-v1-events). ## GET /v1/events The same events your webhooks receive, oldest first. Useful when your endpoint was down, and as a check that you missed nothing. It holds only the events an enabled subscription listed when they happened, so polling it still needs one; that subscription's endpoint does not have to be up. ```bash curl "https://api.layout.link/v1/events?after=evt_7c21_carted&limit=50" \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" ``` ``` 200 OK { "events": [ { "event": "order.placed", "event_id": "evt_7c21e4b9a0d3_placed", "delivery_id": "dl_351eacf74201", "order": { "id": "ord_7c21e4b9a0d3", "state": "placed", "store": "Layout test kitchen", "store_address": "1 Sandbox Way, San Francisco, CA 94107", "items": 2, "total_minor": 2140, "currency": "usd" }, "user": { "id": "usr_4b8e9a01c2d3" }, "occurred_at": "2026-09-12T17:03:00Z", "sent_at": "2026-09-12T17:03:02Z" } ], "nextCursor": "MjAyNi0wOS0xMlQxNzowMzowMi4wMDBafD…" } ``` | Parameter | Notes | | --- | --- | | `after` | An `event_id` you already handled, or a `nextCursor`. The page starts with the next event after it; for an event that was replayed, after its latest copy. Each `order.updated` wave has its own id, such as `evt_7c21e4b9a0d3_updated_2`, and `after` that id resumes right after that wave. Keep the last `event_id` you processed and pass it here to catch up. | | `cursor` | `nextCursor` from the previous page, to keep walking. Send `cursor` or `after`, not both. | | `since` | Only events Layout recorded at or after this time, as an ISO 8601 timestamp with a zone, e.g. `2026-10-01T00:00:00Z`. | | `order_id` | Only this order's events. | | `user_id` | Only this person's events, by the `usr_` id the payload carries. | | `event` | Only this event type, e.g. `order.failed`. | | `limit` | 1 to 100, default 50. A larger number is capped at 100; 0, or anything that is not a whole number, is `400 invalid_request`. | Filters combine. Each event appears once, however many subscriptions it was sent to. When `nextCursor` is null you have reached the newest event; poll again later with `after` set to the last `event_id` you saw. An `after` that names no event in this application's feed for this environment is `400 invalid_request`, never a walk from the start. So is a `cursor` this feed never wrote. With a sandbox key, a parameter this table does not list is `400 invalid_request` naming the ones it takes, such as `orderId` for `order_id`. A replayed delivery appears again with the same `event_id`, so skip an `event_id` you have already handled. ## GET /v1/usage Every limit your application meets in this key's environment, and what your developer account has spent today against each daily one. It is free and spends nothing. See [Seeing your limits and usage](https://docs.layout.link/limits#seeing-your-usage) for the fields. ```bash curl https://api.layout.link/v1/usage \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" ``` Every answer to a request that authenticated also carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` for your per-credential minute. See [RateLimit headers](https://docs.layout.link/limits#rate-limit-headers). ## POST /v1/sandbox/webhooks/fire Sandbox keys only. Sends one signed test event to every enabled endpoint of your application that subscribes to it, through the real delivery pipeline. Send `{ "event": "order.placed" }`, and optionally an `orderId` of one of your sandbox orders for the event to describe. See [Fire a test webhook](https://docs.layout.link/sandbox#fire-a-test-webhook). --- # MCP tools Every tool the Layout MCP server publishes, with the JSON Schema of its input and its output, read off the server's own registry. The same contract is machine-readable at [mcp-tools.json](https://docs.layout.link/mcp-tools.json). ## Calling a tool The server at `https://mcp.layout.link` speaks MCP over Streamable HTTP and keeps no session: every request is one JSON-RPC 2.0 `POST` that carries its own credential, so `tools/list` and `tools/call` work without a session id. Any MCP client works. To call it by hand, send `Accept: application/json, text/event-stream`. ```bash curl https://mcp.layout.link \ -H "Authorization: Bearer $LAYOUT_GRANT" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "order", "arguments": { "action": "preview", "restaurant": "Layout test kitchen", "query": "Layout test kitchen", "near": "San Francisco, CA" } } }' ``` The answer is `text/event-stream` carrying one `message` event, whose data is the JSON-RPC response: ``` event: message data: {"result":{"content":[{"type":"text","text":"…"},{"type":"text","text":"{\"action\":\"preview\",…}"}],"structuredContent":{"action":"preview","preview":{…}}},"jsonrpc":"2.0","id":1} ``` - **`content`** is text. When Layout has a sentence for the person, the first block is that sentence alone; the last block is the whole result as JSON. - **`structuredContent`** is the same result as an object, and matches the tool's output schema below. - **A refusal is a tool result, not a JSON-RPC error.** It has `isError: true` and comes in one of two forms. A refused action on a tool this session lists is one JSON text block whose `error` is `forbidden` (this session may never make that call), `rate_limited`, `invalid_input`, `unauthorized` or `internal`. A tool this session does not list is answered by the MCP SDK with plain text, `MCP error -32602: Tool not found`, which is not JSON and has no `error` field. Call `tools/list` first and call only the tools it returns. ### Before the call A few answers come before any JSON-RPC runs. They are plain HTTP with the API's error envelope, `{ "error": { "code", "message" } }`, and carry no `jsonrpc`, `result` or event stream: | Status | Body | When, and what to do | | --- | --- | --- | | `401` | `{"error":{"code":"grant_expired","message":"This build grant has expired. Mint a new one with POST /v1/users/{userId}/grant and connect with it."}}` | A build grant that ran out. A grant lasts 15 minutes unless you refresh it before then, so this is the common case. Mint a new one with `POST /v1/users/{userId}/grant`. It carries `WWW-Authenticate: Bearer error="invalid_token", error_description="grant_expired"`. | | `401` | `{"error":{"code":"unauthorized","message":"This build session is not valid. …"}}` | A build grant that was revoked, whose application or person can no longer order, or that does not exist. It carries `WWW-Authenticate: Bearer error="invalid_token"`, with no authorization server to visit. | | `401` | `{"error":{"code":"unauthorized","message":"A secret key (sk_test_ or sk_live_) is not an MCP credential. …"}}` | Your application secret sent as the bearer. A secret key is never an MCP credential. Keep it on your server, mint a build grant with `POST /v1/users/{userId}/grant`, and send its `lgb_` token. It carries `WWW-Authenticate: Bearer error="invalid_token"`. | | `401` | `{"error":{"code":"unauthorized","message":"Missing, invalid or disconnected access token. Authorize with Layout again."}}` | No bearer, one Layout does not recognise, or an OAuth access token that is expired or whose connection ended. Refresh the token, or run consent again. It carries `WWW-Authenticate: Bearer resource_metadata="https://mcp.layout.link/.well-known/oauth-protected-resource", scope="order"`. | | `403` | `{"error":{"code":"forbidden","message":"Connect through https://mcp.layout.link."}}` | The request did not come through Layout's MCP host. Call `https://mcp.layout.link`. | | `500` | `{"error":{"code":"internal","message":"Layout could not answer this request. Retry shortly."}}` | Layout failed while checking an OAuth access token. Nothing ran. Retry with backoff. | | `503` | `{"error":{"code":"unavailable","message":"This build session could not be verified right now. Nothing was ordered. Retry shortly."}}` | Layout could not check the build grant. Nothing ran. Retry with backoff. | | `503` | `{"error":{"code":"unavailable","message":"Layout could not answer this request. Retry shortly."}}` | Layout's edge is unavailable. Nothing ran. Retry with backoff. | ### The order actions `order` carries every step of an order in its `action` argument. Each one is a `tools/call` with these arguments: ```json { "action": "preview", "restaurant": "Layout test kitchen", "query": "Layout test kitchen", "near": "San Francisco, CA" } { "action": "build", "restaurant": "Layout Test Kitchen", "near": "San Francisco, CA", "placeId": "test_place_carted", "query": "a large iced oat latte", "items": ["large iced oat latte"], "idempotencyKey": "7f3c2a9e-build-1" } { "action": "status", "idempotencyKey": "7f3c2a9e-build-1" } { "action": "confirm", "orderId": "0b6f2a54-…", "expectedTotalMinor": , "idempotencyKey": "7f3c2a9e-confirm-1" } { "action": "status", "orderId": "0b6f2a54-…" } ``` Which actions a session may call is in the table under each tool. In that table, `request_code` is a `confirm` with no code, or with `stage: "resend"`; `cancel` is `cancel: true` with an `orderId`; and `cancel_build` is `cancel: true` with the `idempotencyKey` of a build this application started that is still building. [Ordering over MCP](https://docs.layout.link/mcp) walks the flow. A confirm that Layout refuses is not `isError`. It is an ordinary result whose `confirm.status` is `"error"` and whose `confirm.error` names the reason, such as `code_required`, `build_only` or `price_changed`. Branch on `confirm.error`. ### A refused call A production build grant canceling with neither an orderId nor a build's idempotencyKey, which would stop whatever is live on the account and which only a person's own connection may do. The refusal is a tool result, not a JSON-RPC error. Recorded from the server: ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "order", "arguments": { "action": "status", "cancel": true } } } ``` ```json { "result": { "content": [ { "type": "text", "text": "{\"error\":\"forbidden\",\"message\":\"This Layout session can find restaurants, assemble a cart, read its state, confirm it with the 6-digit code Layout texts the person, and release a cart it built, named by its orderId, or by its build's idempotencyKey while it is still building. It cannot reach anything else on this account.\",\"assistantInstruction\":\"This Layout session may not make that call, and that refusal is permanent for it. Nothing was ordered and nothing was charged. Do NOT retry, and do NOT tell the user the order failed.\"}" } ], "isError": true }, "jsonrpc": "2.0", "id": 1 } ``` ### A tool outside the session A production build grant calling menu, a tool its tools/list does not return. The SDK answers with plain text, not JSON, and no error field. Call only tools that tools/list returns. Recorded from the server: ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "menu", "arguments": { "placeId": "ChIJbloomCoffee" } } } ``` ```json { "result": { "content": [ { "type": "text", "text": "MCP error -32602: Tool menu not found" } ], "isError": true }, "jsonrpc": "2.0", "id": 2 } ``` ## `order` Place or preview a food order for the user. Can change state. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | `preview`, `build`, `status`, `request_code`, `confirm`, `cancel`, `cancel_build` | | Sandbox build grant | `preview`, `build`, `status`, `request_code`, `confirm`, `cancel`, `cancel_build` | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `action` | `"preview" \| "build" \| "confirm" \| "status"` | Yes | 'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend). | | `restaurant` | `string` | No | The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business, do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all. | | `query` | `string` | No | preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee'), the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept, 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.) | | `items` | `array of string` | No | build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations, ["grande iced pumpkin cream shaken espresso in a venti cup, extra ice", "grande iced brown sugar oatmilk shaken espresso"]. This does NOT replace `query`, `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not. | | `near` | `string` | No | Where the user is, a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates. | | `geo` | `object` | No | preview: the user's DEVICE coordinates (lat/lng), the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead. | | `platform` | `string` | No | build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one. | | `orderUrl` | `string` | No | build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`. | | `restaurantName` | `string` | No | build only: the resolved restaurant name (from preview), shown in the recap. | | `restaurantAddress` | `string` | No | build only: the resolved restaurant address (from preview), shown in the recap. | | `placeId` | `string` | No | the store's placeId, from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address. | | `blacklistHost` | `string` | No | build only: the resolved site host (from preview's openUrl), for the eligibility check. | | `modifiers` | `string \| array of string` | No | build only: the customizations to apply, size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`. | | `knownStoreId` | `string` | No | build only: a resolved store id, if a location is already chosen. | | `timeZone` | `string` | No | build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone. | | `acceptPickupTime` | `string` | No | build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question. | | `choices` | `string` | No | build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question. | | `userInitiatedRetry` | `boolean` | No | build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked, it exists so a real user re-request isn't mistaken for an automatic retry. | | `cancel` | `boolean` | No | Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back. | | `idempotencyKey` | `string` | No | build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars, never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt. | | `orderId` | `string` | No | confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again. | | `expectedTotalMinor` | `integer` | No | confirm only (REQUIRED): the total the user approved (from the build recap). | | `stage` | `"confirm" \| "resend"` | No | confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code. | | `code` | `string` | No | confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'. | | `declineCredit` | `boolean` | No | confirm only (optional): set true ONLY if the user explicitly says to place WITHOUT their Layout credit. Credit auto-applies and is shown on the card; omit this to keep it. | | `simulate` | `string` | No | Sandbox build sessions only. This session places real orders, so a build that sends it is refused. | | `latency` | `string` | No | Sandbox build sessions only. A build that sends it here is refused. | | `engine` | `string` | No | Sandbox build sessions only. A build that sends it here is refused. | The input JSON Schema: ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "preview", "build", "confirm", "status" ], "description": "'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend)." }, "restaurant": { "type": "string", "maxLength": 200, "description": "The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business — do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all." }, "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee') — the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept — 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.)" }, "items": { "type": "array", "items": { "type": "string", "minLength": 1, "maxLength": 200 }, "maxItems": 10, "description": "build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations — [\"grande iced pumpkin cream shaken espresso in a venti cup, extra ice\", \"grande iced brown sugar oatmilk shaken espresso\"]. This does NOT replace `query` — `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not." }, "near": { "type": "string", "description": "Where the user is — a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "preview: the user's DEVICE coordinates (lat/lng) — the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead." }, "platform": { "type": "string", "maxLength": 100, "description": "build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`." }, "restaurantName": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant name (from preview), shown in the recap." }, "restaurantAddress": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant address (from preview), shown in the recap." }, "placeId": { "type": "string", "maxLength": 255, "description": "the store's placeId — from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address." }, "blacklistHost": { "type": "string", "maxLength": 500, "description": "build only: the resolved site host (from preview's openUrl), for the eligibility check." }, "modifiers": { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ], "description": "build only: the customizations to apply — size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`." }, "knownStoreId": { "type": "string", "description": "build only: a resolved store id, if a location is already chosen." }, "timeZone": { "type": "string", "maxLength": 60, "description": "build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone." }, "acceptPickupTime": { "type": "string", "maxLength": 120, "description": "build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question." }, "choices": { "type": "string", "maxLength": 300, "description": "build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question." }, "userInitiatedRetry": { "type": "boolean", "description": "build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked — it exists so a real user re-request isn't mistaken for an automatic retry." }, "cancel": { "type": "boolean", "description": "Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back." }, "idempotencyKey": { "type": "string", "maxLength": 200, "description": "build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars — never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt." }, "orderId": { "type": "string", "maxLength": 100, "description": "confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again." }, "expectedTotalMinor": { "type": "integer", "exclusiveMinimum": 0, "description": "confirm only (REQUIRED): the total the user approved (from the build recap)." }, "stage": { "type": "string", "enum": [ "confirm", "resend" ], "description": "confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code." }, "code": { "type": "string", "description": "confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'." }, "declineCredit": { "type": "boolean", "description": "confirm only (optional): set true ONLY if the user explicitly says to place WITHOUT their Layout credit. Credit auto-applies and is shown on the card; omit this to keep it." }, "simulate": { "type": "string", "description": "Sandbox build sessions only. This session places real orders, so a build that sends it is refused." }, "latency": { "type": "string", "description": "Sandbox build sessions only. A build that sends it here is refused." }, "engine": { "type": "string", "description": "Sandbox build sessions only. A build that sends it here is refused." } }, "required": [ "action" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string", "description": "Echo of the action performed." }, "preview": { "type": "object", "properties": { "sayToUser": { "type": "string", "description": "Layout's own words for the user — relay this framing." }, "needsLocation": { "type": "boolean", "description": "TRUE means this is a QUESTION, not a result: nothing is being built. Ask which store using sayToUser, and the moment the user picks one, call `order` again with action:'build'." }, "nextAction": { "type": "string", "description": "The tool to call once the user answers (e.g. 'order' with action:'build')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Says exactly what to call once they pick a store. Never read it aloud." }, "allClosed": { "type": "boolean", "description": "Every branch Layout can see is CLOSED right now. The which-one question has no true answer — do not ask it. Offer `openAlternatives` instead." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" }, "placeId": { "type": "string" }, "distanceKm": { "type": "number" }, "openNow": { "type": "boolean" }, "closingSoon": { "type": "boolean" } }, "additionalProperties": true }, "description": "REAL places Layout CHECKED are open right now, nearest first — offered when the restaurant they asked for is closed, or when its nearest open branch is a drive away. Offer these BY NAME and build at whichever the user picks. NEVER substitute a restaurant from your own memory: you cannot know whether it is open, still in business, or even nearby." }, "closedNote": { "type": "string", "description": "Layout's words for a resolved store that is closed right now. Relay it and STOP — do not build there unless the user says to anyway." }, "closingSoonNote": { "type": "string", "description": "The store is OPEN and shuts too soon for an order to be placed and made in time, so Layout will refuse the build. Relay it and STOP. Do NOT say they are closed: they are open, and the limit is Layout's. Do not take an item for this store; offer to find somewhere open longer." }, "notOrderableNote": { "type": "string", "description": "Layout READ this restaurant's own website and there is no way to place an order on it. Relay it and STOP. Unlike a closure this is final — do not build there even if the user asks again, and do not ask what they would like from it. Offer to find somewhere nearby that does instead." }, "nearestOpen": { "$ref": "#/properties/preview/properties/openAlternatives/items", "description": "The nearest branch of the SAME brand that Layout CHECKED is open right now, when the one they named is closed. Offer this BEFORE `openAlternatives` and say the distance: they asked for this restaurant, and another branch of it beats a different one." } }, "additionalProperties": true }, "build": { "type": "object", "properties": { "status": { "type": "string", "description": "'building' = still working, keep polling. 'carted' = a REAL priced cart is ready — show confirmationCard verbatim. 'reached_bag_only' = the cart could NOT be verified: nothing was ordered or charged; never present items/prices as confirmed. 'closed_next_day' = their site isn't offering pickup today; relay userMessage and let the user decide. 'pickup_next_day' = the earliest pickup is a DIFFERENT DAY than the user's today, so the build STOPPED: nothing was ordered, there is no cart and nothing to confirm — relay userMessage, and only if they say yes, build again passing pickupDecision.stated as acceptPickupTime. 'needs_choices' = the restaurant REQUIRES a choice on this item (plate or bowl, which rice) that nobody has made, so the build stopped before spending anything: relay userMessage, offer exactly the options in choiceDecision.groups, and when the user answers, build again with a NEW idempotencyKey passing their answer as `choices`. Any other value: relay userMessage — do not improvise an explanation." }, "choiceDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the restaurant requires a choice before this item can be ordered." }, "item": { "type": "string", "description": "The RESTAURANT's own name for what they asked for. Use this name, not the user's words." }, "itemNamedBySite": { "type": "boolean", "description": "True when `item` is the restaurant's own name for it, false when it is the user's words." }, "question": { "type": "string", "description": "Layout's question, built from their menu. Relay it rather than composing your own." }, "groups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. Offer ONLY these; never options you remember." } }, "additionalProperties": true } }, "answerWith": { "type": "object", "properties": { "action": { "type": "string", "description": "The action to call with their answer. Always 'build'." }, "field": { "type": "string" } }, "additionalProperties": true, "description": "How an answer is expressed: a NEW build passing the user's reply as `choices`." } }, "additionalProperties": true, "description": "Present on 'needs_choices'. The choices this restaurant requires before the item can be ordered." }, "pickupDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the user has to agree to this pickup time before anything is ordered." }, "stated": { "type": [ "string", "null" ], "description": "The earliest pickup, in the STORE's own words. Quote it verbatim; never convert the time." }, "day": { "type": "string", "description": "'different' when the earliest pickup is not the user's today." }, "basis": { "type": "string", "description": "How the pickup time was established. Diagnostic; not something to read out." }, "timeZone": { "type": "string", "description": "The STORE's zone. Present so nobody converts `stated` into the user's." }, "question": { "type": "string", "description": "Layout's question for the user. Relay it rather than composing your own." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true }, "description": "The choices Layout's own clients draw as buttons." }, "acceptWith": { "type": "object", "properties": { "action": { "type": "string" }, "field": { "type": "string" }, "value": { "type": [ "string", "null" ] } }, "additionalProperties": true, "description": "How a 'yes' is expressed: a NEW build passing this value as acceptPickupTime. There is no order to confirm." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } }, "additionalProperties": true }, "description": "Places VERIFIED open right now. Offer only these; never a restaurant from your own memory." } }, "additionalProperties": true, "description": "Present on 'pickup_next_day'. The pickup time the user has to agree to before anything is ordered." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "confirmationCard": { "type": "string", "description": "Layout's branded confirmation. Show VERBATIM — never rewrite, reorder, or summarize." }, "complete": { "type": "boolean", "description": "FALSE means this is not the final answer and no cart exists yet. Do not present a false result as an outcome: follow `nextAction`/`assistantInstruction` instead of stopping here." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME idempotencyKey." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. When present, it says exactly what to do next and takes precedence over anything you would infer from `status`. Never read it aloud to the user." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for THIS order. Give it to the user while they wait — it works even if this conversation ends, and it is the only thing they can watch on their own." }, "actionUrl": { "type": "string", "description": "A link the USER must open to unblock the order (e.g. update a payment method). When present, give it to them — the order cannot continue until they do." } }, "additionalProperties": true }, "placement": { "type": "object", "properties": { "status": { "type": "string", "description": "'placed' is the ONLY status that means the order exists — never tell the user an order was placed on any other value. 'placing' = in progress, keep polling. 'failed' = NOT placed, nothing charged (relay userMessage). 'challenge' = the user was texted a secure approval link. 'unconfirmed' = Layout could not verify either way — tell the user to check before re-ordering; claim neither success nor failure. 'carted' (build sessions) = a ready cart nobody has confirmed: it waits on the person, so stop polling; its `totalMinor` is what a confirm sends." }, "userMessage": { "type": "string" }, "orderNumber": { "type": [ "string", "number" ] }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running and this is NOT the outcome. Follow `nextAction`/`assistantInstruction`; never describe a false result as placed or failed." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME orderId." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over anything you would infer from `status`." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." } }, "additionalProperties": true }, "publicOrderId": { "type": "string", "description": "The developer-facing `ord_` id of this order, for the app's own `GET /v1/orders/:id` and webhooks. Present only when an application's own client drove the order. Never read it aloud." }, "refusal": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "build_limit", "unavailable", "forbidden", "menu_not_held" ], "description": "The same code the REST API answers with. `menu_not_held` (sandbox): Layout holds no menu for this place, so build at a place `places` lists." }, "retryable": { "type": "boolean", "description": "True when the same call may succeed shortly. False on a spent daily ceiling: do not retry before limit.resetsAt." }, "limit": { "type": "object", "properties": { "name": { "type": "string", "const": "builds" }, "scope": { "type": "string", "enum": [ "person", "application", "account", "platform" ] }, "max": { "type": "integer" }, "resetsAt": { "type": "string" } }, "required": [ "name", "scope", "max", "resetsAt" ], "additionalProperties": false, "description": "The daily ceiling that refused the build." } }, "required": [ "code", "retryable" ], "additionalProperties": false, "description": "Present when Layout refused to start a build: nothing was built or charged. For code that does not read userMessage. Never read it aloud." }, "cancel": { "type": "object", "properties": { "status": { "type": "string", "description": "'canceled' = the stop is real and complete; relay userMessage and do NOT confirm or poll this order again. 'already_placing' = TOO LATE to cancel — the placement is in flight; poll `order_status` and report only what it returns. 'placed' = the order already went to the restaurant; never describe it as canceled. 'no_active_order' = there was nothing to cancel." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." } }, "additionalProperties": true }, "confirm": { "type": "object", "properties": { "status": { "type": "string", "description": "'placing' = the order is being placed RIGHT NOW; it is NOT placed; poll `order_status` with the orderId. 'code_sent' = a code was texted (in sandbox nothing is texted); ask the user to read it back. 'confirmed' = the cart is locked in. 'confirm_expired' = relay userMessage; nothing was placed. 'place_error' = relay userMessage and follow assistantInstruction; the outcome can be unknown, so poll `order_status`. 'error' = Layout refused the confirm or did not go ahead: the reason is in `error` (code_required, build_only, price_changed, send_failed, texts_blocked, rate_limited, bad_code, ...). Relay userMessage and follow assistantInstruction. Nothing was placed or charged, except on error 'confirm_failed' or 'in_progress', where a placement may be running: poll `order_status`, never confirm again." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "orderId": { "type": "string", "description": "Pass this to `order_status` to follow the placement." }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running — keep polling; do not report an outcome." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." }, "alreadyConfirmed": { "type": "boolean", "description": "TRUE means this order was ALREADY confirmed on another surface (the person replied YES to Layout's text, confirmed in the Layout app, or another call did). Nothing was confirmed or charged again. Do NOT confirm again and do NOT build again; relay userMessage and report `orderState` only." }, "orderState": { "type": "string", "description": "With alreadyConfirmed: the order's real state now. 'placing' is NOT placed and 'unconfirmed' is NOT failed (Layout cannot yet tell whether the restaurant received it); for both, poll `order_status`. 'placed' is placed. 'refunded' was charged and then refunded; it may never have reached the restaurant." }, "confirmedVia": { "type": "string", "description": "With alreadyConfirmed, when Layout knows: 'sms' (the person replied YES to Layout's text) or 'tracking_link' (Layout's tracking page)." } }, "additionalProperties": true } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `order_status` Check on an order that is already running. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | Yes | | Sandbox build grant | Yes | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `idempotencyKey` | `string` | No | The key used for the 'build' call, to follow that cart. | | `orderId` | `string` | No | The order id from a 'confirm' that returned 'placing', to follow the placement. The `ord_` publicOrderId works too. | The input JSON Schema: ```json { "type": "object", "properties": { "idempotencyKey": { "type": "string", "maxLength": 200, "description": "The key used for the 'build' call, to follow that cart." }, "orderId": { "type": "string", "maxLength": 100, "description": "The order id from a 'confirm' that returned 'placing', to follow the placement. The `ord_` publicOrderId works too." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string", "description": "Echo of the action performed." }, "build": { "type": "object", "properties": { "status": { "type": "string", "description": "'building' = still working, keep polling. 'carted' = a REAL priced cart is ready — show confirmationCard verbatim. 'reached_bag_only' = the cart could NOT be verified: nothing was ordered or charged; never present items/prices as confirmed. 'closed_next_day' = their site isn't offering pickup today; relay userMessage and let the user decide. 'pickup_next_day' = the earliest pickup is a DIFFERENT DAY than the user's today, so the build STOPPED: nothing was ordered, there is no cart and nothing to confirm — relay userMessage, and only if they say yes, build again passing pickupDecision.stated as acceptPickupTime. 'needs_choices' = the restaurant REQUIRES a choice on this item (plate or bowl, which rice) that nobody has made, so the build stopped before spending anything: relay userMessage, offer exactly the options in choiceDecision.groups, and when the user answers, build again with a NEW idempotencyKey passing their answer as `choices`. Any other value: relay userMessage — do not improvise an explanation." }, "choiceDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the restaurant requires a choice before this item can be ordered." }, "item": { "type": "string", "description": "The RESTAURANT's own name for what they asked for. Use this name, not the user's words." }, "itemNamedBySite": { "type": "boolean", "description": "True when `item` is the restaurant's own name for it, false when it is the user's words." }, "question": { "type": "string", "description": "Layout's question, built from their menu. Relay it rather than composing your own." }, "groups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. Offer ONLY these; never options you remember." } }, "additionalProperties": true } }, "answerWith": { "type": "object", "properties": { "action": { "type": "string", "description": "The action to call with their answer. Always 'build'." }, "field": { "type": "string" } }, "additionalProperties": true, "description": "How an answer is expressed: a NEW build passing the user's reply as `choices`." } }, "additionalProperties": true, "description": "Present on 'needs_choices'. The choices this restaurant requires before the item can be ordered." }, "pickupDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the user has to agree to this pickup time before anything is ordered." }, "stated": { "type": [ "string", "null" ], "description": "The earliest pickup, in the STORE's own words. Quote it verbatim; never convert the time." }, "day": { "type": "string", "description": "'different' when the earliest pickup is not the user's today." }, "basis": { "type": "string", "description": "How the pickup time was established. Diagnostic; not something to read out." }, "timeZone": { "type": "string", "description": "The STORE's zone. Present so nobody converts `stated` into the user's." }, "question": { "type": "string", "description": "Layout's question for the user. Relay it rather than composing your own." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true }, "description": "The choices Layout's own clients draw as buttons." }, "acceptWith": { "type": "object", "properties": { "action": { "type": "string" }, "field": { "type": "string" }, "value": { "type": [ "string", "null" ] } }, "additionalProperties": true, "description": "How a 'yes' is expressed: a NEW build passing this value as acceptPickupTime. There is no order to confirm." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } }, "additionalProperties": true }, "description": "Places VERIFIED open right now. Offer only these; never a restaurant from your own memory." } }, "additionalProperties": true, "description": "Present on 'pickup_next_day'. The pickup time the user has to agree to before anything is ordered." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "confirmationCard": { "type": "string", "description": "Layout's branded confirmation. Show VERBATIM — never rewrite, reorder, or summarize." }, "complete": { "type": "boolean", "description": "FALSE means this is not the final answer and no cart exists yet. Do not present a false result as an outcome: follow `nextAction`/`assistantInstruction` instead of stopping here." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME idempotencyKey." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. When present, it says exactly what to do next and takes precedence over anything you would infer from `status`. Never read it aloud to the user." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for THIS order. Give it to the user while they wait — it works even if this conversation ends, and it is the only thing they can watch on their own." }, "actionUrl": { "type": "string", "description": "A link the USER must open to unblock the order (e.g. update a payment method). When present, give it to them — the order cannot continue until they do." } }, "additionalProperties": true }, "placement": { "type": "object", "properties": { "status": { "type": "string", "description": "'placed' is the ONLY status that means the order exists — never tell the user an order was placed on any other value. 'placing' = in progress, keep polling. 'failed' = NOT placed, nothing charged (relay userMessage). 'challenge' = the user was texted a secure approval link. 'unconfirmed' = Layout could not verify either way — tell the user to check before re-ordering; claim neither success nor failure. 'carted' (build sessions) = a ready cart nobody has confirmed: it waits on the person, so stop polling; its `totalMinor` is what a confirm sends." }, "userMessage": { "type": "string" }, "orderNumber": { "type": [ "string", "number" ] }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running and this is NOT the outcome. Follow `nextAction`/`assistantInstruction`; never describe a false result as placed or failed." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME orderId." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over anything you would infer from `status`." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." } }, "additionalProperties": true }, "publicOrderId": { "type": "string", "description": "The developer-facing `ord_` id of this order, for the app's own `GET /v1/orders/:id` and webhooks. Present only when an application's own client drove the order. Never read it aloud." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `get_location` Get the user's location when you don't already have it, use this the moment they ask for something NEARBY ('near me', 'around here', 'closest one') and you have no coordinates and they named no place. Can change state. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input No arguments. The input JSON Schema: ```json { "type": "object", "properties": {}, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "located": { "type": "boolean" }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false }, "shareUrl": { "type": "string" }, "userMessage": { "type": "string" }, "assistantInstruction": { "type": "string" } }, "required": [ "located" ], "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `profile` Read or update the user's Layout food profile, the preferences Layout applies to every order: dietary restrictions ('vegetarian', 'no shellfish'), favorite restaurants, usual orders, the first name orders go under, and whether dietary restrictions are applied automatically. Can change state. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `action` | `"get" \| "update"` | Yes | 'get' = read the profile. 'update' = change it (needs at least one field below). | | `firstName` | `string` | No | update: the first name orders are placed under. | | `applyDietaryRestrictions` | `boolean` | No | update: whether Layout automatically applies the user's dietary notes when ordering. | | `addPreferences` | `array of object` | No | update: preference notes to save (max 5 per call). | | `removePreferenceIds` | `array of string` | No | update: ids (from 'get') of notes to remove. | The input JSON Schema: ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "get", "update" ], "description": "'get' = read the profile. 'update' = change it (needs at least one field below)." }, "firstName": { "type": "string", "maxLength": 60, "description": "update: the first name orders are placed under." }, "applyDietaryRestrictions": { "type": "boolean", "description": "update: whether Layout automatically applies the user's dietary notes when ordering." }, "addPreferences": { "type": "array", "items": { "type": "object", "properties": { "category": { "type": "string", "description": "'dietary' | 'favorite_restaurants' | 'usual_orders' (anything else is filed as other)." }, "note": { "type": "string", "maxLength": 200, "description": "The preference in the user's own words, e.g. 'no shellfish'." } }, "required": [ "category", "note" ], "additionalProperties": false }, "maxItems": 5, "description": "update: preference notes to save (max 5 per call)." }, "removePreferenceIds": { "type": "array", "items": { "type": "string" }, "maxItems": 10, "description": "update: ids (from 'get') of notes to remove." } }, "required": [ "action" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string" }, "profile": { "type": "object", "properties": { "firstName": { "type": [ "string", "null" ] }, "applyDietaryRestrictions": { "type": "boolean" }, "preferences": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "category": { "type": "string" }, "note": { "type": "string" } }, "required": [ "id", "category", "note" ], "additionalProperties": true }, "description": "The saved notes. Show notes, not ids; ids are only for removal." } }, "additionalProperties": true }, "sayToUser": { "type": "string", "description": "Layout's own wording for the user — relay it." }, "manageUrl": { "type": "string", "description": "The user's full account page (contact info, addresses, cards live there — share this link when asked)." }, "techWeek": { "type": "object", "properties": { "state": { "type": "string" }, "amount": { "type": "string" }, "window": { "type": "string" }, "terms": { "type": "string" }, "signupUrl": { "type": "string" }, "termsUrl": { "type": "string" }, "sayToUser": { "type": "string" }, "assistantInstruction": { "type": "string" } }, "required": [ "state", "amount", "window", "terms", "signupUrl", "termsUrl", "sayToUser", "assistantInstruction" ], "additionalProperties": true, "description": "This account's SF Tech Week coffee and the published terms. Present only while the promotion runs." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `discover` Recommend WHERE the user should eat, drawn from what they have actually ordered through Layout. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `walkable` | `boolean` | No | They want somewhere they can WALK to ('walking distance', 'on foot'). Nothing past a short walk is suggested. | | `query` | `string` | No | The kind of food they asked for ('healthy', 'tacos'). Used only when nothing they would like is walkable and Layout searches what is. | | `geo` | `object` | No | The user's device coordinates, when your client can read them. Without them Layout uses the last place it knows they ordered from, which is usually right, so this is worth passing but never required. | | `near` | `string` | No | A city, town or ZIP the user NAMED ('Roseville', 'downtown Oakland', '95661'). Pass it whenever they say where they are or where they're asking about; it is resolved for free and beats the last place Layout saw them order. | | `openNow` | `boolean` | No | Only places open right now. Use it when the ask is about eating NOW ('lunch', 'right now'). | | `includeVisited` | `boolean` | No | Include places they already order from. Off by default, because a suggestion should be somewhere new, turn it on when they ask for a usual or a favourite. | | `limit` | `integer` | No | How many to return. Default 5; lead with one regardless. | The input JSON Schema: ```json { "type": "object", "properties": { "walkable": { "type": "boolean", "description": "They want somewhere they can WALK to ('walking distance', 'on foot'). Nothing past a short walk is suggested." }, "query": { "type": "string", "description": "The kind of food they asked for ('healthy', 'tacos'). Used only when nothing they would like is walkable and Layout searches what is." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "The user's device coordinates, when your client can read them. Without them Layout uses the last place it knows they ordered from, which is usually right — so this is worth passing but never required." }, "near": { "type": "string", "description": "A city, town or ZIP the user NAMED ('Roseville', 'downtown Oakland', '95661'). Pass it whenever they say where they are or where they're asking about; it is resolved for free and beats the last place Layout saw them order." }, "openNow": { "type": "boolean", "description": "Only places open right now. Use it when the ask is about eating NOW ('lunch', 'right now')." }, "includeVisited": { "type": "boolean", "description": "Include places they already order from. Off by default, because a suggestion should be somewhere new — turn it on when they ask for a usual or a favourite." }, "limit": { "type": "integer", "minimum": 1, "maximum": 12, "description": "How many to return. Default 5; lead with one regardless." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "suggestions": { "type": "array", "items": { "type": "object", "properties": { "place": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": [ "string", "null" ] }, "cuisine": { "type": [ "string", "null" ] }, "rating": { "type": [ "number", "null" ] }, "openStatus": { "type": "string", "enum": [ "open", "closed", "unknown" ], "description": "READ THIS ONE. 'open' / 'closed' at the STORE's own clock right now; 'unknown' means Layout holds no hours for it, which is NOT closed — say you don't know." }, "closesAt": { "type": [ "string", "null" ], "description": "Store-local closing time when open, e.g. '5:00 PM'. Say it: 'open until 5' is the useful answer." }, "opensAt": { "type": [ "string", "null" ], "description": "Store-local next opening when closed, e.g. '7:00 AM' or 'Monday 7:00 AM'." }, "openNow": { "type": [ "boolean", "null" ], "description": "The same answer as a boolean, for older clients. Prefer `openStatus`: a null here means unknown and is easy to misread as closed." }, "closingSoon": { "type": "boolean", "description": "Open, and shutting within the hour. Say so: Layout stops taking orders shortly before a store closes, so this is the last window to place one." }, "distanceKm": { "type": [ "number", "null" ] } }, "required": [ "name" ], "additionalProperties": true }, "reason": { "type": "string", "description": "Layout's computed reason. Relay it; do not rewrite it." }, "becauseOf": { "type": "object", "properties": { "places": { "type": "array", "items": { "type": "string" }, "description": "Restaurants they have actually ordered from that led here." }, "items": { "type": "array", "items": { "type": "string" }, "description": "Items they have actually ordered that led here." } }, "required": [ "places", "items" ], "additionalProperties": true }, "visited": { "type": "boolean" } }, "required": [ "place", "reason", "becauseOf" ], "additionalProperties": true } }, "nothingBecause": { "type": [ "string", "null" ], "description": "'no_orders' | 'no_matches' — why the list is empty. They need different sentences." }, "sayToUser": { "type": "string", "description": "Layout's own wording — relay it." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `places` Find restaurants, cafes and coffee shops BY DESCRIPTION OR NAME, 'is there a Starbucks in Oakland', 'coffee near me', 'somewhere open for breakfast', 'taco places downtown'. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | Yes | | Sandbox build grant | Yes | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | Yes | What to look for, a name ('Starbucks'), a food ('tacos'), or a kind of place ('coffee shop'). | | `near` | `string` | No | The RESOLVED locality, a real city and state, e.g. 'Oakland, CA' or 'San Francisco, CA'. YOU resolve it: if the user names a landmark, a neighbourhood, an airport or an abbreviation ('Salesforce Tower', 'the Mission', 'SFO', 'sf'), work out which city that is and pass THAT here. Layout matches this against the city each restaurant is in, so a landmark passed here matches nothing. Pass it whenever they said where they're asking about, a named place is where they are asking ABOUT, which is not always where they are. | | `nearRaw` | `string` | No | The user's OWN words for the place, verbatim, 'Salesforce Tower', 'the Mission', 'near the ballpark'. Pass it alongside `near` whenever they said something more specific than a city. If Layout has to go and look the area up, this is what it asks with, and it resolves landmarks far better than a city name alone. Omit when they only ever named a city. | | `geo` | `object` | No | The user's device coordinates, when your client can read them. Used to rank by real distance and to bound 'near me'. | | `openNow` | `boolean` | No | Only places open right now, judged at the STORE's own clock. Use it when the ask is about eating now. | | `limit` | `integer` | No | How many to return. Default 5. | The input JSON Schema: ```json { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "What to look for — a name ('Starbucks'), a food ('tacos'), or a kind of place ('coffee shop')." }, "near": { "type": "string", "maxLength": 200, "description": "The RESOLVED locality — a real city and state, e.g. 'Oakland, CA' or 'San Francisco, CA'. YOU resolve it: if the user names a landmark, a neighbourhood, an airport or an abbreviation ('Salesforce Tower', 'the Mission', 'SFO', 'sf'), work out which city that is and pass THAT here. Layout matches this against the city each restaurant is in, so a landmark passed here matches nothing. Pass it whenever they said where they're asking about — a named place is where they are asking ABOUT, which is not always where they are." }, "nearRaw": { "type": "string", "maxLength": 200, "description": "The user's OWN words for the place, verbatim — 'Salesforce Tower', 'the Mission', 'near the ballpark'. Pass it alongside `near` whenever they said something more specific than a city. If Layout has to go and look the area up, this is what it asks with, and it resolves landmarks far better than a city name alone. Omit when they only ever named a city." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "The user's device coordinates, when your client can read them. Used to rank by real distance and to bound 'near me'." }, "openNow": { "type": "boolean", "description": "Only places open right now, judged at the STORE's own clock. Use it when the ask is about eating now." }, "limit": { "type": "integer", "minimum": 1, "maximum": 10, "description": "How many to return. Default 5." } }, "required": [ "query" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "places": { "type": "array", "items": { "type": "object", "properties": { "placeId": { "type": "string" }, "name": { "type": "string" }, "address": { "type": [ "string", "null" ] }, "city": { "type": [ "string", "null" ] }, "cuisine": { "type": [ "string", "null" ] }, "rating": { "type": [ "number", "null" ] }, "priceLevel": { "type": [ "number", "null" ] }, "openStatus": { "type": "string", "enum": [ "open", "closed", "unknown" ], "description": "READ THIS ONE. 'open' / 'closed' at the STORE's own clock right now; 'unknown' means Layout holds no hours for it, which is NOT closed — say you don't know its hours." }, "closesAt": { "type": [ "string", "null" ], "description": "Store-local closing time when open, e.g. '5:00 PM'. Say it: 'open until 5' is the useful answer." }, "opensAt": { "type": [ "string", "null" ], "description": "Store-local next opening when closed, e.g. '7:00 AM' or 'Monday 7:00 AM'." }, "openNow": { "type": [ "boolean", "null" ], "description": "The same answer as a boolean, for older clients. Prefer `openStatus`: a null here means unknown and is easy to misread as closed." }, "closingSoon": { "type": "boolean", "description": "Open, and shutting within the hour. Say so: Layout stops taking orders shortly before a store closes, so this is the last window to place one." }, "distanceKm": { "type": [ "number", "null" ], "description": "Straight-line km from the coordinates you passed. Absent when you passed none." } }, "required": [ "placeId", "name" ], "additionalProperties": true } }, "notOpenNow": { "type": "array", "items": { "$ref": "#/properties/places/items" }, "description": "Real restaurants Layout holds in that area that are NOT open right now, soonest to open first. Present ONLY when the open-now filter is what emptied `places`: it means Layout searched and FOUND places, so an empty `places` beside it is never 'there is nothing there'. These are NOT offerable — read each row's `openStatus` ('closed' with `opensAt` for when it reopens, 'unknown' when Layout holds no hours) and never offer to order from one now." }, "emptyBecause": { "type": "string", "const": "no_held_menu", "description": "Sandbox only: Layout holds matching places but the menu of none of them, and a sandbox cart is built from a held menu. Not a finding that nothing is there." }, "stillChecking": { "type": "boolean", "description": "Layout found places in that area and has not finished working out whether they take online orders — that runs after this answer and usually lands within a minute. The list may grow shortly, though it is not certain it will. When this is true, an EMPTY list is NOT a finding that there is nothing there: never say there is nothing, say you are still checking and offer to look again in a moment." }, "sayToUser": { "type": "string", "description": "Layout's own wording — relay it." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `place_facts` Answer an EVALUATIVE question about ONE restaurant you already have a `placeId` for, 'is it vegetarian', 'can I sit down there', 'do they do takeout', 'is it good for a group', 'do they serve breakfast'. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `placeId` | `string` | Yes | The Google place id from a places, discover, or order result. Required, this tool never resolves a name. | The input JSON Schema: ```json { "type": "object", "properties": { "placeId": { "type": "string", "minLength": 1, "maxLength": 400, "description": "The Google place id from a places, discover, or order result. Required — this tool never resolves a name." } }, "required": [ "placeId" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "attributes": { "anyOf": [ { "type": "object", "properties": { "source": { "type": "string" }, "asOf": { "type": "string" }, "dietary": { "type": "object", "properties": {}, "additionalProperties": true }, "service": { "type": "object", "properties": {}, "additionalProperties": true }, "goodFor": { "type": "object", "properties": {}, "additionalProperties": true } }, "additionalProperties": true }, { "type": "null" } ] }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. It says what these fields do and do not let you claim. Never read it aloud." } }, "required": [ "attributes" ], "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `help` Answer a question ABOUT LAYOUT ITSELF from Layout's own published help centre, refunds, when and how the user is charged, spending limits, changing or cancelling an order, what happens when an order is wrong or late, card safety, what data Layout stores, profile and dietary preferences, where Layout can order from, and how to set Layout up in an assistant or on a phone. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `question` | `string` | Yes | What the user wants to know, in their own words or as keywords, 'how do refunds work', 'when am I charged', 'can I cancel an order'. | The input JSON Schema: ```json { "type": "object", "properties": { "question": { "type": "string", "minLength": 1, "maxLength": 300, "description": "What the user wants to know, in their own words or as keywords — 'how do refunds work', 'when am I charged', 'can I cancel an order'." } }, "required": [ "question" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "articles": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string" }, "url": { "type": "string", "description": "The public help centre page. GIVE THIS TO THE USER with your answer." }, "excerpt": { "type": "string", "description": "The part of the article about their question. Answer from this and nothing else." } }, "required": [ "title", "url", "excerpt" ], "additionalProperties": true } }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. It says what you may and may not assert. Never read it aloud." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `menu` Look up something SPECIFIC on a restaurant's LIVE menu, read from their own ordering site, so items and prices are real, not from memory. Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | Yes | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | No | Restaurant name to look up (with `near` or `geo` for locality). | | `placeId` | `string` | No | A specific location's place id, when one is already settled, from an earlier order preview or a location the user already picked. Skips the which-location question. | | `near` | `string` | No | The user's area, to pick the right location. | | `geo` | `object` | No | Device coordinates, the strongest locality signal. If you do not have them and your client can request device location permission, do that first and pass the result here; otherwise use `near`. | | `orderUrl` | `string` | No | A specific location's ordering URL from an earlier preview, skips name resolution. | | `category` | `string` | No | A section name from a previous 'categories' answer, EXACTLY as given, reads just that section. | | `intent` | `string` | No | The user's FULL request in their own words (e.g. 'what's under $2 at McDonald's'). Always pass it, it's used only if the live read fails, so the web-knowledge fallback answers exactly what they asked. | | `mode` | `"live" \| "knowledge"` | No | Defaults to 'live' (reads their real site: ~a minute, a real browsing session). Pass 'knowledge' for RECOMMENDATIONS, 'what should I get at X', 'what are they known for', where item names answer the question and an exact live price does not. Knowledge mode returns in seconds and never opens a browser; it is a metered web lookup, so ask it once per restaurant. | The input JSON Schema: ```json { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Restaurant name to look up (with `near` or `geo` for locality)." }, "placeId": { "type": "string", "maxLength": 255, "description": "A specific location's place id, when one is already settled — from an earlier order preview or a location the user already picked. Skips the which-location question." }, "near": { "type": "string", "maxLength": 300, "description": "The user's area, to pick the right location." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "Device coordinates — the strongest locality signal. If you do not have them and your client can request device location permission, do that first and pass the result here; otherwise use `near`." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "A specific location's ordering URL from an earlier preview — skips name resolution." }, "category": { "type": "string", "maxLength": 80, "description": "A section name from a previous 'categories' answer, EXACTLY as given — reads just that section." }, "intent": { "type": "string", "maxLength": 400, "description": "The user's FULL request in their own words (e.g. 'what's under $2 at McDonald's'). Always pass it — it's used only if the live read fails, so the web-knowledge fallback answers exactly what they asked." }, "mode": { "type": "string", "enum": [ "live", "knowledge" ], "description": "Defaults to 'live' (reads their real site: ~a minute, a real browsing session). Pass 'knowledge' for RECOMMENDATIONS — 'what should I get at X', 'what are they known for' — where item names answer the question and an exact live price does not. Knowledge mode returns in seconds and never opens a browser; it is a metered web lookup, so ask it once per restaurant." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "status": { "type": "string", "description": "'menu' = a live read of their menu; present it as such. 'reading' = still being read; relay sayToUser ONLY if present (say nothing otherwise), then call again immediately with the same arguments — the call attaches to the read in flight and waits with it. 'categories' = their site keeps prices one level down; relay sayToUser and list the `categories` cleanly (short lines, no JSON) and, once the user picks, call again with the SAME arguments plus `category` set to their choice. A question that spans the WHOLE menu ('what is under $3?') CANNOT be answered from one section — say so plainly and let them pick a section to check, or offer to order a specific item directly. NEVER re-call this tool with the same arguments hoping for a different result: each call is a real, billed browse of their site, and a repeat gets you the same answer. 'unreadable' = Layout could not read the menu cleanly — do NOT invent or fill in items; relay sayToUser (ordering directly still works). 'knowledge' = the live menu couldn't be read, so `knowledge` holds a WEB-lookup answer to the user's request — relay sayToUser, present it as general/non-live background (prices vary, may be out of date), never as their live menu. 'needs_location' = ask the user which location, offering the `nearby` choices. Any other value: relay sayToUser." }, "restaurant": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": [ "string", "null" ] } }, "additionalProperties": true }, "menu": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "priceMinor": { "type": "number", "description": "Price in cents, from their site." }, "description": { "type": "string" }, "category": { "type": "string" }, "optionGroups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean", "description": "TRUE = their site will not let this item be ordered until it is answered." }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. If you offer choices, offer THESE and nothing else." }, "moreOptions": { "type": "number", "description": "Options beyond the ones listed — say the list is partial rather than implying it is all of them." }, "defaults": { "type": "array", "items": { "type": "string" }, "description": "What the item already comes with, written as in options. The group is chosen: keep it unless the user asks to change it." } }, "additionalProperties": true }, "description": "The choices this item carries, in the restaurant's words. THE FIELD BEING ABSENT IS NOT 'no choices': it means Layout could not read whether this item has any, so do not tell the user it needs none and do not invent a question. An EMPTY array does mean none." } }, "required": [ "name" ], "additionalProperties": true } } }, "additionalProperties": true }, "confidence": { "type": "string", "description": "'partial' = may not be the complete menu; say so when presenting." }, "categories": { "type": "array", "items": { "type": "string" }, "description": "On 'categories': the sections their site offers. Offer these to the user; pass their choice back as `category`." }, "pricesUnavailable": { "type": "boolean", "description": "True = real items, but their site gave no prices. Do NOT supply prices from memory; say they weren't available." }, "scope": { "type": "string", "description": "'chain' = this location's own menu isn't read yet or couldn't be read, so these are the items most of the restaurant's locations carry, with no prices because prices vary by location. Relay sayToUser; never present it as this location's exact menu." }, "knowledge": { "type": "string", "description": "On 'knowledge': a NON-LIVE, web-lookup answer to the user's request. Relay via sayToUser; never present it as the live menu or as exact current prices." }, "live": { "type": "boolean", "description": "false on a 'knowledge' answer — the info is general and may be out of date." }, "sayToUser": { "type": "string", "description": "Layout's own wording for the user — relay it. On 'reading' this may be absent, which means stay quiet and just poll again." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `item_photo` Show what ONE menu item at a restaurant looks like, using the photo the restaurant itself published (screened, and never a stock or AI image). Read-only. Safe to repeat. | Session | Can call | | --- | --- | | OAuth connection | Yes | | Production build grant | No | | Sandbox build grant | No | ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `item` | `string` | Yes | The menu item, in the user's words. | | `query` | `string` | No | Restaurant name (with `near` or `geo` for locality). | | `placeId` | `string` | No | A location's place id already settled earlier in the thread. | | `near` | `string` | No | The user's area. | | `geo` | `object` | No | Device coordinates, when you have them. | | `orderUrl` | `string` | No | A location's ordering URL from an earlier preview. | | `storedOnly` | `boolean` | No | Leave unset. Layout's own follow-up on a pending photo. | | `label` | `string` | No | Leave unset. Layout's own follow-up on a pending photo. | | `website` | `string` | No | Leave unset. Layout's own follow-up on a pending photo. | The input JSON Schema: ```json { "type": "object", "properties": { "item": { "type": "string", "minLength": 1, "maxLength": 120, "description": "The menu item, in the user's words." }, "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Restaurant name (with `near` or `geo` for locality)." }, "placeId": { "type": "string", "maxLength": 255, "description": "A location's place id already settled earlier in the thread." }, "near": { "type": "string", "maxLength": 300, "description": "The user's area." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "Device coordinates, when you have them." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "A location's ordering URL from an earlier preview." }, "storedOnly": { "type": "boolean", "description": "Leave unset. Layout's own follow-up on a pending photo." }, "label": { "type": "string", "maxLength": 120, "description": "Leave unset. Layout's own follow-up on a pending photo." }, "website": { "type": "string", "maxLength": 2000, "description": "Leave unset. Layout's own follow-up on a pending photo." } }, "required": [ "item" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "status": { "type": "string", "description": "'photo' = show `imageUrl` (a JPEG) with one short line naming the item; never describe it as something it is not. 'pending' = still being read; relay sayToUser and do NOT call again right away (wait for the user, or a minute). 'no_photo' / 'not_on_menu' / 'removed' / 'unavailable' = relay sayToUser and show nothing; never substitute another picture. 'needs_location' = ask which location from `nearby`. 'needs_item' = no dish was named; relay sayToUser and wait for one." }, "restaurant": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": [ "string", "null" ] } }, "additionalProperties": true }, "item": { "type": "string", "description": "The item's name as the restaurant writes it." }, "priceMinor": { "type": "number", "description": "Their listed price in cents when last read; not a quote." }, "imageUrl": { "type": "string", "description": "The photo, a JPEG up to 1000px on its long side." }, "imageUrlWebp": { "type": "string", "description": "The same photo as WebP, smaller, for surfaces that take it." }, "width": { "type": [ "number", "null" ] }, "height": { "type": [ "number", "null" ] }, "source": { "type": "string", "description": "'menu' = from their ordering menu; 'product_page' = from their own site's page for the item." }, "checkedAt": { "type": [ "string", "null" ], "description": "When the photo was last checked." }, "sayToUser": { "type": "string", "description": "Layout's own wording; relay it." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## Build session tools A production build grant gets its own `tools/list`: only `order`, `order_status` and `places`, each described for a build session. `order` here takes no `declineCredit`. Which actions each session may call is in the tables above. ## `order` on a build grant Build and finish a real food order for the person, for pickup. Can change state. ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `action` | `"preview" \| "build" \| "confirm" \| "status"` | Yes | 'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend). | | `restaurant` | `string` | No | The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business, do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all. | | `query` | `string` | No | preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee'), the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept, 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.) | | `items` | `array of string` | No | build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations, ["grande iced pumpkin cream shaken espresso in a venti cup, extra ice", "grande iced brown sugar oatmilk shaken espresso"]. This does NOT replace `query`, `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not. | | `near` | `string` | No | Where the user is, a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates. | | `geo` | `object` | No | preview: the user's DEVICE coordinates (lat/lng), the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead. | | `platform` | `string` | No | build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one. | | `orderUrl` | `string` | No | build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`. | | `restaurantName` | `string` | No | build only: the resolved restaurant name (from preview), shown in the recap. | | `restaurantAddress` | `string` | No | build only: the resolved restaurant address (from preview), shown in the recap. | | `placeId` | `string` | No | the store's placeId, from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address. | | `blacklistHost` | `string` | No | build only: the resolved site host (from preview's openUrl), for the eligibility check. | | `modifiers` | `string \| array of string` | No | build only: the customizations to apply, size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`. | | `knownStoreId` | `string` | No | build only: a resolved store id, if a location is already chosen. | | `timeZone` | `string` | No | build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone. | | `acceptPickupTime` | `string` | No | build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question. | | `choices` | `string` | No | build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question. | | `userInitiatedRetry` | `boolean` | No | build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked, it exists so a real user re-request isn't mistaken for an automatic retry. | | `cancel` | `boolean` | No | Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back. | | `idempotencyKey` | `string` | No | build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars, never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt. | | `orderId` | `string` | No | confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again. | | `expectedTotalMinor` | `integer` | No | confirm only (REQUIRED): the total the user approved (from the build recap). | | `stage` | `"confirm" \| "resend"` | No | confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code. | | `code` | `string` | No | confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'. | | `simulate` | `string` | No | Sandbox build sessions only. This session places real orders, so a build that sends it is refused. | | `latency` | `string` | No | Sandbox build sessions only. A build that sends it here is refused. | | `engine` | `string` | No | Sandbox build sessions only. A build that sends it here is refused. | The input JSON Schema: ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "preview", "build", "confirm", "status" ], "description": "'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend)." }, "restaurant": { "type": "string", "maxLength": 200, "description": "The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business — do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all." }, "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee') — the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept — 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.)" }, "items": { "type": "array", "items": { "type": "string", "minLength": 1, "maxLength": 200 }, "maxItems": 10, "description": "build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations — [\"grande iced pumpkin cream shaken espresso in a venti cup, extra ice\", \"grande iced brown sugar oatmilk shaken espresso\"]. This does NOT replace `query` — `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not." }, "near": { "type": "string", "description": "Where the user is — a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "preview: the user's DEVICE coordinates (lat/lng) — the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead." }, "platform": { "type": "string", "maxLength": 100, "description": "build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`." }, "restaurantName": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant name (from preview), shown in the recap." }, "restaurantAddress": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant address (from preview), shown in the recap." }, "placeId": { "type": "string", "maxLength": 255, "description": "the store's placeId — from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address." }, "blacklistHost": { "type": "string", "maxLength": 500, "description": "build only: the resolved site host (from preview's openUrl), for the eligibility check." }, "modifiers": { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ], "description": "build only: the customizations to apply — size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`." }, "knownStoreId": { "type": "string", "description": "build only: a resolved store id, if a location is already chosen." }, "timeZone": { "type": "string", "maxLength": 60, "description": "build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone." }, "acceptPickupTime": { "type": "string", "maxLength": 120, "description": "build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question." }, "choices": { "type": "string", "maxLength": 300, "description": "build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question." }, "userInitiatedRetry": { "type": "boolean", "description": "build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked — it exists so a real user re-request isn't mistaken for an automatic retry." }, "cancel": { "type": "boolean", "description": "Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back." }, "idempotencyKey": { "type": "string", "maxLength": 200, "description": "build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars — never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt." }, "orderId": { "type": "string", "maxLength": 100, "description": "confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again." }, "expectedTotalMinor": { "type": "integer", "exclusiveMinimum": 0, "description": "confirm only (REQUIRED): the total the user approved (from the build recap)." }, "stage": { "type": "string", "enum": [ "confirm", "resend" ], "description": "confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code." }, "code": { "type": "string", "description": "confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'." }, "simulate": { "type": "string", "description": "Sandbox build sessions only. This session places real orders, so a build that sends it is refused." }, "latency": { "type": "string", "description": "Sandbox build sessions only. A build that sends it here is refused." }, "engine": { "type": "string", "description": "Sandbox build sessions only. A build that sends it here is refused." } }, "required": [ "action" ], "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string", "description": "Echo of the action performed." }, "preview": { "type": "object", "properties": { "sayToUser": { "type": "string", "description": "Layout's own words for the user — relay this framing." }, "needsLocation": { "type": "boolean", "description": "TRUE means this is a QUESTION, not a result: nothing is being built. Ask which store using sayToUser, and the moment the user picks one, call `order` again with action:'build'." }, "nextAction": { "type": "string", "description": "The tool to call once the user answers (e.g. 'order' with action:'build')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Says exactly what to call once they pick a store. Never read it aloud." }, "allClosed": { "type": "boolean", "description": "Every branch Layout can see is CLOSED right now. The which-one question has no true answer — do not ask it. Offer `openAlternatives` instead." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" }, "placeId": { "type": "string" }, "distanceKm": { "type": "number" }, "openNow": { "type": "boolean" }, "closingSoon": { "type": "boolean" } }, "additionalProperties": true }, "description": "REAL places Layout CHECKED are open right now, nearest first — offered when the restaurant they asked for is closed, or when its nearest open branch is a drive away. Offer these BY NAME and build at whichever the user picks. NEVER substitute a restaurant from your own memory: you cannot know whether it is open, still in business, or even nearby." }, "closedNote": { "type": "string", "description": "Layout's words for a resolved store that is closed right now. Relay it and STOP — do not build there unless the user says to anyway." }, "closingSoonNote": { "type": "string", "description": "The store is OPEN and shuts too soon for an order to be placed and made in time, so Layout will refuse the build. Relay it and STOP. Do NOT say they are closed: they are open, and the limit is Layout's. Do not take an item for this store; offer to find somewhere open longer." }, "notOrderableNote": { "type": "string", "description": "Layout READ this restaurant's own website and there is no way to place an order on it. Relay it and STOP. Unlike a closure this is final — do not build there even if the user asks again, and do not ask what they would like from it. Offer to find somewhere nearby that does instead." }, "nearestOpen": { "$ref": "#/properties/preview/properties/openAlternatives/items", "description": "The nearest branch of the SAME brand that Layout CHECKED is open right now, when the one they named is closed. Offer this BEFORE `openAlternatives` and say the distance: they asked for this restaurant, and another branch of it beats a different one." } }, "additionalProperties": true }, "build": { "type": "object", "properties": { "status": { "type": "string", "description": "'building' = still working, keep polling. 'carted' = a priced cart is ready: tell the person the items, the store and `price`, which is the whole total with Layout's service fee included, then wait for their decision. 'cart_expired' = it ran out before anyone confirmed it; nothing was ordered. 'reached_bag_only' = the cart could NOT be verified: nothing was ordered or charged; never present items or prices as confirmed. 'needs_choices' = ask exactly the options in choiceDecision.groups, then build again with a NEW idempotencyKey passing their answer as `choices`. 'pickup_next_day' = nothing was ordered; only on a yes, build again passing pickupDecision.stated as acceptPickupTime. Any other value: relay userMessage and follow assistantInstruction." }, "choiceDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the restaurant requires a choice before this item can be ordered." }, "item": { "type": "string", "description": "The RESTAURANT's own name for what they asked for. Use this name, not the user's words." }, "itemNamedBySite": { "type": "boolean", "description": "True when `item` is the restaurant's own name for it, false when it is the user's words." }, "question": { "type": "string", "description": "Layout's question, built from their menu. Relay it rather than composing your own." }, "groups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. Offer ONLY these; never options you remember." } }, "additionalProperties": true } }, "answerWith": { "type": "object", "properties": { "action": { "type": "string", "description": "The action to call with their answer. Always 'build'." }, "field": { "type": "string" } }, "additionalProperties": true, "description": "How an answer is expressed: a NEW build passing the user's reply as `choices`." } }, "additionalProperties": true, "description": "Present on 'needs_choices'. The choices this restaurant requires before the item can be ordered." }, "pickupDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the user has to agree to this pickup time before anything is ordered." }, "stated": { "type": [ "string", "null" ], "description": "The earliest pickup, in the STORE's own words. Quote it verbatim; never convert the time." }, "day": { "type": "string", "description": "'different' when the earliest pickup is not the user's today." }, "basis": { "type": "string", "description": "How the pickup time was established. Diagnostic; not something to read out." }, "timeZone": { "type": "string", "description": "The STORE's zone. Present so nobody converts `stated` into the user's." }, "question": { "type": "string", "description": "Layout's question for the user. Relay it rather than composing your own." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true }, "description": "The choices Layout's own clients draw as buttons." }, "acceptWith": { "type": "object", "properties": { "action": { "type": "string" }, "field": { "type": "string" }, "value": { "type": [ "string", "null" ] } }, "additionalProperties": true, "description": "How a 'yes' is expressed: a NEW build passing this value as acceptPickupTime. There is no order to confirm." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } }, "additionalProperties": true }, "description": "Places VERIFIED open right now. Offer only these; never a restaurant from your own memory." } }, "additionalProperties": true, "description": "Present on 'pickup_next_day'. The pickup time the user has to agree to before anything is ordered." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "complete": { "type": "boolean", "description": "FALSE means this is not the final answer and no cart exists yet. Do not present a false result as an outcome: follow `nextAction`/`assistantInstruction` instead of stopping here." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME idempotencyKey." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. When present, it says exactly what to do next and takes precedence over anything you would infer from `status`. Never read it aloud to the user." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for THIS order. Give it to the user while they wait — it works even if this conversation ends, and it is the only thing they can watch on their own." }, "actionUrl": { "type": "string", "description": "A link the USER must open to unblock the order (e.g. update a payment method). When present, give it to them — the order cannot continue until they do." } }, "additionalProperties": true }, "placement": { "type": "object", "properties": { "status": { "type": "string", "description": "'placed' is the ONLY status that means the order exists — never tell the user an order was placed on any other value. 'placing' = in progress, keep polling. 'failed' = NOT placed, nothing charged (relay userMessage). 'challenge' = the user was texted a secure approval link. 'unconfirmed' = Layout could not verify either way — tell the user to check before re-ordering; claim neither success nor failure. 'carted' (build sessions) = a ready cart nobody has confirmed: it waits on the person, so stop polling; its `totalMinor` is what a confirm sends." }, "userMessage": { "type": "string" }, "orderNumber": { "type": [ "string", "number" ] }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running and this is NOT the outcome. Follow `nextAction`/`assistantInstruction`; never describe a false result as placed or failed." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME orderId." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over anything you would infer from `status`." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." } }, "additionalProperties": true }, "publicOrderId": { "type": "string", "description": "The developer-facing `ord_` id of this order, for the app's own `GET /v1/orders/:id` and webhooks. Present only when an application's own client drove the order. Never read it aloud." }, "refusal": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "build_limit", "unavailable", "forbidden", "menu_not_held" ], "description": "The same code the REST API answers with. `menu_not_held` (sandbox): Layout holds no menu for this place, so build at a place `places` lists." }, "retryable": { "type": "boolean", "description": "True when the same call may succeed shortly. False on a spent daily ceiling: do not retry before limit.resetsAt." }, "limit": { "type": "object", "properties": { "name": { "type": "string", "const": "builds" }, "scope": { "type": "string", "enum": [ "person", "application", "account", "platform" ] }, "max": { "type": "integer" }, "resetsAt": { "type": "string" } }, "required": [ "name", "scope", "max", "resetsAt" ], "additionalProperties": false, "description": "The daily ceiling that refused the build." } }, "required": [ "code", "retryable" ], "additionalProperties": false, "description": "Present when Layout refused to start a build: nothing was built or charged. For code that does not read userMessage. Never read it aloud." }, "cancel": { "type": "object", "properties": { "status": { "type": "string", "description": "'canceled' = the stop is real and complete; relay userMessage and do NOT confirm or poll this order again. 'already_placing' = TOO LATE to cancel — the placement is in flight; poll `order_status` and report only what it returns. 'placed' = the order already went to the restaurant; never describe it as canceled. 'no_active_order' = there was nothing to cancel." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." } }, "additionalProperties": true }, "confirm": { "type": "object", "properties": { "status": { "type": "string", "description": "'placing' = the order is being placed RIGHT NOW; it is NOT placed; poll `order_status` with the orderId. 'code_sent' = a code was texted (in sandbox nothing is texted); ask the user to read it back. 'confirmed' = the cart is locked in. 'confirm_expired' = relay userMessage; nothing was placed. 'place_error' = relay userMessage and follow assistantInstruction; the outcome can be unknown, so poll `order_status`. 'error' = Layout refused the confirm or did not go ahead: the reason is in `error` (code_required, build_only, price_changed, send_failed, texts_blocked, rate_limited, bad_code, ...). Relay userMessage and follow assistantInstruction. Nothing was placed or charged, except on error 'confirm_failed' or 'in_progress', where a placement may be running: poll `order_status`, never confirm again." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "orderId": { "type": "string", "description": "Pass this to `order_status` to follow the placement." }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running — keep polling; do not report an outcome." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." }, "alreadyConfirmed": { "type": "boolean", "description": "TRUE means this order was ALREADY confirmed on another surface (the person replied YES to Layout's text, confirmed in the Layout app, or another call did). Nothing was confirmed or charged again. Do NOT confirm again and do NOT build again; relay userMessage and report `orderState` only." }, "orderState": { "type": "string", "description": "With alreadyConfirmed: the order's real state now. 'placing' is NOT placed and 'unconfirmed' is NOT failed (Layout cannot yet tell whether the restaurant received it); for both, poll `order_status`. 'placed' is placed. 'refunded' was charged and then refunded; it may never have reached the restaurant." }, "confirmedVia": { "type": "string", "description": "With alreadyConfirmed, when Layout knows: 'sms' (the person replied YES to Layout's text) or 'tracking_link' (Layout's tracking page)." } }, "additionalProperties": true } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `order_status` on a build grant Check on a cart or an order this session started. Read-only. Safe to repeat. ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `idempotencyKey` | `string` | No | The key used for the 'build' call, to follow that cart. | | `orderId` | `string` | No | The order id from a 'confirm' that returned 'placing', to follow the placement. The `ord_` publicOrderId works too. | The input JSON Schema: ```json { "type": "object", "properties": { "idempotencyKey": { "type": "string", "maxLength": 200, "description": "The key used for the 'build' call, to follow that cart." }, "orderId": { "type": "string", "maxLength": 100, "description": "The order id from a 'confirm' that returned 'placing', to follow the placement. The `ord_` publicOrderId works too." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string", "description": "Echo of the action performed." }, "build": { "type": "object", "properties": { "status": { "type": "string", "description": "'building' = still working, keep polling. 'carted' = a priced cart is ready: tell the person the items, the store and `price`, which is the whole total with Layout's service fee included, then wait for their decision. 'cart_expired' = it ran out before anyone confirmed it; nothing was ordered. 'reached_bag_only' = the cart could NOT be verified: nothing was ordered or charged; never present items or prices as confirmed. 'needs_choices' = ask exactly the options in choiceDecision.groups, then build again with a NEW idempotencyKey passing their answer as `choices`. 'pickup_next_day' = nothing was ordered; only on a yes, build again passing pickupDecision.stated as acceptPickupTime. Any other value: relay userMessage and follow assistantInstruction." }, "choiceDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the restaurant requires a choice before this item can be ordered." }, "item": { "type": "string", "description": "The RESTAURANT's own name for what they asked for. Use this name, not the user's words." }, "itemNamedBySite": { "type": "boolean", "description": "True when `item` is the restaurant's own name for it, false when it is the user's words." }, "question": { "type": "string", "description": "Layout's question, built from their menu. Relay it rather than composing your own." }, "groups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. Offer ONLY these; never options you remember." } }, "additionalProperties": true } }, "answerWith": { "type": "object", "properties": { "action": { "type": "string", "description": "The action to call with their answer. Always 'build'." }, "field": { "type": "string" } }, "additionalProperties": true, "description": "How an answer is expressed: a NEW build passing the user's reply as `choices`." } }, "additionalProperties": true, "description": "Present on 'needs_choices'. The choices this restaurant requires before the item can be ordered." }, "pickupDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the user has to agree to this pickup time before anything is ordered." }, "stated": { "type": [ "string", "null" ], "description": "The earliest pickup, in the STORE's own words. Quote it verbatim; never convert the time." }, "day": { "type": "string", "description": "'different' when the earliest pickup is not the user's today." }, "basis": { "type": "string", "description": "How the pickup time was established. Diagnostic; not something to read out." }, "timeZone": { "type": "string", "description": "The STORE's zone. Present so nobody converts `stated` into the user's." }, "question": { "type": "string", "description": "Layout's question for the user. Relay it rather than composing your own." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true }, "description": "The choices Layout's own clients draw as buttons." }, "acceptWith": { "type": "object", "properties": { "action": { "type": "string" }, "field": { "type": "string" }, "value": { "type": [ "string", "null" ] } }, "additionalProperties": true, "description": "How a 'yes' is expressed: a NEW build passing this value as acceptPickupTime. There is no order to confirm." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } }, "additionalProperties": true }, "description": "Places VERIFIED open right now. Offer only these; never a restaurant from your own memory." } }, "additionalProperties": true, "description": "Present on 'pickup_next_day'. The pickup time the user has to agree to before anything is ordered." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "complete": { "type": "boolean", "description": "FALSE means this is not the final answer and no cart exists yet. Do not present a false result as an outcome: follow `nextAction`/`assistantInstruction` instead of stopping here." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME idempotencyKey." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. When present, it says exactly what to do next and takes precedence over anything you would infer from `status`. Never read it aloud to the user." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for THIS order. Give it to the user while they wait — it works even if this conversation ends, and it is the only thing they can watch on their own." }, "actionUrl": { "type": "string", "description": "A link the USER must open to unblock the order (e.g. update a payment method). When present, give it to them — the order cannot continue until they do." } }, "additionalProperties": true }, "placement": { "type": "object", "properties": { "status": { "type": "string", "description": "'placed' is the ONLY status that means the order exists — never tell the user an order was placed on any other value. 'placing' = in progress, keep polling. 'failed' = NOT placed, nothing charged (relay userMessage). 'challenge' = the user was texted a secure approval link. 'unconfirmed' = Layout could not verify either way — tell the user to check before re-ordering; claim neither success nor failure. 'carted' (build sessions) = a ready cart nobody has confirmed: it waits on the person, so stop polling; its `totalMinor` is what a confirm sends." }, "userMessage": { "type": "string" }, "orderNumber": { "type": [ "string", "number" ] }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running and this is NOT the outcome. Follow `nextAction`/`assistantInstruction`; never describe a false result as placed or failed." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME orderId." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over anything you would infer from `status`." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." } }, "additionalProperties": true }, "publicOrderId": { "type": "string", "description": "The developer-facing `ord_` id of this order, for the app's own `GET /v1/orders/:id` and webhooks. Present only when an application's own client drove the order. Never read it aloud." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `places` on a build grant Find restaurants, cafes and coffee shops by description or name: 'is there a Starbucks in Oakland', 'coffee near me', 'taco places downtown'. Read-only. Safe to repeat. ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | Yes | What to look for, a name ('Starbucks'), a food ('tacos'), or a kind of place ('coffee shop'). | | `near` | `string` | No | The RESOLVED locality, a real city and state, e.g. 'Oakland, CA' or 'San Francisco, CA'. YOU resolve it: if the user names a landmark, a neighbourhood, an airport or an abbreviation ('Salesforce Tower', 'the Mission', 'SFO', 'sf'), work out which city that is and pass THAT here. Layout matches this against the city each restaurant is in, so a landmark passed here matches nothing. Pass it whenever they said where they're asking about, a named place is where they are asking ABOUT, which is not always where they are. | | `nearRaw` | `string` | No | The user's OWN words for the place, verbatim, 'Salesforce Tower', 'the Mission', 'near the ballpark'. Pass it alongside `near` whenever they said something more specific than a city. If Layout has to go and look the area up, this is what it asks with, and it resolves landmarks far better than a city name alone. Omit when they only ever named a city. | | `geo` | `object` | No | The user's device coordinates, when your client can read them. Used to rank by real distance and to bound 'near me'. | | `openNow` | `boolean` | No | Only places open right now, judged at the STORE's own clock. Use it when the ask is about eating now. | | `limit` | `integer` | No | How many to return. Default 5. | The input JSON Schema: ```json { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "What to look for — a name ('Starbucks'), a food ('tacos'), or a kind of place ('coffee shop')." }, "near": { "type": "string", "maxLength": 200, "description": "The RESOLVED locality — a real city and state, e.g. 'Oakland, CA' or 'San Francisco, CA'. YOU resolve it: if the user names a landmark, a neighbourhood, an airport or an abbreviation ('Salesforce Tower', 'the Mission', 'SFO', 'sf'), work out which city that is and pass THAT here. Layout matches this against the city each restaurant is in, so a landmark passed here matches nothing. Pass it whenever they said where they're asking about — a named place is where they are asking ABOUT, which is not always where they are." }, "nearRaw": { "type": "string", "maxLength": 200, "description": "The user's OWN words for the place, verbatim — 'Salesforce Tower', 'the Mission', 'near the ballpark'. Pass it alongside `near` whenever they said something more specific than a city. If Layout has to go and look the area up, this is what it asks with, and it resolves landmarks far better than a city name alone. Omit when they only ever named a city." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "The user's device coordinates, when your client can read them. Used to rank by real distance and to bound 'near me'." }, "openNow": { "type": "boolean", "description": "Only places open right now, judged at the STORE's own clock. Use it when the ask is about eating now." }, "limit": { "type": "integer", "minimum": 1, "maximum": 10, "description": "How many to return. Default 5." } }, "required": [ "query" ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "places": { "type": "array", "items": { "type": "object", "properties": { "placeId": { "type": "string" }, "name": { "type": "string" }, "address": { "type": [ "string", "null" ] }, "city": { "type": [ "string", "null" ] }, "cuisine": { "type": [ "string", "null" ] }, "rating": { "type": [ "number", "null" ] }, "priceLevel": { "type": [ "number", "null" ] }, "openStatus": { "type": "string", "enum": [ "open", "closed", "unknown" ], "description": "READ THIS ONE. 'open' / 'closed' at the STORE's own clock right now; 'unknown' means Layout holds no hours for it, which is NOT closed — say you don't know its hours." }, "closesAt": { "type": [ "string", "null" ], "description": "Store-local closing time when open, e.g. '5:00 PM'. Say it: 'open until 5' is the useful answer." }, "opensAt": { "type": [ "string", "null" ], "description": "Store-local next opening when closed, e.g. '7:00 AM' or 'Monday 7:00 AM'." }, "openNow": { "type": [ "boolean", "null" ], "description": "The same answer as a boolean, for older clients. Prefer `openStatus`: a null here means unknown and is easy to misread as closed." }, "closingSoon": { "type": "boolean", "description": "Open, and shutting within the hour. Say so: Layout stops taking orders shortly before a store closes, so this is the last window to place one." }, "distanceKm": { "type": [ "number", "null" ], "description": "Straight-line km from the coordinates you passed. Absent when you passed none." } }, "required": [ "placeId", "name" ], "additionalProperties": true } }, "notOpenNow": { "type": "array", "items": { "$ref": "#/properties/places/items" }, "description": "Real restaurants Layout holds in that area that are NOT open right now, soonest to open first. Present ONLY when the open-now filter is what emptied `places`: it means Layout searched and FOUND places, so an empty `places` beside it is never 'there is nothing there'. These are NOT offerable — read each row's `openStatus` ('closed' with `opensAt` for when it reopens, 'unknown' when Layout holds no hours) and never offer to order from one now." }, "emptyBecause": { "type": "string", "const": "no_held_menu", "description": "Sandbox only: Layout holds matching places but the menu of none of them, and a sandbox cart is built from a held menu. Not a finding that nothing is there." }, "stillChecking": { "type": "boolean", "description": "Layout found places in that area and has not finished working out whether they take online orders — that runs after this answer and usually lands within a minute. The list may grow shortly, though it is not certain it will. When this is true, an EMPTY list is NOT a finding that there is nothing there: never say there is nothing, say you are still checking and offer to look again in a moment." }, "sayToUser": { "type": "string", "description": "Layout's own wording — relay it." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## Sandbox session tools A sandbox build grant reads these as listed here, and every other tool as a production build grant does. ## `order` on a sandbox build grant Build and finish a real food order for the person, for pickup. Can change state. ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `action` | `"preview" \| "build" \| "confirm" \| "status"` | Yes | 'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend). | | `restaurant` | `string` | No | The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business, do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all. | | `query` | `string` | No | preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee'), the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept, 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.) | | `items` | `array of string` | No | build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations, ["grande iced pumpkin cream shaken espresso in a venti cup, extra ice", "grande iced brown sugar oatmilk shaken espresso"]. This does NOT replace `query`, `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not. | | `near` | `string` | No | Where the user is, a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates. | | `geo` | `object` | No | preview: the user's DEVICE coordinates (lat/lng), the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead. | | `platform` | `string` | No | build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one. | | `orderUrl` | `string` | No | build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`. | | `restaurantName` | `string` | No | build only: the resolved restaurant name (from preview), shown in the recap. | | `restaurantAddress` | `string` | No | build only: the resolved restaurant address (from preview), shown in the recap. | | `placeId` | `string` | No | the store's placeId, from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address. | | `blacklistHost` | `string` | No | build only: the resolved site host (from preview's openUrl), for the eligibility check. | | `modifiers` | `string \| array of string` | No | build only: the customizations to apply, size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`. | | `knownStoreId` | `string` | No | build only: a resolved store id, if a location is already chosen. | | `timeZone` | `string` | No | build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone. | | `acceptPickupTime` | `string` | No | build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question. | | `choices` | `string` | No | build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question. | | `userInitiatedRetry` | `boolean` | No | build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked, it exists so a real user re-request isn't mistaken for an automatic retry. | | `cancel` | `boolean` | No | Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back. | | `idempotencyKey` | `string` | No | build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars, never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt. | | `orderId` | `string` | No | confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again. | | `expectedTotalMinor` | `integer` | No | confirm only (REQUIRED): the total the user approved (from the build recap). | | `stage` | `"confirm" \| "resend"` | No | confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code. | | `code` | `string` | No | confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'. | | `simulate` | `"placed" \| "failed" \| "unconfirmed" \| "challenge" \| "code" \| "card_declined" \| "price_changed" \| "closed" \| "pickup_later"` | No | build only, sandbox: the ending this cart reaches, to test how your integration handles it. 'placed' when omitted. | | `latency` | `"realistic" \| "instant"` | No | build only, sandbox: 'realistic' (default) takes about as long as a real build, 30 seconds for one item and 10 for each item after it, at most 90; 'instant' skips every wait. | | `engine` | `"shadow" \| "real"` | No | build only, sandbox: 'shadow' (default) builds from the menu Layout holds and opens no browser; 'real' drives the restaurant's own site, 10 a day per developer account. | The input JSON Schema: ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "preview", "build", "confirm", "status" ], "description": "'preview' = resolve + estimate. 'build' = START building a cart (returns 'building'; poll status). 'status' = poll a build by idempotencyKey, OR poll PLACEMENT by orderId after confirm returns 'placing'. 'confirm' = finalize with the code (or resend)." }, "restaurant": { "type": "string", "maxLength": 200, "description": "The restaurant, cafe, shop or brand the user named to order FROM, in their exact words ('602 Coffee', 'Starbucks', 'Jack in the Box'). It is a BUSINESS, never a place on a map: a name that starts with a number ('602 Coffee', '7 Leaves') is still a business — do not read it as an address, do not fold it into `near`, and never replace it with the city it is in. Fill this on preview AND on build whenever the user said where to order from; omit it only when they named no restaurant at all." }, "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "preview: the RESTAURANT or brand name the user named ('Starbucks', '602 Coffee') — the same name you put in `restaurant`. NOT the item, size or 'decaf', and NOT a city: 'an iced americano at 602 coffee in Huntington Beach' is restaurant='602 Coffee', query='602 Coffee', near='Huntington Beach'; the drink waits for build. Only when they named NO restaurant is query the food they want ('iced americano'), and Layout finds places that serve it. build: EVERYTHING they asked for, in their own words, WITH every size, cup, temperature, and prep detail kept — 'a grande iced brown sugar oatmilk shaken espresso in a venti cup with extra ice' is passed WHOLE, never shortened to 'iced brown sugar oatmilk shaken espresso'. If they named two drinks, both belong here ('a grande latte and a grande cold brew'). Never drop a word of what they asked for. (Unused for confirm.)" }, "items": { "type": "array", "items": { "type": "string", "minLength": 1, "maxLength": 200 }, "maxItems": 10, "description": "build, optional but STRONGLY preferred when they asked for more than one thing: the same order split into ONE ENTRY PER ITEM, each with its own size and customizations — [\"grande iced pumpkin cream shaken espresso in a venti cup, extra ice\", \"grande iced brown sugar oatmilk shaken espresso\"]. This does NOT replace `query` — `query` must still name everything. The list is what lets Layout check that every item actually reached the cart and tell the user by name if one did not." }, "near": { "type": "string", "description": "Where the user is — a street address, city, or area. Pass whatever you know (even approximate) so Layout can find the right store; it will ask the user to pick if it's not precise enough. Prefer `geo` when you have exact coordinates." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "preview: the user's DEVICE coordinates (lat/lng) — the highest-confidence locality. It does not skip the which-store question (only a store address the user states does that); it makes the question name the nearest store as a one-tap default. If you do not have them and your client CAN get them, request device location permission from the user first, then pass the result here. Ask once per conversation; if declined, use `near` instead." }, "platform": { "type": "string", "maxLength": 100, "description": "build only: Layout's id for a restaurant's ordering site (e.g. 'carlsjr'), when you have one." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "build only: the restaurant's own https ordering page, when you have it. A build needs one of `platform`, `orderUrl`, or a restaurant name with `near` or `geo`." }, "restaurantName": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant name (from preview), shown in the recap." }, "restaurantAddress": { "type": "string", "maxLength": 500, "description": "build only: the resolved restaurant address (from preview), shown in the recap." }, "placeId": { "type": "string", "maxLength": 255, "description": "the store's placeId — from preview's `resolved.placeId`, or from the `nearby` option the user picked. Send it on BUILD and on any follow-up PREVIEW after a pick. Pass it whenever you have it: it identifies WHICH BRANCH, so Layout checks that store's opening hours instead of matching a name and an address." }, "blacklistHost": { "type": "string", "maxLength": 500, "description": "build only: the resolved site host (from preview's openUrl), for the eligibility check." }, "modifiers": { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ], "description": "build only: the customizations to apply — size, cup, temperature, extras, substitutions ('grande', 'in a venti cup', 'extra ice', 'oat milk', 'no whip'), free text or a list. `query` already carries them in the person's words; fill this too when they customized, so the runner has the customizations as an explicit checklist and not only buried in the item text. Never a reason to shorten `query`." }, "knownStoreId": { "type": "string", "description": "build only: a resolved store id, if a location is already chosen." }, "timeZone": { "type": "string", "maxLength": 60, "description": "build only: the user's IANA timezone (e.g. 'America/Los_Angeles') if you know it. It decides whether a pickup slot falls on THEIR today or a different day; without it Layout falls back to the store's own zone." }, "acceptPickupTime": { "type": "string", "maxLength": 120, "description": "build only: pass ONLY after a previous build returned status 'pickup_next_day' AND the user said yes. Set it to `pickupDecision.stated` VERBATIM. Never invent it and never set it to skip the question." }, "choices": { "type": "string", "maxLength": 300, "description": "build only: the user's answer to a 'needs_choices' question ('bowl, white rice'), in THEIR own words. Pass it on the fresh build that follows their answer. Never invent choices they did not make and never set it to skip the question." }, "userInitiatedRetry": { "type": "boolean", "description": "build only: set TRUE ONLY when the HUMAN explicitly asked to try again after a failed/blocked attempt. NEVER set it yourself to bypass a retry_blocked — it exists so a real user re-request isn't mistaken for an automatic retry." }, "cancel": { "type": "boolean", "description": "Set TRUE when the USER asks to cancel their order or cart. Works with any action value; pass orderId when you have it, or the build's idempotencyKey while it is still building (omitting both means the active order, on a person's own connection only; a build session must name one or it is refused). The response's `cancel.status` is the only truth: 'canceled' = stopped, nothing charged; 'canceling' = it was still building and Layout releases it when it lands, nothing charged; 'already_placing' = too late, the placement is in flight (poll order_status); 'placed' = it already went to the restaurant; 'no_active_order' = nothing to cancel. NEVER tell the user an order was canceled unless status 'canceled' or 'canceling' actually came back." }, "idempotencyKey": { "type": "string", "maxLength": 200, "description": "build + confirm (REQUIRED): FRESH RANDOM id per NEW attempt (8+ random chars — never only date/food-derived, that collides across sessions). Reuse only to poll/confirm the SAME attempt." }, "orderId": { "type": "string", "maxLength": 100, "description": "confirm (REQUIRED): the order to confirm. status: pass it (WITHOUT idempotencyKey semantics) to poll PLACEMENT after a confirm returned 'placing': keep polling while placement.status is 'placing' or 'challenge'. 'placed', 'failed' and 'unconfirmed' are final: stop and relay userMessage, and never order an unconfirmed order again." }, "expectedTotalMinor": { "type": "integer", "exclusiveMinimum": 0, "description": "confirm only (REQUIRED): the total the user approved (from the build recap)." }, "stage": { "type": "string", "enum": [ "confirm", "resend" ], "description": "confirm only (optional, defaults to 'confirm'): omit it to finalize; pass 'resend' to text a new code." }, "code": { "type": "string", "description": "confirm + stage 'confirm': the 6-digit code the person read back. On a person's own connection, send it when the build's confirmation.method is 'sms_code'. On a build session, omit it on the first confirm and send it only after a confirm answered error 'code_required'." }, "simulate": { "type": "string", "enum": [ "placed", "failed", "unconfirmed", "challenge", "code", "card_declined", "price_changed", "closed", "pickup_later" ], "description": "build only, sandbox: the ending this cart reaches, to test how your integration handles it. 'placed' when omitted." }, "latency": { "type": "string", "enum": [ "realistic", "instant" ], "description": "build only, sandbox: 'realistic' (default) takes about as long as a real build, 30 seconds for one item and 10 for each item after it, at most 90; 'instant' skips every wait." }, "engine": { "type": "string", "enum": [ "shadow", "real" ], "description": "build only, sandbox: 'shadow' (default) builds from the menu Layout holds and opens no browser; 'real' drives the restaurant's own site, 10 a day per developer account." } }, "required": [ "action" ], "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "action": { "type": "string", "description": "Echo of the action performed." }, "preview": { "type": "object", "properties": { "sayToUser": { "type": "string", "description": "Layout's own words for the user — relay this framing." }, "needsLocation": { "type": "boolean", "description": "TRUE means this is a QUESTION, not a result: nothing is being built. Ask which store using sayToUser, and the moment the user picks one, call `order` again with action:'build'." }, "nextAction": { "type": "string", "description": "The tool to call once the user answers (e.g. 'order' with action:'build')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Says exactly what to call once they pick a store. Never read it aloud." }, "allClosed": { "type": "boolean", "description": "Every branch Layout can see is CLOSED right now. The which-one question has no true answer — do not ask it. Offer `openAlternatives` instead." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" }, "placeId": { "type": "string" }, "distanceKm": { "type": "number" }, "openNow": { "type": "boolean" }, "closingSoon": { "type": "boolean" } }, "additionalProperties": true }, "description": "REAL places Layout CHECKED are open right now, nearest first — offered when the restaurant they asked for is closed, or when its nearest open branch is a drive away. Offer these BY NAME and build at whichever the user picks. NEVER substitute a restaurant from your own memory: you cannot know whether it is open, still in business, or even nearby." }, "closedNote": { "type": "string", "description": "Layout's words for a resolved store that is closed right now. Relay it and STOP — do not build there unless the user says to anyway." }, "closingSoonNote": { "type": "string", "description": "The store is OPEN and shuts too soon for an order to be placed and made in time, so Layout will refuse the build. Relay it and STOP. Do NOT say they are closed: they are open, and the limit is Layout's. Do not take an item for this store; offer to find somewhere open longer." }, "notOrderableNote": { "type": "string", "description": "Layout READ this restaurant's own website and there is no way to place an order on it. Relay it and STOP. Unlike a closure this is final — do not build there even if the user asks again, and do not ask what they would like from it. Offer to find somewhere nearby that does instead." }, "nearestOpen": { "$ref": "#/properties/preview/properties/openAlternatives/items", "description": "The nearest branch of the SAME brand that Layout CHECKED is open right now, when the one they named is closed. Offer this BEFORE `openAlternatives` and say the distance: they asked for this restaurant, and another branch of it beats a different one." } }, "additionalProperties": true }, "build": { "type": "object", "properties": { "status": { "type": "string", "description": "'building' = still working, keep polling. 'carted' = a priced cart is ready: tell the person the items, the store and `price`, which is the whole total with Layout's service fee included, then wait for their decision. 'cart_expired' = it ran out before anyone confirmed it; nothing was ordered. 'reached_bag_only' = the cart could NOT be verified: nothing was ordered or charged; never present items or prices as confirmed. 'needs_choices' = ask exactly the options in choiceDecision.groups, then build again with a NEW idempotencyKey passing their answer as `choices`. 'pickup_next_day' = nothing was ordered; only on a yes, build again passing pickupDecision.stated as acceptPickupTime. Any other value: relay userMessage and follow assistantInstruction." }, "choiceDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the restaurant requires a choice before this item can be ordered." }, "item": { "type": "string", "description": "The RESTAURANT's own name for what they asked for. Use this name, not the user's words." }, "itemNamedBySite": { "type": "boolean", "description": "True when `item` is the restaurant's own name for it, false when it is the user's words." }, "question": { "type": "string", "description": "Layout's question, built from their menu. Relay it rather than composing your own." }, "groups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. Offer ONLY these; never options you remember." } }, "additionalProperties": true } }, "answerWith": { "type": "object", "properties": { "action": { "type": "string", "description": "The action to call with their answer. Always 'build'." }, "field": { "type": "string" } }, "additionalProperties": true, "description": "How an answer is expressed: a NEW build passing the user's reply as `choices`." } }, "additionalProperties": true, "description": "Present on 'needs_choices'. The choices this restaurant requires before the item can be ordered." }, "pickupDecision": { "type": "object", "properties": { "needed": { "type": "boolean", "description": "True when the user has to agree to this pickup time before anything is ordered." }, "stated": { "type": [ "string", "null" ], "description": "The earliest pickup, in the STORE's own words. Quote it verbatim; never convert the time." }, "day": { "type": "string", "description": "'different' when the earliest pickup is not the user's today." }, "basis": { "type": "string", "description": "How the pickup time was established. Diagnostic; not something to read out." }, "timeZone": { "type": "string", "description": "The STORE's zone. Present so nobody converts `stated` into the user's." }, "question": { "type": "string", "description": "Layout's question for the user. Relay it rather than composing your own." }, "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true }, "description": "The choices Layout's own clients draw as buttons." }, "acceptWith": { "type": "object", "properties": { "action": { "type": "string" }, "field": { "type": "string" }, "value": { "type": [ "string", "null" ] } }, "additionalProperties": true, "description": "How a 'yes' is expressed: a NEW build passing this value as acceptPickupTime. There is no order to confirm." }, "openAlternatives": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": "string" } }, "additionalProperties": true }, "description": "Places VERIFIED open right now. Offer only these; never a restaurant from your own memory." } }, "additionalProperties": true, "description": "Present on 'pickup_next_day'. The pickup time the user has to agree to before anything is ordered." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "complete": { "type": "boolean", "description": "FALSE means this is not the final answer and no cart exists yet. Do not present a false result as an outcome: follow `nextAction`/`assistantInstruction` instead of stopping here." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME idempotencyKey." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. When present, it says exactly what to do next and takes precedence over anything you would infer from `status`. Never read it aloud to the user." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for THIS order. Give it to the user while they wait — it works even if this conversation ends, and it is the only thing they can watch on their own." }, "actionUrl": { "type": "string", "description": "A link the USER must open to unblock the order (e.g. update a payment method). When present, give it to them — the order cannot continue until they do." } }, "additionalProperties": true }, "placement": { "type": "object", "properties": { "status": { "type": "string", "description": "'placed' is the ONLY status that means the order exists — never tell the user an order was placed on any other value. 'placing' = in progress, keep polling. 'failed' = NOT placed, nothing charged (relay userMessage). 'challenge' = the user was texted a secure approval link. 'unconfirmed' = Layout could not verify either way — tell the user to check before re-ordering; claim neither success nor failure. 'carted' (build sessions) = a ready cart nobody has confirmed: it waits on the person, so stop polling; its `totalMinor` is what a confirm sends." }, "userMessage": { "type": "string" }, "orderNumber": { "type": [ "string", "number" ] }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running and this is NOT the outcome. Follow `nextAction`/`assistantInstruction`; never describe a false result as placed or failed." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status'), with the SAME orderId." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over anything you would infer from `status`." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." } }, "additionalProperties": true }, "publicOrderId": { "type": "string", "description": "The developer-facing `ord_` id of this order, for the app's own `GET /v1/orders/:id` and webhooks. Present only when an application's own client drove the order. Never read it aloud." }, "refusal": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "build_limit", "unavailable", "forbidden", "menu_not_held" ], "description": "The same code the REST API answers with. `menu_not_held` (sandbox): Layout holds no menu for this place, so build at a place `places` lists." }, "retryable": { "type": "boolean", "description": "True when the same call may succeed shortly. False on a spent daily ceiling: do not retry before limit.resetsAt." }, "limit": { "type": "object", "properties": { "name": { "type": "string", "const": "builds" }, "scope": { "type": "string", "enum": [ "person", "application", "account", "platform" ] }, "max": { "type": "integer" }, "resetsAt": { "type": "string" } }, "required": [ "name", "scope", "max", "resetsAt" ], "additionalProperties": false, "description": "The daily ceiling that refused the build." } }, "required": [ "code", "retryable" ], "additionalProperties": false, "description": "Present when Layout refused to start a build: nothing was built or charged. For code that does not read userMessage. Never read it aloud." }, "cancel": { "type": "object", "properties": { "status": { "type": "string", "description": "'canceled' = the stop is real and complete; relay userMessage and do NOT confirm or poll this order again. 'already_placing' = TOO LATE to cancel — the placement is in flight; poll `order_status` and report only what it returns. 'placed' = the order already went to the restaurant; never describe it as canceled. 'no_active_order' = there was nothing to cancel." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." } }, "additionalProperties": true }, "confirm": { "type": "object", "properties": { "status": { "type": "string", "description": "'placing' = the order is being placed RIGHT NOW; it is NOT placed; poll `order_status` with the orderId. 'code_sent' = a code was texted (in sandbox nothing is texted); ask the user to read it back. 'confirmed' = the cart is locked in. 'confirm_expired' = relay userMessage; nothing was placed. 'place_error' = relay userMessage and follow assistantInstruction; the outcome can be unknown, so poll `order_status`. 'error' = Layout refused the confirm or did not go ahead: the reason is in `error` (code_required, build_only, price_changed, send_failed, texts_blocked, rate_limited, bad_code, ...). Relay userMessage and follow assistantInstruction. Nothing was placed or charged, except on error 'confirm_failed' or 'in_progress', where a placement may be running: poll `order_status`, never confirm again." }, "userMessage": { "type": "string", "description": "Layout's message for the user. Relay it rather than paraphrasing." }, "orderId": { "type": "string", "description": "Pass this to `order_status` to follow the placement." }, "complete": { "type": "boolean", "description": "FALSE means the placement is still running — keep polling; do not report an outcome." }, "nextAction": { "type": "string", "description": "The tool to call next (e.g. 'order_status')." }, "assistantInstruction": { "type": "string", "description": "Written for YOU, not the user. Takes precedence over inference." }, "trackUrl": { "type": "string", "description": "Layout's live tracking page for this order — give it to the user." }, "alreadyConfirmed": { "type": "boolean", "description": "TRUE means this order was ALREADY confirmed on another surface (the person replied YES to Layout's text, confirmed in the Layout app, or another call did). Nothing was confirmed or charged again. Do NOT confirm again and do NOT build again; relay userMessage and report `orderState` only." }, "orderState": { "type": "string", "description": "With alreadyConfirmed: the order's real state now. 'placing' is NOT placed and 'unconfirmed' is NOT failed (Layout cannot yet tell whether the restaurant received it); for both, poll `order_status`. 'placed' is placed. 'refunded' was charged and then refunded; it may never have reached the restaurant." }, "confirmedVia": { "type": "string", "description": "With alreadyConfirmed, when Layout knows: 'sms' (the person replied YES to Layout's text) or 'tracking_link' (Layout's tracking page)." } }, "additionalProperties": true } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ## `menu` on a sandbox build grant The restaurant's menu as Layout holds it: the same menu a sandbox cart there is priced from, so build only from the item and option names it lists. Read-only. Safe to repeat. ### Input | Argument | Type | Required | Description | | --- | --- | --- | --- | | `query` | `string` | No | Restaurant name to look up (with `near` or `geo` for locality). | | `placeId` | `string` | No | A specific location's place id, when one is already settled, from an earlier order preview or a location the user already picked. Skips the which-location question. | | `near` | `string` | No | The user's area, to pick the right location. | | `geo` | `object` | No | Device coordinates, the strongest locality signal. If you do not have them and your client can request device location permission, do that first and pass the result here; otherwise use `near`. | | `orderUrl` | `string` | No | A specific location's ordering URL from an earlier preview, skips name resolution. | | `category` | `string` | No | A section name from a previous 'categories' answer, EXACTLY as given, reads just that section. | | `intent` | `string` | No | The user's FULL request in their own words (e.g. 'what's under $2 at McDonald's'). Always pass it, it's used only if the live read fails, so the web-knowledge fallback answers exactly what they asked. | | `mode` | `"live" \| "knowledge"` | No | Leave it out in sandbox: a sandbox menu is the one Layout holds, and 'knowledge' answers the same way. | The input JSON Schema: ```json { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Restaurant name to look up (with `near` or `geo` for locality)." }, "placeId": { "type": "string", "maxLength": 255, "description": "A specific location's place id, when one is already settled — from an earlier order preview or a location the user already picked. Skips the which-location question." }, "near": { "type": "string", "maxLength": 300, "description": "The user's area, to pick the right location." }, "geo": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "required": [ "lat", "lng" ], "additionalProperties": false, "description": "Device coordinates — the strongest locality signal. If you do not have them and your client can request device location permission, do that first and pass the result here; otherwise use `near`." }, "orderUrl": { "type": "string", "maxLength": 2000, "description": "A specific location's ordering URL from an earlier preview — skips name resolution." }, "category": { "type": "string", "maxLength": 80, "description": "A section name from a previous 'categories' answer, EXACTLY as given — reads just that section." }, "intent": { "type": "string", "maxLength": 400, "description": "The user's FULL request in their own words (e.g. 'what's under $2 at McDonald's'). Always pass it — it's used only if the live read fails, so the web-knowledge fallback answers exactly what they asked." }, "mode": { "type": "string", "enum": [ "live", "knowledge" ], "description": "Leave it out in sandbox: a sandbox menu is the one Layout holds, and 'knowledge' answers the same way." } }, "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } ``` ### Output The output JSON Schema. A successful call's `structuredContent` matches it. ```json { "type": "object", "properties": { "status": { "type": "string", "description": "'menu' = a live read of their menu; present it as such. 'reading' = still being read; relay sayToUser ONLY if present (say nothing otherwise), then call again immediately with the same arguments — the call attaches to the read in flight and waits with it. 'categories' = their site keeps prices one level down; relay sayToUser and list the `categories` cleanly (short lines, no JSON) and, once the user picks, call again with the SAME arguments plus `category` set to their choice. A question that spans the WHOLE menu ('what is under $3?') CANNOT be answered from one section — say so plainly and let them pick a section to check, or offer to order a specific item directly. NEVER re-call this tool with the same arguments hoping for a different result: each call is a real, billed browse of their site, and a repeat gets you the same answer. 'unreadable' = Layout could not read the menu cleanly — do NOT invent or fill in items; relay sayToUser (ordering directly still works). 'knowledge' = the live menu couldn't be read, so `knowledge` holds a WEB-lookup answer to the user's request — relay sayToUser, present it as general/non-live background (prices vary, may be out of date), never as their live menu. 'needs_location' = ask the user which location, offering the `nearby` choices. Any other value: relay sayToUser." }, "restaurant": { "type": "object", "properties": { "name": { "type": "string" }, "address": { "type": [ "string", "null" ] } }, "additionalProperties": true }, "menu": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "priceMinor": { "type": "number", "description": "Price in cents, from their site." }, "description": { "type": "string" }, "category": { "type": "string" }, "optionGroups": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "What the restaurant calls this choice." }, "required": { "type": "boolean", "description": "TRUE = their site will not let this item be ordered until it is answered." }, "options": { "type": "array", "items": { "type": "string" }, "description": "Their own option names. If you offer choices, offer THESE and nothing else." }, "moreOptions": { "type": "number", "description": "Options beyond the ones listed — say the list is partial rather than implying it is all of them." }, "defaults": { "type": "array", "items": { "type": "string" }, "description": "What the item already comes with, written as in options. The group is chosen: keep it unless the user asks to change it." } }, "additionalProperties": true }, "description": "The choices this item carries, in the restaurant's words. THE FIELD BEING ABSENT IS NOT 'no choices': it means Layout could not read whether this item has any, so do not tell the user it needs none and do not invent a question. An EMPTY array does mean none." } }, "required": [ "name" ], "additionalProperties": true } } }, "additionalProperties": true }, "confidence": { "type": "string", "description": "'partial' = may not be the complete menu; say so when presenting." }, "categories": { "type": "array", "items": { "type": "string" }, "description": "On 'categories': the sections their site offers. Offer these to the user; pass their choice back as `category`." }, "pricesUnavailable": { "type": "boolean", "description": "True = real items, but their site gave no prices. Do NOT supply prices from memory; say they weren't available." }, "scope": { "type": "string", "description": "'chain' = this location's own menu isn't read yet or couldn't be read, so these are the items most of the restaurant's locations carry, with no prices because prices vary by location. Relay sayToUser; never present it as this location's exact menu." }, "knowledge": { "type": "string", "description": "On 'knowledge': a NON-LIVE, web-lookup answer to the user's request. Relay via sayToUser; never present it as the live menu or as exact current prices." }, "live": { "type": "boolean", "description": "false on a 'knowledge' answer — the info is general and may be out of date." }, "sayToUser": { "type": "string", "description": "Layout's own wording for the user — relay it. On 'reading' this may be absent, which means stay quiet and just poll again." } }, "additionalProperties": true, "$schema": "http://json-schema.org/draft-07/schema#" } ``` --- # Event types The full catalog of webhook events. The vocabulary carries the truth: `order.unconfirmed` is first-class, and must never be collapsed into `order.failed`. | Event | Meaning | | --- | --- | | `order.building` | The order was accepted and the cart is being built. | | `order.carted` | The cart is priced and ready. Carries the store's name and address, how many items it holds, and the store's total, without Layout's service fee. A cart you canceled while it was building is never sent as `order.carted`: it ends with one `order.failed` carrying `USER_CANCELED`. | | `order.placing` | The person confirmed; Layout is submitting to the merchant. | | `order.placed` | The order reached the merchant, with evidence. Fires only on a confirmed placement. | | `order.failed` | Not placed. `order.failure_code` says why. A ready cart nobody confirmed before `expiresAt` is `CART_EXPIRED`. A failed order can still show a pending hold: see [Holds on a failed order](https://docs.layout.link/errors#holds-on-a-failed-order). | | `order.unconfirmed` | Placement is genuinely uncertain. Do not retry it, automatically or by hand, and do not treat it as failed. Wait for `order.placed` or read `GET /v1/orders/:id`. That is the double-charge trap. Layout keeps following the order, and `order.placed` can follow once it learns the outcome, for instance when the restaurant's receipt arrives. | | `order.updated` | More pickup details arrived after placement (name, time, order number, instructions). It can come several times, each with its own `event_id`: see [More than one order.updated](#more-than-one-order-updated). Read the order again on each one. `order.state` stays `placed`, and it is sent only for an order whose `order.placed` was sent to you. | | `order.refunded` | Money went back to the person. `order.refunded_minor` is this one refund and `order.method` says where it went. `order.state` does not change. See [order.refunded](#order-refunded). Add it to a subscription's events to receive it. | | `order.challenge` | Layout texted the person a link to approve this purchase, and it is waiting on them. A pause, not a new state: `order.state` is where the order was when it paused, which is `building`, `carted` or `placing`. Sent once per order, and only while the order is still in flight. | | `order.needs_approval` | Reserved. Not sent. | ## Example payload Every order event carries the same payload, whatever its type, on a webhook and in `GET /v1/events`. It never carries item names, modifiers or card data: read `GET /v1/carts/:id` for a cart's items and `GET /v1/orders/:id` for the order. ```json { "event": "order.carted", "event_id": "evt_7c21e4b9a0d3_carted", "delivery_id": "dl_351eacf74201", "order": { "id": "ord_7c21e4b9a0d3", "state": "carted", "store": "Layout test kitchen", "store_address": "1 Sandbox Way, San Francisco, CA 94107", "items": 2, "total_minor": 2140, "currency": "usd" }, "user": { "id": "usr_4b8e9a01c2d3" }, "occurred_at": "2026-09-10T17:01:05Z", "sent_at": "2026-09-10T17:01:07Z" } ``` | Field | Notes | | --- | --- | | `event` | The event type (not `type`). | | `event_id` | The event: this order reaching this state, or one wave of `order.updated`. Every delivery of it, to every endpoint, on every retry and replay, carries the same id. Deduplicate on it and treat it as opaque. | | `delivery_id` | This delivery (not `id`). A retry keeps it; a replay gets a new one. | | `order.id` | The same `ord_` id `GET /v1/orders/:id` returns. The order is nested under `order` (not `data`). | | `order.store`, `order.store_address` | The store's name and address. `store_address` is null when Layout does not know it, and always null when `store` is. | | `order.items` | How many items the order holds. | | `order.total_minor` | The store's total in minor units, without Layout's service fee, the same as on `GET /v1/orders/:id`. Null until the cart is priced. The cart's `totalMinor` includes the fee, and the order's `charged_minor` is what the card was charged. | | `order.failure_code` | On `order.failed` only: the same code `GET /v1/orders/:id` returns as `failure_code`, or null when Layout recorded none. | | `order.ready_eta`, `order.ready_at`, `order.fulfillment` | On `order.updated` only: the values `GET /v1/orders/:id` returns when the event is written. `ready_at` is the pickup time as an instant, or null when Layout cannot pin the store's words to its clock. Absent when Layout could not read the order then. | | `order.refunded_minor`, `order.method` | On `order.refunded` only: the amount of this one refund in minor units, and `card` or `credit`. | | `order.simulated` | `true` on a simulated sandbox order. See [Simulated orders](#simulated-orders). | | `test` | `true` on a console test delivery, a fired test event and `endpoint.verification`. | | `user.id` | The `usr_` id you provisioned. For a person who connected through your OAuth client, a `usr_` id that is stable for your application and differs in every other one; `POST /v1/users/:id/grant` does not accept it. | | `occurred_at`, `sent_at` | When the order changed, and when Layout wrote the delivery (kept on a retry, restamped on a replay). Order events by `occurred_at`, never by arrival. | `simulated` and `test` are absent, never `false`, when not true. `endpoint.verification` has its own shape with no `order` or `user`: see [Endpoint verification](https://docs.layout.link/webhooks#endpoint-verification). ## order.refunded Sent for each refund on an order. It changes no state, so an order that was `placed` stays `placed`; the deprecated `refunded` state is never sent. `order.refunded_minor` is the amount of this refund, in minor units, and `order.method` is `card` when it goes back to the person's card, or `credit` when it is Layout credit on their account. `GET /v1/orders/:id` `refunded_minor` is the total refunded so far. A second refund has its own `event_id`, ending `_2`, so de-duplicate on `event_id`. It is not part of any subscription you already have: edit the subscription and add it to its events. ## More than one order.updated Every other event is sent at most once per order. `order.updated` is sent once for each wave of pickup details that fills a detail the order did not have yet. A detail already there is never replaced. | Wave | `event_id` | | --- | --- | | First | `evt_7c21e4b9a0d3_updated` | | Second | `evt_7c21e4b9a0d3_updated_2` | | Third, and so on | `evt_7c21e4b9a0d3_updated_3` | - De-duplicating on `event_id` keeps every wave, because each has its own id. A retry or a replay of one wave keeps that wave's id. - Do not key on the order id and the event type together: that drops the second wave and everything after it. - `GET /v1/events?after=evt_7c21e4b9a0d3_updated_2` resumes right after that wave. - On each one, read `GET /v1/orders/:id` and show what it has now. The pickup time is often the last detail to arrive: see [Pickup time](https://docs.layout.link/api#pickup-time). - Test it with [test_place_pickup_later](https://docs.layout.link/sandbox#the-test-restaurants), which sends two. ## Which orders send events Events fire for orders your application caused, and only those: - Orders built for a user you provisioned, through a build session from `POST /v1/users/:id/grant`. - Orders a person placed through an [OAuth client you created](https://docs.layout.link/authentication#oauth-clients) in the console, while they are still connected to it. An order a person placed on their own, or through anyone else's app, never reaches you. Layout sends no event when a person is disconnected. ## Simulated orders A simulated sandbox order carries `"simulated": true` in its `order` object: nothing went to the restaurant and nothing was charged. A [sandbox](https://docs.layout.link/sandbox) order built from the menu Layout holds, or at a test restaurant, carries it on every event, from `order.building` on. A sandbox order built with `"engine": "real"` carries it once its confirm is simulated, and not on its events before that. On every other delivery the key is absent, never `false`, so test for `true`. `GET /v1/orders/:id` follows the same rule. --- # 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](#failure-codes). Do not tell the person "nothing was charged" on every failure: see [Holds on a failed order](#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.placed` or read `GET /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. ```json { "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](https://docs.layout.link/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](https://docs.layout.link/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](#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](#409-already-claimed). | | `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](https://docs.layout.link/card-links#errors). | | `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](https://docs.layout.link/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](https://docs.layout.link/limits#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](https://docs.layout.link/limits#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](https://docs.layout.link/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](https://docs.layout.link/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](https://docs.layout.link/card-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](https://docs.layout.link/carts#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](https://docs.layout.link/carts#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](https://docs.layout.link/card-links) 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](https://docs.layout.link/carts#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](https://docs.layout.link/carts#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](https://docs.layout.link/card-links). | | `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](https://docs.layout.link/card-links). | | `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: ```json { "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`: ```json { "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](https://docs.layout.link/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](https://docs.layout.link/environments). | | `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](https://docs.layout.link/provisioning#connecting-an-existing-account). Once they approve, grants work again. --- # Rate limits and idempotency Every ceiling that can refuse you, when to come back after a 429, and the key that stops a retry from running twice. ## Per-minute and per-hour limits Counted per credential (your client id) over a rolling window, unless the row says per person. Your calling address does not count against any of them. | Endpoint | Approved, in production | Sandbox | | --- | --- | --- | | `POST /v1/users` | 600 / hour | 600 / hour | | `POST /v1/users/:id/grant` | 1,200 / hour. A refresh does not count | 120 / hour | | `POST /v1/users/:id/verify/start` | 250 requests an hour per credential. Texts actually sent are capped by your account's [texts per hour](#texts-per-hour), which starts at 30 | 120 / hour | | `POST /v1/users/:id/verify` | 1,500 / hour | 300 / hour | | `POST /v1/users/:id/connect` | 250 requests an hour per credential. Texts actually sent are capped by your account's [texts per hour](#texts-per-hour), which starts at 30 | 120 / hour | | `POST /v1/users/:id/links` | 10 per 10 minutes, per person, and 600 / hour per credential | 10 per 10 minutes, per person, and 600 / hour per credential | | `GET /v1/orders/:id`, `GET /v1/orders` | 600 / minute with your secret, shared | 600 / minute with your secret, shared | | `GET /v1/places`, `GET /v1/places/:placeId` | 30 / minute, per person, shared. The first query in an area can take several seconds while it answers `stillChecking` | 30 / minute, per person, shared | | `GET /v1/events` | 120 / minute | 120 / minute | | `GET /v1/whoami` | 120 / minute with your secret | 120 / minute with your secret | | `GET /v1/usage` | 60 / minute | 60 / minute | | `POST /v1/sandbox/webhooks/fire` | Sandbox only | 30 / minute, and 1,000 a day per developer account | | `POST /v1/menus` | 20 per 10 minutes, per person, shared with that person's own Layout app | 20 per 10 minutes, per person, shared with that person's own Layout app | | `POST /v1/images/items` | 20 per 10 minutes, per person | 20 per 10 minutes, per person | | `GET /v1/menus/:menuId` | 120 / minute, per person | 120 / minute, per person | | `GET /v1/images/items/:imageId` | 120 / minute, per person | 120 / minute, per person | | `POST /v1/carts/:id/cancel` | 6 per 10 minutes, per person, counting only cancels that find nothing to stop | Same | ### Every request, per credential Every call also counts toward one limit per credential across every endpoint together: **3,000 a minute** for an approved application in production, and 600 a minute for sandbox, or for an application not yet approved. A credential is your secret, a person's access token, or a build grant, each counted on its own. Credentials Layout has no record of count against the calling address, at 300 a minute. A refused credential Layout issued, such as a revoked secret, counts against itself, at 600 a minute. ### RateLimit headers Every answer to a request that authenticated carries the per-credential count: ``` RateLimit-Limit: 3000 RateLimit-Remaining: 2987 RateLimit-Reset: 41 ``` `RateLimit-Reset` is the seconds until `RateLimit-Remaining` next rises. A `429` from a per-endpoint limit carries that endpoint's numbers, and one from a daily ceiling carries none. ## Seeing your limits and usage `GET /v1/usage` with your secret answers every limit on this page as it applies to you, in that key's environment, with what your developer account has spent today against each daily one. It is free. ```bash curl https://api.layout.link/v1/usage \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" 200 OK { "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "live", "approvalStatus": "approved", "resetsAt": "2026-10-02T00:00:00.000Z", "daily": [ { "name": "builds", "description": "Builds on the restaurant's own site. In sandbox, only engine real", "scope": "developer", "limit": 5000, "used": 412, "remaining": 4588, "poolLimit": 20000, "custom": false }, { "name": "users_provisioned", "description": "Users provisioned", "scope": "developer", "limit": 10000, "used": 1250, "remaining": 8750, "poolLimit": 50000, "custom": false }, { "name": "cardless_builds", "description": "Builds for a person with no card on file", "scope": "application", "limit": 150, "used": 9, "remaining": 141 } ], "perPerson": [ { "name": "builds", "period": "day", "limit": 25 } ], "rates": [ { "name": "requests", "scope": "credential", "period": "minute", "limit": 3000 }, { "name": "texts", "scope": "developer", "period": "hour", "limit": 50, "verifiedPeople": 400, "max": 1000, "next": { "verifiedPeople": 420, "limit": 51 } }, { "name": "texts_platform", "scope": "platform", "period": "hour", "limit": 2000 } ] } ``` The example is shortened: `daily` lists every daily ceiling on this page. `poolLimit` is the ceiling across every developer. `custom` is `true` when Layout set that number for your account. A pool closed to the key, such as verification texts in sandbox, has a `limit` of 0 and a `note` saying why. ## Handling a 429 Wait `Retry-After` (also `error.retryInSeconds`). No `Retry-After` means a daily ceiling: `error.limit` names it and it resets at midnight UTC. A spent build ceiling on `POST /v1/carts` or `/answer` is `build_limit` instead of `rate_limited`. Carry the same idempotency key into the retry. ```bash HTTP/1.1 429 Too Many Requests { "error": { "code": "rate_limited", "message": "Your developer account has reached its daily limit of 200 users provisioned. It resets at midnight UTC. GET /v1/usage shows every limit; request more from Usage in the console.", "limit": { "name": "users_provisioned", "scope": "account", "max": 200, "resetsAt": "2026-10-02T00:00:00Z" } } } ``` | Field | Meaning | | --- | --- | | `name` | What the ceiling counts: `builds`, `users_provisioned`, `build_sessions`, `verification_texts`, `place_reads`, `menu_reads`, `menu_knowledge`, `image_searches` or `test_events`. | | `scope` | Whose ceiling it is: `person`, `application`, `account` (your developer account, across its applications) or `platform` (Layout's own, across every developer). | | `max` | The ceiling, per day, or per rolling hour when `period` is `hour`. | | `period` | Only on the hourly ceiling on texts: `hour`. Absent means per day. | | `resetsAt` | The next midnight UTC, or on an hourly ceiling when the oldest text in the hour stops counting. | A 429 with neither `Retry-After` nor `limit`, such as an [order code limit](#order-codes), needs the message read and a backoff of at least a minute. Over MCP, the `order` tool answers a refused build with no cart and a `refusal` object whose `limit` has the same shape. ## Daily ceilings Every one resets at midnight UTC, and sandbox and production are counted separately. Your developer account is every application you have created, together. When a ceiling across every developer refuses you, the message says it is Layout's limit rather than yours. ### Order builds A build is one `POST /v1/carts`, one `POST /v1/carts/:id/answer`, or one MCP `order` `build` that starts a cart. The first row it hits refuses it. A sandbox cart opens no browser unless it is sent with `"engine": "real"`, so it counts only toward its own 10,000 a day per developer account. See [Sandbox](https://docs.layout.link/sandbox#limits). | Counted per | Sandbox | Sandbox, `"engine": "real"` | Production | | --- | --- | --- | --- | | Person, per credential | No ceiling | 25 | 25 | | Application | No ceiling | 50, raised from Usage | No ceiling for a person with a card. For a person without one, see [No-card builds](#cardless-builds) | | Developer account | 10,000 | 10 | 5,000 once your application is approved; 250 until then | | Credential, every person | No ceiling | 50,000 | 50,000 | A build with `"engine": "real"` counts toward both its application's 50 and its developer account's 10, and the first one spent refuses it. With the 10 shared by every application on the account, the 50 matters only once Layout has raised the 10. `GET /v1/usage` lists the sandbox 10,000 as `simulated_builds`, and the 10 as `builds`. To raise the 10, email [developer@layout.link](mailto:developer@layout.link), or ask from Usage in the console (see [Raising a limit](#raising-a-limit)). ### Users, sessions and texts Counted per developer account. Applications awaiting approval count on a separate counter, so they never use up an approved application's room. | Ceiling | Sandbox | Production | | --- | --- | --- | | Users provisioned | 200 | 10,000 | | Build sessions minted | 500 | 50,000 | | Verification texts | None sent; the sandbox code is `000000` | 6,000, growing to 24,000 with [verified people](#texts-per-hour) | | Approval texts Layout sends | None sent; you get the link | 6,000, growing to 24,000 with [verified people](#texts-per-hour) | Users provisioned counts `POST /v1/users` and the console's Provisioned users page together, and only a call that provisions a new person counts. Until an application is approved, provisioning from the console is held to 25 a day. Layout sends approval texts only for an approved application; otherwise `connect.connectUrl` comes back for you to deliver. ### Verified people raise hourly texts Verification and approval texts count together, per developer account, over a rolling hour. For an approved application in production the hour starts at **30** and grows by one for every 20 people you have verified, up to **1,000**. `GET /v1/usage` shows yours under `rates` as `texts`, and the next step as `next`. Sandbox keeps 120 an hour per credential. Past the hour, `verify/start` answers `429 rate_limited` with `Retry-After` and `error.limit` with `"period": "hour"`, and `connect` hands you the link to deliver instead of refusing. Ask for more hourly texts in the same way, from **Request a higher limit** on Usage in the console. ### Build grants last 15 minutes A build grant from `POST /v1/users/:id/grant` lasts 15 minutes. Send the current grant back as `refresh` before it expires to extend the same token another 15 minutes, up to 24 hours from when it was minted. A refresh never counts toward build sessions minted or the hourly limit. A grant that expired, was revoked, or is 24 hours old answers `409 grant_expired`; mint a new one without `refresh`. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "refresh": "lgb_9f3a2c" }' 200 OK { "token": "lgb_9f3a2c", "expiresAt": "2026-10-01T18:15:41Z", "scope": "build", "refreshed": true } ``` ### Order codes for build grants Codes Layout texts for a confirm have their own limits, separate from verification and approval texts. Sandbox texts nobody but applies the per-cart and per-person limits. | Ceiling | Limit | | --- | --- | | Codes texted for one cart | 4 | | Codes texted per credential, per day | 3,000, or 100 until your application is approved | | Codes texted to one number, per hour | 6 | | Codes texted to one person, per hour | 10 | Each refusal is `429 rate_limited` on `/confirm` or `/confirm/resend-code`, and nothing was confirmed or charged. A spent cart or daily ceiling has no `Retry-After`: ask the person for the newest code Layout already texted, or build a new cart. ## Place and menu reads Per developer account, on top of the person's own. A live menu read or an image search counts only when Layout opens the restaurant's site; anything Layout already holds is free. An image search also counts toward the person's 30 live reads. In sandbox a menu read answers only with the menu Layout holds, in either mode, never reads one live, never gives a knowledge answer, and counts toward none of the menu rows. | Allowance | Sandbox, or not yet approved (each environment) | Approved, in production | | --- | --- | --- | | Place reads, per developer account per day | 5,000 | 50,000 | | Live menu reads, per developer account per day | None in sandbox. 200 for an application not yet approved | 1,000 | | Live menu reads, per person per day | None in sandbox. 30 for an application not yet approved | 30 | | Knowledge answers, per developer account per day | None in sandbox. 100 for an application not yet approved | 1,000 | | Image searches, per developer account per day | 300 | 2,000 | | Place searches beyond Layout's registry, per developer account per day | None: search covers only the places Layout already holds | 200 | A spent allowance is a `429 rate_limited` whose message names the limit. Reading the same menu again within three minutes, when Layout holds no read of it, is a `429` with `Retry-After`. A spent place-search allowance is not an error: `GET /v1/places` answers from Layout's registry alone and says so in `searchLimit`. ## No-card builds, per day In production, each application has its own daily number of builds for a person with **no card on file**. It starts at zero and is set from the volume you gave when you applied. Spend it, and the next card-less build comes back asking for a card instead of running. Builds for someone with a card never touch it, and sandbox never asks for a card. See [Building without a card](https://docs.layout.link/provisioning#building-without-a-card). ## Raising a limit Every daily pool `GET /v1/usage` lists can be raised for your developer account, up to that pool's ceiling across every developer (`poolLimit`), and so can your hourly texts, and your application's own sandbox and no-card build limits. Open [Usage](https://console.layout.link/usage) in the console, choose **Request a higher limit**, and say which limit and by how much. Or email [developer@layout.link](mailto:developer@layout.link). A raised number shows in `GET /v1/usage` with `custom: true` within a minute. A raised hourly texts number shows the same `custom: true` on `texts` under `rates`, with `expiresAt` while Layout set it for a limited time. ## Idempotency Every `POST` takes an `Idempotency-Key` header, except the retired `POST /v1/orders`. Send one on every call that changes something, and carry the same key into every retry of that call. A random UUID per real intent is the simplest choice. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Idempotency-Key: 4f1d2c8e-7b6a-4e5f-9c3d-2a1b0e9f8d7c" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' ``` - **Same key and body** returns the stored answer for 24 hours, with `Idempotent-Replayed: true`, and nothing runs again. A replay is the first answer, not the current state: read the resource for that. - **Same key, different body** is `409 idempotency_conflict`. Nothing runs. - **Still running** is `409 in_progress` with `Retry-After`. On a confirm, read `GET /v1/orders/:id` instead of sending it again. - **Only successes are stored**, so an error frees the key. A `5xx` may have run: read the resource before you send anything changed. A key is 1 to 255 visible ASCII characters, scoped to your application and environment, and to the person on a person credential. Over MCP, pass `idempotencyKey` to `order` `build` the same way. ### Retrying a confirm If `POST /v1/carts/:id/confirm` times out, **send the same call again with the same key. Never a new key.** The retry gets the stored `202 placing`, or `409 in_progress` while the first is still running: read `GET /v1/orders/:id` then. Either way the person is charged once. A `500 internal` means the outcome is not known yet, not that it failed. `expectedTotalMinor` is the cart's `totalMinor`, fee included. ```bash curl https://api.layout.link/v1/carts/$CART_ID/confirm \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Idempotency-Key: 7f3c2a9e-confirm-1" \ -H "Content-Type: application/json" \ -d '{ "expectedTotalMinor": 763 }' ``` A `code_required` answer is an error, so it is not stored: send the same request again with the same key and the person's `code` added. ### The body's idempotencyKey `POST /v1/carts`, `/answer` and `/confirm` also take an `idempotencyKey` in the body, which names the cart or the confirm: 8 to 100 letters, digits, `.`, `_`, `:` or `-`. It is optional when you send the header, whose value then stands in for it and must meet the same rules. When you send both, the body key names the cart or the confirm and the header replays the HTTP answer. They need not match. A body key still in progress answers `409 in_progress` without `Retry-After`: wait a couple of seconds. --- # Versioning How the API names its versions, what can change without one, and how much notice you get before anything you rely on stops working. ## The Layout-Version header Versions are dates. The current version is `2026-10-01`, and it is the only one. Send `Layout-Version` on a request to pin the version you code against. Leave it out and you get the current version. Every answer from an endpoint, errors included, says which version answered in its own `Layout-Version` header. ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \ -H "Layout-Version: 2026-10-01" ``` A version this API does not answer is `400 unsupported_version`, and the message lists the versions it does. Nothing runs. Pin a version in production. Then a new version changes nothing for you until you change the header yourself, after reading what it changes. ## Changes without a new version Additive changes ship into the current version, so write code that tolerates them: - A new endpoint, or a new optional request field or query parameter. - A new field in a response or a webhook payload. Ignore fields you do not know. - A new error `code`. Handle one you do not know by its HTTP status. - A new event type. You receive it only once a subscription lists it. - A new `Limit` name, or a raised limit. - Message text. `message` is for a person; switch on `code`. ## How a breaking change ships A breaking change is anything that can stop working code from working: removing or renaming an endpoint, a field or an error code, changing a field's type or meaning, making an optional field required, or changing what a status code means. That is the policy going forward. `2026-10-01` is the only version, and no route answers differently by version yet. Breaking changes announced in the [changelog](https://docs.layout.link/changelog) since 2026-10-01 shipped into it, so pinning it does not hold them back. Read the changelog entry for each one. 1. **It ships as a new dated version.** The version you pinned keeps answering exactly as it did. 2. **It is announced in the [changelog](https://docs.layout.link/changelog)** on the day it ships, marked breaking, naming the new version, what changed and what to do about it. 3. **The old version keeps working for at least 180 days** from that announcement. A version is never removed without a changelog entry that gives the date it stops answering. 4. **After that date** a request that pins it is `400 unsupported_version`. A request without the header gets the current version. ## Deprecation A deprecated field stays in every version Layout answers today, marked deprecated in the [OpenAPI specification](https://docs.layout.link/openapi.json), and its answers carry a `Deprecation` header and a `Link` header with `rel="deprecation"` pointing at the changelog entry. It is removed only in a new dated version, with at least 180 days' notice. Today `User.handoffUrl`, in the `POST /v1/users` answer, is deprecated. That answer carries `Deprecation: @1792540800` and a link to [the changelog entry](https://docs.layout.link/changelog/verifying-makes-the-account), including when an `Idempotency-Key` replays it. The `refunded` order state is deprecated too and is never sent; read `refunded_minor` instead. ## What is not versioned - **The MCP server.** `Layout-Version` applies to the REST API. MCP tools are described to the model on every connection, and a change to one is in the changelog. - **Limits.** A change to a limit is in the changelog. - **The console.** It is a product, not an interface your code calls. --- # Fees and Layout One This is Layout's fee schedule, the one the [Terms and Conditions](https://layout.link/legal/terms-conditions) incorporate by reference. It is effective October 21, 2026. ## The service fee When Layout places an order for you, it adds one service fee to that order: **4% of the restaurant's total, plus 25¢.** The restaurant's total is everything the restaurant charges for the order, including its tax and its own fees. The 4% is rounded to the nearest cent. The fee is charged per order, it is the same whatever card you pay with, and it is part of the one total you see and confirm. It is never a separate charge that arrives later. On a delivery, the 4% includes the delivery fee, and it is never charged on the driver tip. ## Worked examples | Restaurant's total | Service fee | Total you confirm | | --- | --- | --- | | $3.50 | $0.39 | $3.89 | | $7.00 | $0.53 | $7.53 | | $12.34 | $0.74 | $13.08 | | $25.00 | $1.25 | $26.25 | | $40.00 | $1.85 | $41.85 | ## When there is no fee - **Nothing placed, nothing charged.** Browsing menus, building a cart and checking on an order are free. The fee applies only when Layout places an order. - **An order that is not placed.** When an order fails, the hold on your card is released, so neither the food nor the fee is charged. - **Layout One.** See below. ## Layout One Layout One is $4.99 a month. While it is active, Layout charges no service fee on any of your orders, including orders placed for you through apps built on Layout. It also lets you keep up to three cards on your account instead of one. Subscribe at [layout.link/one](https://layout.link/one). What it includes and how cancelling works are in the help center: [what Layout One includes](https://help.layout.link/en/articles/16070896-what-layout-one-includes). ## Refunds and the fee When a refund is approved for an order that went wrong, the service fee for that order is refunded with it. What qualifies and how to ask are in [how refunds work](https://help.layout.link/en/articles/16070255-how-refunds-work). ## What the restaurant charges Menu prices, tax and any fees the restaurant adds are set by the restaurant, not by Layout. Layout passes them through as the restaurant states them, and they are part of the restaurant's total above. ## Changes to this schedule A change to this schedule applies only to orders placed after it takes effect. --- # Changelog The entries below are history. A later entry can change what an earlier one says, and where an entry disagrees with a page above, the page is current. --- # Order notifications October 26, 2026 · Console A person who has the Layout app saw Layout's Live Activity on their Lock Screen for an order your application started, even when you had turned Layout's order texts off to be the only one telling them. Now one setting covers all of it. ## What changed On **App details**, **Order texts** is now **Order notifications**. It is still on for every application. Turned off, for every order your application starts, it stops: - The text telling the person their order is placed, and the later text with the pickup time, as before. - Layout's push notification that the order is placed. - The Live Activity in the Layout app: building, ready to review, placing, placed and the courier's progress. Turning off the **Cart-ready text** now also stops the Layout app's Live Activity from buzzing the person to review and place a ready cart. The card itself still moves along when **Order notifications** is on. The text saying an order did not go through, order codes, approval links and the receipt email still go whatever the settings. A Live Activity already on the person's Lock Screen when you change the setting keeps running until that order ends. ## Why it matters If your product tells the person about their order, they now hear it only from you, on every surface, including the Layout app. ## What to do Nothing. Your setting carries over and applies on its own. The console endpoint is unchanged: `PATCH /v1/console/apps/:appId/order-texts`. Every text Layout can send, and which setting covers it, is in [Texts Layout sends](https://docs.layout.link/texts). ## Breaking changes None in the API. If you turned Order texts off and counted on Layout's push notification or Live Activity reaching people who have the Layout app, those stop too; tell the person yourself from `order.placed` and `order.updated`. --- # Connect, grants and account closing October 25, 2026 · API · Breaking Three changes to what your integration can rely on, and one fix. ## Connect return URLs are registered ones `returnUrl` on [POST /v1/users/:id/connect](https://docs.layout.link/api#post-v1-users-id-connect) must now be exactly one of the [return URLs registered](https://docs.layout.link/card-links#return-urls) for your application, the rule card links already follow. A URL you did not register is not put in the consent link, and the answer carries `returnUrlIgnored: true`; the person finishes on Layout's page instead of being sent back. **What to do.** Register the `https` URL you pass as `returnUrl` under **Return URLs** in the console, and check your connect answers for `returnUrlIgnored`. A later change will answer `400` `invalid_request` for an unregistered URL, as card links do. ## Build grants read their own orders With a build grant, `order_status` and `POST /v1/order/placement-status` answer only for orders your application built, whether you pass an `ord_` id or an order's own id. Any other id answers `order_not_found`. **What to do.** Nothing, if you poll the ids your own builds returned. ## Closing an account ends its connections Closing your developer account now disconnects every OAuth client your applications owned, so their tokens stop refreshing, and retires your console sign-in test accounts. A suspended account cannot be closed from the console; contact Layout. ## Return URLs into the Layout app A return URL may not use the Layout app's own `layout` scheme. Fixed. ## Breaking changes Yes. An unregistered connect `returnUrl` no longer sends the person back to it. A build grant that polled an order it did not build now gets `order_not_found`. --- # Layout CLI 0.5.0 October 25, 2026 · Console Version 0.5.0 of the Layout CLI is out. Update with `npm i -g @layoutplatforms/cli`. `layout --version` prints yours. ## What changed - **`layout check`** grades your integration from the last 24 hours of sandbox. Each check that does not pass says what to do. It exits 0 when nothing failed and 1 otherwise. - **`--forward-to `** sends your webhook handler four test events, signed with your sandbox signing secret or with **`--secret `**. Without it the webhook handler check fails. - **`--no-webhooks`** skips the webhook handler check, for an integration that reads `GET /v1/orders`. - **`--since <30m|2h>`** grades a shorter window, up to `24h`. - **Confirms with a code** and **Confirms again after price_changed** grade an order built with `"simulate": "code"` and one built with `"simulate": "price_changed"`. - **`layout listen --secret `**, at least 16 characters, signs with that secret instead of a new one each run. Pass the same one to `layout check --secret`. - Every POST `layout api` and `layout test-order` send carries an `Idempotency-Key` starting `cli-` unless you give one, and `check` leaves out the test people those carts were built for. - **`layout test-order --place [--simulate ] [--latency ]`** runs a whole sandbox order at a real restaurant, ordering the first item on the menu Layout holds. ## What to do Run `layout check --forward-to `, or `layout check --no-webhooks`, until every check passes. See [Check your integration](https://docs.layout.link/cli#check-your-integration). ## Breaking changes None. --- # Logo changes go to review October 25, 2026 · Console · Breaking Your logo is shown on the consent screen and on card links, the places a person decides to trust your application. A change to it is now reviewed the way a change of name is. ## What changed Uploading, replacing or removing the logo of an approved application returns it to `pending`. It keeps working in sandbox, and live keys work again once Layout approves it. The help line beside the logo on **App details** says so. ## Why it matters While an application is pending, live requests for people who are not test accounts are refused, over the API and over MCP. ## What to do Settle your logo before you ask for production. If you need to change it later, plan for a short review, and email [developer@layout.link](mailto:developer@layout.link) with your application ID. ## Breaking changes Yes. An approved application that changes its logo stops working live until it is approved again. --- # Sandbox carts at real restaurants October 25, 2026 · Sandbox · Breaking In sandbox, `POST /v1/carts` and the MCP `order` tool build a cart at a real restaurant from that restaurant's menu as Layout holds it, with its real items and choices. No browser opens, and the same code you ship builds it. ## What changed - **`simulate`** on a sandbox cart picks its ending: `placed` (the default), `failed`, `unconfirmed`, `challenge`, `code`, `card_declined`, `price_changed`, `closed` or `pickup_later`. - **`latency`**: `realistic` (the default) takes 30 seconds for one item and 10 more for each item after it, at most 90; `instant` skips every wait. - **`engine`**: `shadow` (the default) builds from the menu Layout holds; `real` builds on the restaurant's own site, 10 a day per developer account. - Production refuses `simulate`, `latency` and `engine` with `400 invalid_request`, and MCP with `sandbox_only`. - **`409 menu_not_held`** answers a sandbox cart or menu read at a restaurant whose menu Layout does not hold, or a sandbox cart that names no place. - **Sandbox places** searches and browses list only restaurants whose menu Layout holds. A list emptied for that reason carries `emptyBecause: "no_held_menu"`. - **Sandbox `POST /v1/menus`** answers only with the menu Layout holds, the one a sandbox cart is priced from, and never reads one live. - **MCP `menu`** is on sandbox build sessions, and answers the same way. [mcp-tools.json](https://docs.layout.link/mcp-tools.json) lists the tools a sandbox session reads differently as `sandboxSessionTools`, and the session's own `instructions`. - **Sandbox places** list a restaurant only when its held menu shows at least 3 items. An option that takes money off reads like `No Rice (-$1.00)`. - **`soldOut: true`** marks a menu item that is sold out. - **`GET /v1/usage`** lists sandbox builds that open no browser as `simulated_builds`, 10,000 a day. In sandbox, `builds` counts only builds with `"engine": "real"`. ## What to do Build sandbox carts with a `placeId` from a sandbox places search. Test each ending with `simulate`, and `"latency": "instant"` in CI. See [Sandbox](https://docs.layout.link/sandbox). ## Breaking changes A sandbox cart opens the restaurant's site only with `"engine": "real"`. A sandbox cart or menu read at a restaurant whose menu Layout does not hold answers `menu_not_held`. The `GET /v1/usage` pool `test_restaurant_builds` is renamed `simulated_builds`. --- # Card links replace any card October 24, 2026 · API A card link on a full account no longer stops. The person sees the card on file, picks which one to replace when they hold more than one, and enters the new card on the same page. ## What changed - **Any card can be replaced.** Before, on an account the person signs in to, a card link replaced only a card your own card links added, and otherwise ended on *"No room for another card"*. Now it can replace any card on the account. The page names that card before anything is typed and asks again before it saves. - **The person is told.** When a card link replaces a card your application's card links did not add, Layout emails the person which card was replaced, and by which application. - **`reason=account_full` is now rare.** It comes back only when the account filled up while the page was open. Keep handling it. - **A card being paid with is locked.** When an order is being paid for with the card the page would replace, the page says so before anything is typed, and going back returns `status=canceled`. ## Why it matters A person who already added a card in Layout could not use your card link to change it. Now the link is the one way to change a card from your application. ## What to do Nothing. The same `POST /v1/users/:id/links` call and the same return URLs work as before. See [What the person sees](https://docs.layout.link/card-links#what-the-person-sees). ## Breaking changes None. --- # Preselected choices on menus October 24, 2026 · API When a restaurant preselects choices on an item, each option group on `POST /v1/menus` and the MCP `menu` tool now carries `defaults`: what the item comes with, written exactly as in `options`. ## What to do Show the options in `defaults` as already selected. A group can have more than one. A required group without `defaults` still needs the person to pick. When ordering, send the dish name alone if nothing changed, or the name and only what the person changed: Don't list the defaults: Layout orders the item as it comes. ## Breaking changes None. `defaults` is optional and absent when nothing is preselected. --- # Choices on usual menus October 23, 2026 · API A usual menu, marked `scope: "chain"`, now carries each item's choices in `optionGroups`, on `POST /v1/menus` and the MCP `menu` tool. Before, it had names, sections and photos only. See [POST /v1/menus](https://docs.layout.link/api#post-v1-menus). ## What changed - **The same shape as a location's own menu.** Each group has `name`, `required` and `options`, and `moreOptions` when a group has more than 24. - **No prices.** An option on a usual menu never carries a price, because prices are each location's. `pricesUnavailable` is still `true`. ## Why it matters You can show a person what they can choose for a dish at a location Layout has not read yet, on the first call. ## What to do Nothing, if you already read `optionGroups` on a location's own menu. Show no price for an option on a usual menu. ## Breaking changes None. `optionGroups` already exists on menu items; it now also appears on a usual menu. --- # Usual menus answer first October 23, 2026 · API When you ask for the menu of a location Layout has not read yet, `POST /v1/menus` and the MCP `menu` tool now answer at once with the restaurant's usual menu, if Layout knows it. Before, the answer was `status: "reading"` and the usual menu came back only if that read failed. See [POST /v1/menus](https://docs.layout.link/api#post-v1-menus). ## What changed - **Order of answers.** This location's own recent menu first. Then the restaurant's usual menu, marked `scope: "chain"`. A live read starts only when Layout knows neither. - **No allowance spent.** A usual menu draws nothing from your live menu read allowance. The per-person request limit still applies. - **The location is still read.** Asking counts toward Layout reading this location, so a later ask can return its own priced menu, with no `scope`. ## Why it matters You get a menu on the first call for most locations of a restaurant Layout already knows, instead of polling a read. ## What to do Nothing, if you already handle `scope: "chain"`. If you do not, treat it as what this location probably carries, not its exact menu, and show no prices for it. ## Breaking changes None. No field was added or removed. You may see `scope: "chain"` more often, and `status: "reading"` less often. --- # Card status and picked places October 22, 2026 · API · Breaking You can now check a person's card before you build for them, and ask for exactly the restaurants your app shows instead of sorting search results yourself. ## What changed - **GET /v1/users/{userId}** answers `id`, `status`, `authorized` and `card`, with your secret key. `card` is `{ "onFile", "orderable", "brand", "last4" }` for the card Layout would charge, and `null` until the person has authorized your application. See [GET /v1/users/:id](https://docs.layout.link/api#get-v1-users-id). - **The verify answer carries the same `card`** once `authorized` is `true`. - **A cart read on a build grant now carries `card`**, the card that will be billed, so your confirm screen can name it. It used to be `null` on a build grant. - **`query` on GET /v1/places takes up to three names separated by commas**, such as `sweetgreen,chipotle,cava`. Each is searched and they come back as one list: each place once, open places first, then nearest first. Each name counts as one place read and answers at most 10 places. - **`ids` on GET /v1/places** reads up to 25 places you already picked, in the order you sent them, with their status now. Closed places are included, each id counts as one place read, and an id Layout no longer holds comes back in `missing`. - **`include=closed`** also lists places that are shut now or whose hours Layout does not hold, after the open ones, from the places Layout already holds. **`ordering=confirmed`** lists only places where Layout has found an online ordering route, and an empty answer says `emptyBecause: "none_confirmed"`. - **`limit` goes up to 25**, from 10. - **Every place carries `lat`, `lng`, `closesAtIso` and `opensAtIso`**, on GET /v1/places and GET /v1/places/{placeId}. ## Why it matters Until now the only way to learn that a person had no card was to build and get `card_required` back, and the only proof a card link worked was the return URL, which anyone can open. Now you can send a card link right after verify when `card.orderable` is `false`, and confirm a `status=card_added` return from your server. An app built around a few chains, or a short list of restaurants, can ask for exactly those in one call and render the answer as it comes, with coordinates for a map and instants it can compare to its own clock. ## What to do Nothing, unless you want the new behaviour. To check a card after verify: To show a chain app's stores near the person, open ones first: To refresh places you saved: In sandbox every verified person reads as holding a test Visa ending 4242, because a sandbox build never needs a card. A sandbox cart's `card` is `null`, since nothing is charged. ## Breaking changes A comma in `query` now separates names. A query that used one as punctuation, such as `coffee, San Francisco`, is now two searches that count as two place reads, and a query with more than three names is a `400 invalid_request`. Put a place in `near` instead. Nothing else changes for existing calls: every other field is new, and a build grant's cart now fills `card` where it was `null`. --- # Choose what a webhook receives October 22, 2026 · Webhooks A subscription now chooses its environment, and an endpoint that has not verified no longer drops its events. ## What changed - **`receives`.** `POST /v1/console/apps/:appId/webhooks` and `PATCH .../webhooks/:id` take an optional `receives`: `"sandbox"`, `"live"` or `"both"`, the default. Anything else is `400 invalid_receives`. The console has a **Receives** selector, and `POST /v1/cli/apps/:app/webhooks` accepts the same field. The subscription object carries `receives`. - **Held and expired deliveries.** While an endpoint has not verified, its events are `held`, in the delivery log and the events feed. A held delivery becomes `expired` after 72 hours, past 1,000 held for one endpoint, or when the endpoint is removed or verified while switched off. Neither can be replayed. - **Automatic re-verification.** After a failed verification Layout checks the endpoint again after about 1 minute, 10 minutes, 1 hour and 6 hours, then daily for 7 days. `verification.nextCheckAt` on the subscription says when, or is `null`. These checks do not count against the 50 manual verifications a day, and each manual verify restarts the schedule. On the first 2xx the held events are delivered in order. - **`POST /v1/cli/apps/:app/webhooks/:id/verify`** with `{ "environment": "sandbox" | "live" }` picks the signing secret tried first and answers `200 { environment, subscription }`. It works on an approved application. Errors: `404 not_found`, `403 app_suspended`, `429 rate_limited`, `503 unavailable`. ## What to do Set a development tunnel to `sandbox` so it never sees a production order once your application is approved. See [Events held while an endpoint is unverified](https://docs.layout.link/webhooks#held-events). ## Breaking changes None. A webhook endpoint used to have no environment; the default `both` keeps every existing subscription receiving what it did. --- # Layout CLI 0.4.3 October 22, 2026 · Console Version 0.4.3 of the Layout CLI is out. Update with `npm i -g @layoutplatforms/cli`. `layout --version` prints yours. ## What changed - **`layout webhooks add [--environment sandbox|live|both]`** says which environment's events the endpoint receives. - **`layout webhooks verify [--live]`** sends the endpoint the verification event now. It exits 0 when it answers with a 2xx, and exits 1 with `endpoint_not_verified` when it does not. - **`layout webhooks list`** prints the columns ID, STATE, RECEIVES, VERIFICATION, LAST ERROR, URL and EVENTS. - **`layout refusals [--live]`** lists why the application's keys were refused recently. It needs the capability `refusals:read`. - **`keys list --live`, `webhooks list --live` and `refusals --live`** no longer need `layout login --live`. `listen` and `deliveries` still do. - **An unknown flag or test place** ends with `This is @layoutplatforms/cli 0.4.3; newer versions may support this: npm i -g @layoutplatforms/cli`. - **`keys create --replace`** prints `New sandbox key, same client id. The old secret works until UTC.` - **`layout test-order`** runs the new test place `test_place_card_declined_confirm`. ## What to do If a script parses the table from `layout webhooks list`, read `--json` instead. See [Commands](https://docs.layout.link/cli#commands). ## Breaking changes None. The new columns in `webhooks list` are for people; `--json` is unchanged apart from added fields. --- # Card links for every app October 21, 2026 · API · Breaking Every application can now mint a card link for a person it provisioned and verified. It is how that person adds a card. ## What changed - **`POST /v1/users/:id/links`** with `{ "purpose": "card" }`, and optionally a `returnUrl` and a `state`, answers `201` with a `url` on `layout.link/h/` and its `expiresAt`. The link works once and must be opened within 15 minutes. - **The page.** It shows your application's name and logo, says your application partners with Layout and never sees the card, takes a card, Apple Pay or Google Pay, and asks for no Layout sign-in. - **Return URLs.** Register them on **App details** in the console: `https`, your app's own scheme, or `localhost` for sandbox. The page sends the person back with `status=card_added` or `status=canceled`, and your `state`. - **A card link only adds a card.** On an account that already holds all the cards it can, it replaces only a card one of your own card links added. Otherwise the page tells the person their account is full, nothing is saved, and going back returns `status=canceled`. It never removes a card the person or another application added. - **No url on `card_required`.** For a person you provisioned, a build that fails with `no_card` or `card_not_orderable`, and a confirm that answers `build_only` with `card_required`, carry no `url`. Mint a card link instead. **Update 2026-10-21:** A card link adds only on an account the person signs in to. On an account nobody signs in to, which is every account a verify made, a card link on a full account replaces the default card, and the page names that card before they save. See [What the person sees](https://docs.layout.link/card-links#what-the-person-sees). ``` POST /v1/users/usr_4b8e9a01c2d3/links { "purpose": "card", "returnUrl": "com.acme.assistant:/layout/card", "state": "c1f9e2" } → 201 { "url": "https://layout.link/h/3q2Xw7…", "expiresAt": "2026-10-21T17:15:00.000Z", "purpose": "card" } ``` ## Why it matters A person you provisioned has no Layout sign-in to use, so a page that asked for one stopped them. The card link asks for nothing but the card, and brings them back to your app. ## What to do Register a return URL in the console. When Layout asks for a card, mint a link and open it: as a sheet in an iOS app, in `ASWebAuthenticationSession` or `SFSafariViewController`, or as a link in a chat or text. See [Card links](https://docs.layout.link/card-links). ## Breaking changes Code that sent `failure.next.url` or `error.next.url` on `card_required` to a person you provisioned gets no url now. Mint a card link for them instead. --- # Disconnect a person with DELETE October 21, 2026 · API `DELETE /v1/users/:id` is open to every application, with its secret key. ## What changed ``` DELETE /v1/users/usr_4b8e9a01c2d3 → 200 { "id": "usr_4b8e9a01c2d3", "status": "revoked" } ``` Every build grant for the person stops working at once, and their standing agreement that your application places orders for them ends. Their Layout account, cards and orders stay theirs and are not deleted. Repeating the call answers the same. Provisioning the same number again later gives the same `id` back as a new invitation, and the person verifies again. ## Why it matters When a person leaves your product, you can cut your application's access to them yourself, without asking them to disconnect you in Layout. ## What to do Call it when a person closes their account with you. See [Disconnect a person](https://docs.layout.link/provisioning#disconnect-a-person). ## Breaking changes None. --- # Layout CLI 0.4.2 October 21, 2026 · Console · Breaking Version 0.4.2 of the Layout CLI is out. Update with `npm i -g @layoutplatforms/cli`. `layout --version` prints yours. ## What changed - **`layout webhooks add [--events ] [--name ]`** adds an endpoint, for every event unless you name some. Layout sends it `endpoint.verification` and turns it on when it answers 2xx. If it does not, the endpoint is added but off, and the command exits 1 with `endpoint_not_verified` and the `subscriptionId` on stderr, or 6 when Layout could not send the verification yet. **`layout webhooks remove --yes`** removes one. Both work while the application is not approved, and are refused with exit code 4 once it is: an endpoint has no environment, so one added to an application under review also receives its live events once it is approved. The console still adds and removes endpoints at any time. A CLI session's capabilities now include `webhooks:sandbox`. - **`layout webhooks list`** shows the sandbox signing secret as its prefix and last four, such as `whsec_...wxyz`. Add `--show-secret` to print it whole. With `--json`, `signingSecretMasked` says which you got. - **`--app`** takes an application's id or a name only one application has. A name several applications share exits 2 and lists them; an unknown name exits 5. With `--json`, every error that asks you to pick an application carries `apps`, each with `id`, `name` and `status`. `layout init --app` works the same way. - **`layout usage`** lists every sandbox allowance and rate `GET /v1/usage` lists, with today's use, and says whether each counts for the application or for your developer account. With `--json`, when `daily` is there it no longer carries the older `sandbox` and `live` blocks. - **`layout test-order`** also runs `test_place_needs_choices`, `test_place_needs_location`, `test_place_pickup_later` and `test_place_delivery_far`. A `price_changed` refusal is its own step with the old and new totals, and the JSON `order` carries `confirmedTotalMinor`. - **`layout listen`** says on stderr when Layout cannot start recording for the run, and asks again every 10 seconds; `recordsWithoutEndpoint` is false until it does. Ctrl-C stops it at once. - A `405` from `layout api` exits with code 2. The update notice is shown on stderr also when no terminal is attached, never with `--json`. ## What to do If a script reads the signing secret from `layout webhooks list --json`, add `--show-secret`. If a script reads `usage.sandbox` or `usage.live` from `layout usage --json`, read `usage.daily` instead. See [Commands](https://docs.layout.link/cli#commands). ## Breaking changes - `layout webhooks list` masks the signing secret unless you add `--show-secret`, in `--json` too. - `layout usage --json` no longer carries the `sandbox` and `live` blocks when `daily` is there. - A `405` from `layout api` now exits 2, where it was 1. --- # MCP builds match REST October 21, 2026 · MCP · Breaking ## Store names - A build session's `build` with a `placeId` and a `restaurant` or `restaurantName` now compares every word of the name against the store's name and address, as `POST /v1/carts` does. A name the store does not carry answers `storeMismatch` and builds nothing. - A `build` or `preview` for a name Layout finds no restaurant by answers `store_not_found`, on a preview as `preview.outcome: "store_not_found"` with `preview.storeNotFound.candidates`. Its candidates are restaurants with other names within about 30 miles. Offer them by their own names, never as branches, and build at one only if the person picks it, with its `placeId` and its own name as `restaurant`. - In sandbox, a name or `test_place_` id that no test restaurant carries answers `store_not_found`, with the test restaurants as candidates. See [What a preview concluded](https://docs.layout.link/mcp#what-a-preview-concluded). ## Cancel while building A build grant can now cancel a cart that is still building: `order` with `action: "status"`, `cancel: true` and that build's `idempotencyKey` answers `cancel.status: "canceling"`, and Layout releases the cart the moment the build lands and never texts the person about it. It works only for a build your application started; any other key answers `no_active_order`. In [mcp-tools.json](https://docs.layout.link/mcp-tools.json) it is the session action `cancel_build`. A cancel naming neither an `orderId` nor an `idempotencyKey` is still refused as `forbidden`. See [Confirm with the person's code](https://docs.layout.link/mcp#confirm-with-a-relayed-code). ## ord_ ids on every action `orderId` takes the `ord_` `publicOrderId` on `order_status`, `confirm` and a cancel, and every answer is the same as for the id the build returned. A build grant reads only its own application's orders. See [The tools](https://docs.layout.link/mcp#the-tools). ## Carted, expired and canceled carts - A ready cart nobody has confirmed, read by `orderId`, answers `placement.status: "carted"` with `totalMinor`, `price` and `expiresAt`, where it answered `building`. It waits on the person, so stop polling it. - A cart past its confirm window reads `cart_expired` by key, and `failed` with `expired: true` by `orderId`. Its answer carries `failureCode: "CART_EXPIRED"`, where it was `OUR_TIMEOUT`, test restaurants included. - Reading an expired cart with `order_status` closes it, as a REST read does, so `order.failed` is sent at once. - A cart the person canceled reads `canceled` by key, and by `orderId` its message says it was canceled. ## Confirm totals A build session's confirm carries `totalMinor`, the total you confirmed with, fee included; `merchantTotalMinor`, the restaurant's own total; and `subtotalMinor`, the food before tax, absent when Layout cannot read it. They are on every answer about an order that went ahead: `placing`, `placed`, `confirmed`, `already_placing` and an `alreadyConfirmed` answer. See [Confirm in sandbox](https://docs.layout.link/mcp#confirm-in-sandbox). ## Waits you can act on A confirm or resend answered `rate_limited` with no `reason` now carries `retryAfterSec`. `too_many_attempts` and `limiter_unavailable` carry `retryAfterSec` too, and `too_many_attempts` carries `lock`: `person`, `requester` or `spacing`. ## grant_expired An expired build grant sent to `mcp.layout.link` now answers `401` with `error.code: "grant_expired"` and `WWW-Authenticate: Bearer error="invalid_token", error_description="grant_expired"`, as on `api.layout.link`. Mint a new grant. Any other refused grant still answers `unauthorized`. A secret key sent to `mcp.layout.link` is `401 unauthorized` with `WWW-Authenticate: Bearer error="invalid_token"` and a message naming the build grant. See [MCP refusals](https://docs.layout.link/authentication#mcp-refusals). ## Smaller changes - [mcp-tools.json](https://docs.layout.link/mcp-tools.json) adds `buildSessionTools`, the tools as a build grant's `tools/list` returns them, rendered on [MCP tools](https://docs.layout.link/mcp-tools#build-session-tools). - The `order` and `order_status` output schemas in a build grant's `tools/list` no longer list `build.confirmationCard`, which a build session never gets. - A simulated challenge carries `smsSent: false`. ## Breaking changes - A build session's `build` whose name has a word the store does not carry now answers `storeMismatch`, where it could build. - A build session's `build` for a name Layout cannot find now answers `store_not_found`, where it could ask which of several other restaurants. - A build session's confirm `subtotalMinor` is now the food before tax, not the restaurant's total. Read `merchantTotalMinor` for that, and `totalMinor` for what was confirmed. - Code that polls a carted cart by `orderId` until it leaves `building` now gets `carted` at once, and a poll of an expired cart answers `cart_expired` or `failed`. - Code that matches `OUR_TIMEOUT` for an expired cart over MCP now gets `CART_EXPIRED`. - Code that matches `unauthorized` for an expired build grant now gets `grant_expired`. - A build session's `build` that asks for delivery builds nothing and answers `deliveryUnavailable: { reason: "not_enabled" }`, where it used to build a pickup. Everything else here is a new field, value or action, or a call that used to be refused, and code that ignores what it does not know keeps working. --- # No per-person code setting October 21, 2026 · API · Breaking ## What changed - A person can no longer turn on a code for every order in Layout. A live confirm asks for the 6-digit code only for the other reasons in [When a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). - `codeReason` no longer has the value `person_setting`, on a cart or on `code_required`. - In sandbox, the cart and the `code_required` error at `test_place_code` now say `codeReason: "risk_check"`, where they said `person_setting`. The code is still `000000` and nothing is texted. ## Why it matters Fewer live confirms need a code. A person whose orders were asking for one only because of that setting now places in one call when no other reason holds. ## What to do Nothing for live orders. Keep handling `code_required` on every confirm. If you tell the person why a code is needed, you can drop the line for `person_setting`. If a sandbox test checks the reason at `test_place_code`, expect `risk_check`. ## Breaking changes A test that expects `codeReason: "person_setting"` at `test_place_code` now fails, because the reason there is `risk_check`. --- # Request production in the console October 21, 2026 · Console · Breaking Moving an application from sandbox to production now starts in the console. Open **App details** and choose **Request production**. A full-screen form asks a few questions, one at a time, and sends them to a person at Layout for review. Anything you gave us at sign-up arrives filled in. ## What changed - **The request is a form.** It asks for your app's name and logo, your developer name and legal entity, your website, a one-line subtitle (30 characters at most), a description, a category, your privacy policy, terms of service and support contact, how people approve an order in your app, and the person Layout should contact. Each screen saves as you go. - **It writes your marketplace listing.** Approved apps appear in the Layout connector marketplace in the Layout app, under their category, with the subtitle, description and links from the form. Listing is on by default, and you can turn it off on the last screen. We still need the details to review your app. - **Reviews come back by email.** You get an email when you submit. If Layout declines, the email and the console say why, and **Edit and resubmit** sends it again. Approval still emails you as before. - **Approved apps can edit their listing.** On an approved app the button reads **Edit listing**. Listing changes go live straight away. Renaming the app still sends it back to review. - **The CLI points to the console.** `layout request-live-access` no longer files a request. For an app that is not approved it exits with `request_in_console` and the console link in `error.requestUrl`. A live call from an unapproved app answers `not_approved` with the same link. ## Why it matters A production review asks about your company, your policies and what people will see, which is a person's answer to give. The form keeps it in one place, fills in what we already know, and turns the same answers into your listing, so people can find your app in the Layout app. ## What to do If your app is waiting on review, open App details and submit the form. Apps approved before today keep their listing; add a subtitle, description and category from **Edit listing** so it reads well. Coding agents that ran `layout request-live-access` should hand the `requestUrl` to their person. ## Breaking changes `layout request-live-access` exits non-zero with `request_in_console` for an app that is not approved, where it used to answer `requested`. Nothing else about sandbox or production changes. --- # Verifying makes the Layout account October 21, 2026 · API · Breaking In production, the code a person reads back to `POST /v1/users/:id/verify` now makes their own Layout account, on the number they just proved. There is no join page for anyone you provision. ## What changed - **Verify answers `active`.** A good code answers `"status": "active"` and `"claimed": true`, as sandbox already did, where production answered `"status": "provisioned"` and `"claimed": false`. - **One person, many applications.** When the number's only Layout account was made by another application's verify, provisioning answers `next: "verify"`, as for a new number. The text reads `Layout: 123456 connects Acme Assistant to your existing Layout account, and Acme Assistant can then place orders for you.`, and a good code connects you to that account. You learn nothing about the other application. - **A person's own Layout sign-in is unchanged.** When the person signs in to Layout on the number themselves, provisioning still answers `awaiting_user_approval` or `connect`, and verify still answers `authorized: false` with `next: "connect"`. - **New confirm step.** A confirm for a person who has not verified with your application answers `403 build_only` with `error.next.step` `verify_required`, and no url. `sign_up_required` and `link_expired` are no longer sent. - **`handoffUrl` is not for people.** It is still in the provisioning answer. Do not send anyone to it. ## Why it matters A person you provision orders from your product without ever opening a Layout page to join. A person several applications reach has one Layout account, with one card. ## What to do - Verify everyone you provision with `POST /v1/users/:id/verify/start` and `POST /v1/users/:id/verify`, and stop sending `handoffUrl`. - Handle `verify_required` on a confirm by verifying the person, then confirming again. - Tell the person about Layout in your own words before the first code reaches them. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone) and [Introduce Layout first](https://docs.layout.link/provisioning#introduce-layout-first). ## Breaking changes - Code that reads `claimed: false` after a production verify, or waits for the person to sign up to Layout, now sees `claimed: true` at once. - Code that branches on `sign_up_required` or `link_expired` no longer sees them. Branch on `verify_required`. --- # One total on MCP carts October 20, 2026 · MCP · Breaking A build session never gets Layout's confirmation card, so the build itself now carries the one total the person pays. ## What changed - **The fee is in the total.** On a build session, a carted build's `totalMinor`, `price` and `cart.totalMinor` are the restaurant's total plus Layout's service fee, unless the fee is waived. `cart.merchantTotalMinor` is the restaurant's own total. Over REST, the cart's `totalMinor` already worked this way. - **`fee_not_included`.** A build session's confirm that sends the restaurant's total alone answers `confirm.error: "price_changed"` with `reason: "fee_not_included"`. Nothing is confirmed or charged. - **Expired carts.** A confirm on a carted build more than fifteen minutes after it was ready answers `confirm_expired`, and nothing is charged. `not_confirmable_cold` now carries `reason`: `canceled`, `expired`, `closed` or `not_found`. - **Pickup only on a build session.** A build with `fulfillment: "delivery"`, `deliverTo` or `tipPercent` builds nothing and answers `deliveryUnavailable: { reason: "not_enabled" }`. It used to build a pickup. A build session's tool list no longer shows those fields. - **`order_status` on a build session.** With no arguments it follows this application's latest build when Layout can tell it is this application's, and otherwise answers `build.status: "not_found"`. An unknown `orderId` answers `placement.status: "not_found"`. A placed order carries `placement.pickup`, and a `challenge` carries `smsSent` when Layout knows whether the approval link was texted. ## Why it matters The person agrees to the amount their card is charged, and on a build session the build is the only place you can read it. Showing `price` and confirming with `totalMinor` now means showing and confirming that amount. ## What to do Show the person `price`, and confirm with the build's `totalMinor`, unchanged: ``` order { "action": "confirm", "orderId": "0b6f2a54-…", "expectedTotalMinor": 1890, "idempotencyKey": "a7c1e0d2-…" } ``` Never add up the fee yourself, and never confirm with `cart.merchantTotalMinor` or an order's `total_minor`. See [Confirm with the person's code](https://docs.layout.link/mcp#confirm-with-a-relayed-code). ## Breaking changes Yes, in two cases. A build session that confirms with the restaurant's total alone, instead of the build's `totalMinor`, is now refused with `fee_not_included`. A build session that asks for delivery now gets no cart; build again without the delivery fields. Code that already sends `totalMinor` back as it came keeps working, and the total it shows is now the one the person pays. --- # Week of October 19 October 20, 2026 · API · Breaking ## wrong_credential A real credential of the wrong kind now answers `403 wrong_credential`, with a message that names what to send. That is 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`. Your secret on a person endpoint used to be `401 unauthorized`. A credential Layout does not recognise is still `401 unauthorized`. See [Every error code](https://docs.layout.link/errors#every-error-code). ## grant_expired A build grant past its expiry is now `401` with `error.code` `grant_expired`, where it was `401 unauthorized`, and `WWW-Authenticate` carries `error_description="grant_expired"`. Mint a new grant with `POST /v1/users/:id/grant` and send it. A grant that was revoked, or is refused for any other reason, still answers `401 unauthorized`. `mcp.layout.link` answers the same way. See [Refusals](https://docs.layout.link/authentication#person-credential-refusals). ## Expired carts A ready cart nobody confirmed before `expiresAt` now ends on its own. The cart reads `expired` with `failure.code` `CART_EXPIRED`, where `failure` was `null`, and its order reads `failed` with `failure_code` `CART_EXPIRED`, where it was `OUR_TIMEOUT`. Its confirm is `409 cart_closed`, and nothing is charged. The cart is released for you, so you need not cancel it; a cancel answers `200` with `status: "expired"`. Build again with a new key. `order.failed` for an expired cart, or for a ready cart you canceled, is sent at once when you read the cart or its order, and within a few minutes otherwise. See [GET /v1/carts/:id](https://docs.layout.link/carts#get-v1-carts-id) and [Failure codes](https://docs.layout.link/errors#failure-codes). ## order_in_progress names the order A cart that fails with `order_in_progress` because the person has an order at the same store that is confirmed and paying now carries `failure.orderId` when your application placed that order. Read it until it is placed or failed, then build with a new key. When another cart is still building for the person, the message says so and there is no `orderId`. A sandbox test order no longer blocks a build at a real store, and a second cart at a test restaurant while the person's order there is placing now fails with `order_in_progress`, so you can test it. ## Store names - On `POST /v1/carts` and `/answer`, every word of `restaurantName` must appear in the store's name or address, allowing small differences in how a name is written. A word the store does not carry, such as another branch, is `409 store_mismatch`. - A `restaurantName` that matches no store by that name is `409 store_not_found`, not `store_ambiguous`. `candidates`, when present, are other stores the search found; send one only if the person says it is the one they meant. `store_ambiguous` lists only stores whose names match. - After `store_ambiguous`, you may send the `placeId` the person picked with the `restaurantName` you first sent: a `placeId` Layout offered for that name in the last 30 minutes is never `store_mismatch`. See [POST /v1/carts](https://docs.layout.link/carts#post-v1-carts). ## Places search - `GET /v1/places` answers an empty search where Layout looked and holds no open place matching it with `emptyBecause: "no_match"`. Treat a value you do not know as an empty list. - A common drink in `query`, such as `latte`, `cold brew` or `boba`, finds the coffee and tea shops that serve it. - `localityMatched` is also false when some places come from a place matching fewer of the words in `near` than the best match Layout holds. See [GET /v1/places](https://docs.layout.link/api#get-v1-places). ## Answered carts Once a cart's question is answered, that cart reads `closed` with `replacedBy`, the id of the cart the answer started. Only the first `idempotencyKey` to answer starts a cart. An answer under another key is `409 answer_not_needed` with `error.replacedBy`, and starts nothing. Confirming the answered cart is `409 cart_closed` with `error.replacedBy`, the cart to confirm instead; it was `cart_not_ready`. See [POST /v1/carts/:id/answer](https://docs.layout.link/carts#answer). ## A real wait on every short 429 Every per-minute or per-hour `429` now carries `Retry-After` and the same wait as the new `error.retryInSeconds`, including `GET /v1/events`, `GET /v1/whoami` and the per-number verification limits. `RateLimit-Reset` is the seconds until `RateLimit-Remaining` next rises, and on a `429` it equals `Retry-After`. A `429` from `verify/start` for a code sent too recently carries `RateLimit-*` too. A confirm refused for too many wrong codes, `429 code_attempts_exceeded`, carries the real wait, and its message says why. A cart that failed with `failure.code: "retry_blocked"`, because several cancels in a row or many builds in an hour paused new builds, carries `failure.retryInSeconds`: how long until a build can start. Test restaurants never count toward it. See [Handling a 429](https://docs.layout.link/limits#handling-a-429). ## Cancels - A cancel that releases a cart, ready or still building, is never refused for rate. Only a cancel that finds nothing left to stop counts toward 6 per 10 minutes per person, and past that its `429` carries `Retry-After`, `RateLimit-*` and `error.retryInSeconds`. - A cancel of a cart that already ended is answered from the cart and never counts toward that limit. - A cancel of a cart whose order already failed answers `409 cart_closed` with `error.order`, that order with status `failed`. See [POST /v1/carts/:id/cancel](https://docs.layout.link/carts#cancel). ## provisioned_to_another_app `POST /v1/users` for a number another of your applications already holds, in the same environment, now answers `409 provisioned_to_another_app`, where it was `409 invalid_request`. Only a call that provisions a new person counts toward Users provisioned: a repeat for a number already provisioned, a refused body and this `409` give their count back. See [Users, sessions and texts](https://docs.layout.link/limits#users-and-texts). ## POST /v1/users checks its body These are now `400 invalid_request`, refused before any limit counts them: a body that is not valid JSON or not a JSON object, a `firstName`, `lastName`, `phone`, `label` or `email` that is not a string, a phone with letters or other characters, and a `connectDelivery` other than `"developer"`. Fields the endpoint does not name are still ignored. See [Fields](https://docs.layout.link/provisioning#fields). ## GET /v1/events checks its query A `cursor` this feed never wrote is now `400 invalid_request`, and so is a `limit` of 0 or anything that is not a whole number. A larger `limit` is still capped at 100. With a sandbox key, a parameter the endpoint does not take, such as `orderId` for `order_id`, is `400 invalid_request` naming the ones it does. See [GET /v1/events](https://docs.layout.link/api#get-v1-events). ## challenge on GET /v1/orders/:id While an order is `placing` and its payment waits on the person approving Layout's texted link, `GET /v1/orders/:id` carries `"challenge": { "sms_sent": true }`. It is `false` at `test_place_challenge`, which texts nobody, and absent otherwise. See [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). ## Pickup details on order.updated `order.updated` now carries `order.ready_eta` and `order.fulfillment`, the same values `GET /v1/orders/:id` returns when the event is written. No other event carries them, and both are absent when Layout could not read the order at that moment. A test `order.updated` from `POST /v1/sandbox/webhooks/fire` carries them too: the order's own when you name one, placeholders otherwise. See [Example payload](https://docs.layout.link/events#example-payload). ## fee_not_included A confirm whose `expectedTotalMinor` left out Layout's service fee now carries `error.reason: "fee_not_included"`, on the `400 invalid_request` and on a `409 price_changed` for the same cause. Show the person the cart's `totalMinor` and confirm with it. ## The standard 404 A request no endpoint handles now answers `404` in the standard shape, `{ "error": { "code": "not_found", "message": "No route answers GET /v1/…" } }`, with `X-Request-Id` and `Layout-Version`. It was a flat body with `error` as a string. See [Error shape](https://docs.layout.link/errors#error-shape). ## Limits counted once The per-minute and per-hour limits, and the per-credential count in `RateLimit-*`, now hold at the numbers [Rate limits](https://docs.layout.link/limits#per-minute-limits) lists. Sandbox now applies production's code limits too: `verify/start` meets the per-number spacing and hourly cap, `resend-code` at `test_place_code` meets the cart's four codes, the person's hour and codes in a row, and after 8 wrong codes in 15 minutes every code there, `000000` too, is `429 code_attempts_exceeded`. Nothing is texted. See [the test restaurants](https://docs.layout.link/sandbox#the-test-restaurants). ## Smaller changes - `GET /v1/whoami` lists `confirm` in a build grant's `scopes`, `["build", "confirm"]`, and `order:confirm` in an application's. - After `price_changed`, a read of the cart carries `fees.subtotalMinor`, `fees.taxAndFeesMinor` and each item's `priceMinor` when the restaurant stated them, where they were `null`. - The sandbox test menu reads a request the way a person says it: `"an iced latte, large, with oat milk"` is one latte with its size and options, `"a muffin"` is the Blueberry muffin, and number words such as `"two lattes"` or `"a dozen muffins"` set the quantity, up to 999. An option named after several items goes on the nearest item before it that offers it, and anything else said about an item stays on its line at no charge. `test_place_price_changed` puts its $1.00 rise on an item and its tax. See [The test menu](https://docs.layout.link/sandbox#the-test-menu). ## Breaking changes - Code that treats a `401` from a person endpoint as "the secret was sent there" now gets `403 wrong_credential`. - Code that matches `unauthorized` for an expired build grant now gets `grant_expired`. The status is still `401`. - A cart past `expiresAt` can no longer be confirmed. Code that reads `failure` as `null` on an expired cart now gets `{ "code": "CART_EXPIRED", "message": "…" }`, and code that matches `OUR_TIMEOUT` on its order now gets `CART_EXPIRED`. - A `restaurantName` with a word the store does not carry, sent with a `placeId`, is now `store_mismatch`. - Code that waits on `cart_not_ready` after confirming an answered cart now gets `cart_closed`. - Code that matches `409 invalid_request` from `POST /v1/users` for a number another application holds now gets `provisioned_to_another_app`. - A `POST /v1/users` body, or a `GET /v1/events` query, that Layout used to read loosely, such as a number for `firstName`, `limit=0` or an unreadable `cursor`, is now `400`. - Code that reads `error` as a string on a `404` for an unknown path now gets the standard object. - Sandbox tests that resend codes or call `verify/start` in quick succession can now get `429 rate_limited`, as production does. - A sandbox request without `items` can now build different lines, quantities and totals than before. Everything else here is a new field, header or value, or a request that used to be refused, and code that ignores what it does not know keeps working. --- # A location's usual menu October 14, 2026 · API When a live menu read fails or comes back unreadable, `POST /v1/menus` and the MCP `menu` tool now answer with the restaurant's usual menu across its locations, instead of a general knowledge answer. See [POST /v1/menus](https://docs.layout.link/api#post-v1-menus). ## What changed - **The new `scope` field.** A usual menu carries `scope: "chain"`. A menu without `scope` is still this location's own. - **Never priced.** A usual menu always has `pricesUnavailable: true` and `confidence: "partial"`, because prices vary by location. - **Own menu first.** A location's own recent menu always comes first, and rate limit refusals are unchanged. Since 2026-10-23 the usual menu also answers a location Layout has not read yet, before any live read. See [Usual menus answer first](https://docs.layout.link/changelog/usual-menus-answer-first). ## What to do If you show menus, treat `scope: "chain"` as what this location probably carries, not its exact menu, and show no prices for it. ## Breaking changes None. `scope` is new and optional, and no field was removed. --- # Kept menus on /v1/menus October 14, 2026 · API `POST /v1/menus` now answers from a whole menu Layout already read from the restaurant's own site, when it read that menu recently. These answers come back as a `200` at once: no `202 reading`, no polling, and nothing drawn from your allowance. The new optional `readAt` field says when that read happened. See [POST /v1/menus](https://docs.layout.link/api#post-v1-menus). ## What changed - **Recent menus on REST.** `POST /v1/menus` answers from a recent read, as the MCP `menu` tool already did. - **No prices, said plainly.** When none of a menu's items carry a price, `pricesUnavailable` is `true` and `confidence` is `partial`, on `POST /v1/menus` and the MCP `menu` tool alike. Until now a menu with no prices could come back with `pricesUnavailable: false`. - **Asked by name.** A menu asked for by restaurant name now finds a recent read of that exact store, the same as one asked for by `placeId`. ## What to do Nothing is required. If you show prices, check `pricesUnavailable` and never fill a missing price in. ## Breaking changes None. Nothing you send changes and no field was removed; `readAt` is new and optional. --- # Pickup details in several waves October 14, 2026 · Webhooks An order's pickup details can arrive in more than one wave, minutes apart. Every wave that fills a detail the order did not have sends its own `order.updated`. ## What changed - **More than one `order.updated`.** The first wave keeps its id, `evt_7c21e4b9a0d3_updated`. Later waves are `evt_7c21e4b9a0d3_updated_2`, `evt_7c21e4b9a0d3_updated_3` and so on, so de-duplicating on `event_id` keeps every one, and `GET /v1/events?after=` with one of them resumes right after that wave. Each one carries `order.ready_eta` and `order.fulfillment` as the order read when it was written. See [More than one order.updated](https://docs.layout.link/events#more-than-one-order-updated). - **The pickup time, when the store gives one.** `ready_eta` can be null when the order is `placed` and fill in later, with an `order.updated`. When the store never states a time it stays null. Show it word for word; it is not a timestamp. See [Pickup time](https://docs.layout.link/api#pickup-time). - **`test_place_pickup_later`.** A sandbox test restaurant at 12 Sandbox Way. Its order is placed with no pickup details. About 5 seconds later the name, the order number and the instructions arrive with `order.updated`, and about 10 seconds after placement the pickup time arrives with a second one. See [Sandbox](https://docs.layout.link/sandbox#the-test-restaurants). - **Read the type.** `fulfillment.type` says how the person gets the order. Read it rather than assuming pickup. ## Why it matters The pickup time is what the person needs to know when to walk over, and it is often the last detail a store states. With one event per wave, your product hears about it when it arrives instead of on whatever read happens next. ## What to do Subscribe your endpoint to `order.updated` if it does not already, de-duplicate on `event_id`, and read the order again on every `order.updated`: Then run an order at `test_place_pickup_later` and check that your product shows the pickup time when the second `order.updated` arrives. ## Breaking changes None for a handler that de-duplicates on `event_id`. A handler that treated the order id and the event type together as one event, or that checked `event_id` against the old pattern, keeps only the first wave and drops the rest, often including the pickup time. Switch it to `event_id`. The `EventId` pattern in the OpenAPI spec now allows the `_2` suffix. --- # Turn off order texts October 14, 2026 · Console When an order is placed, Layout texts the person that it is placed, and texts again if the pickup time arrives later. Some applications tell the person themselves and want them to hear it only from one sender. Those applications can now switch these texts off. ## What changed The console's **App details** page has an **Order texts** setting. It is on for every application, and turning it off stops two texts: - The text telling the person their order is placed. - The later text with the pickup time, sent when the time arrives after the order was placed. It covers every order your application starts, whether for users you provision or through an OAuth client you created, including orders already in flight whose text has not gone out. It never stops the text saying an order did not go through, an order code, an approval link, the receipt email or push notifications: a person who never hears that an order failed may order again and be charged twice. The **Cart-ready text** keeps its own setting. If Layout cannot read the setting, the texts are sent. ## Why it matters If your product already tells the person their order is placed and when to pick it up, they get one message from you instead of two from different senders. ## What to do Nothing, unless you want them off. If you turn them off, your application owns telling the person their order is placed and the pickup time: read them from `order.placed`, `order.updated` and `GET /v1/orders/:id`. Every text Layout can send, and which setting covers it, is in [Texts Layout sends](https://docs.layout.link/mcp#texts-layout-sends). ## Breaking changes None. The texts stay on until you turn them off. --- # Your brand on card pages October 14, 2026 · API **Update 2026-10-21:** There is no sign-in screen on card pages now. A card link from `POST /v1/users/:id/links` opens the card page directly, with your logo once Layout approves your application. See [What the person sees](https://docs.layout.link/card-links#what-the-person-sees). When a person you provisioned needs a card, the link Layout hands you opens Layout's card page. That page now says which application sent them. ## What changed - The sign-in screen shows your logo beside Layout's, and says your application uses Layout to place their orders. - The card screen says your application never sees their card. - Once the card is saved, the page tells them to go back to your application to place their order. The name and logo are the ones on your application's **App details** page in the console, sandbox or live. Only a logo you uploaded there is shown. The link carries only your application's id, as `?app=lyt_app_…`; any other parameter on it is ignored, including the `via` and `back` that earlier links carried. ## Why it matters A person who started in your product and lands on a Layout page knows they are still in the right place, and knows where to go when they are done. ## What to do Upload your logo on **App details** if you have not, and mint a card link when a card is needed. See [Card links](https://docs.layout.link/card-links). ## Breaking changes None. Links you already sent still open the card page; they show Layout's page without your name. --- # A guide for coding agents October 12, 2026 · Docs [Coding agents](https://docs.layout.link/coding-agents) is a new guide for handing a Layout integration to a coding agent. It names the one step a person does first (sign up and make a CLI token), what the agent runs from there, the rules it must follow, and a prompt to start it with. `llms.txt` now points an agent to it first. ## What changed - **One name for your secret.** Every reference example now reads the secret from `LAYOUT_SECRET_KEY`, the variable the CLI already reads, instead of `LAYOUT_SECRET`. The quickstart no longer asks for the client id, which no step uses. - **Going to production.** [Going to production](https://docs.layout.link/environments#going-to-production) said swapping in the `sk_live_` secret was the whole migration. It now also covers the production signing secret, what answers differently in production (phone numbers, test restaurants, confirms), and keeping `sk_live_` and development webhook endpoints away from production. - **Calling person routes from the CLI.** The [Call the API](https://docs.layout.link/cli#call-the-api) example sent `GET /v1/places` with your key, which answers `401`. Places, menus, item images and carts take a person's build grant or access token in `--token`. - **The listen secret.** `layout listen` signs with a new secret on every run. The CLI reference now says to give your server that run's secret each time. - **Sandbox confirms.** A sign-in test account's confirm over REST answers `placing`, as every REST confirm does. The page said `placed`. - **The retired order route.** `POST /v1/orders` is marked `deprecated` in [openapi.json](https://docs.layout.link/openapi.json), so a client generated from it flags `createOrder`, which always answers `410`. Build with `POST /v1/carts`. - **Markdown copies.** The Markdown version of every page, and `llms-full.txt`, now show characters such as `•` and `×` instead of their HTML codes. ## What to do Nothing. If you generated a client from `openapi.json` and it calls `createOrder`, move that call to [Carts](https://docs.layout.link/carts); it has answered `410` since the route was retired. ## Breaking changes None. The API did not change; the pages now describe what it already does. --- # Stopped builds read as canceled October 11, 2026 · MCP When a person or Layout stops an order while its cart is still being built, the `order` tool's `status` action now answers with `build.status: "canceled"`, the stopped order's `orderId`, and a `userMessage` saying nothing was ordered or charged. Until now it could keep answering `building` for a minute or more after the stop. ## Why it matters A status that keeps saying "building" after a stop invites the person, or your agent, to wait on an order that will never arrive, or to start the same one again. ## What to do Treat `canceled` as terminal: stop polling that key and relay the `userMessage`. Start a new build only if the person asks for one. Nothing else changes. ## Breaking changes None. `canceled` is a new value for `build.status` on a status read; every existing value means what it did. --- # Confirm with the cart's total October 10, 2026 · API · Breaking `expectedTotalMinor` on [POST /v1/carts/:id/confirm](https://docs.layout.link/carts#confirm) must be the cart's `totalMinor`. A confirm that sends the restaurant's total alone (`fees.merchantTotalMinor`) while Layout's service fee applies now answers `400 invalid_request`, and nothing is confirmed or charged. When the fee is waived, the two totals are the same and either confirms. ## Why it matters The person's card is charged `totalMinor`. A confirm with a smaller number used to go through, so an app could show the restaurant's total, confirm it, and charge the person more than they saw. ## What to do Show the person the cart's `totalMinor` and send it back unchanged as `expectedTotalMinor`. If the price moved, the answer is still `409 price_changed`. ## Breaking changes A confirm that sent `fees.merchantTotalMinor` now fails with `400 invalid_request`. The reference has always asked for `totalMinor`. The MCP `order` tool is unchanged. --- # Docs corrections October 10, 2026 · Docs The reference pages were corrected where they were wrong, stale or missing. Most corrections fill in limits and refusals that were not written down. The ones below change what an integration should do. ## What to do - **Totals.** An order's and a webhook's `total_minor` is the restaurant's total without Layout's fee. Show the cart's `totalMinor` before confirm and the order's `amounts.charged_minor` after. Never confirm with `total_minor`. See [Confirm](https://docs.layout.link/carts#confirm). - **A failed order.** Do not tell the person "nothing was charged" on every failure: a pending hold can show for a while. Retry only on the failure codes that say a retry can work. See [Failure codes](https://docs.layout.link/errors#failure-codes). - **When a code is needed.** A build grant's confirm needs the person's code when Layout would ask for one anywhere else, and on the first order of a person who connected before Layout asked for their agreement at connect. Read `codeReason` on the cart. See [When the answer is code_required](https://docs.layout.link/carts#code-required). - **Sandbox codes.** Sandbox asks for a code only at `test_place_code`, whose code is `000000`. See [Sandbox](https://docs.layout.link/sandbox). - **Unconfirmed orders over MCP.** The placement poll answers `unconfirmed`, never `failed`. Never retry it. See [MCP](https://docs.layout.link/mcp). - **Building over MCP.** A `build` needs `query` and a restaurant name with `near` or `geo`; a `placeId` alone is refused. A build session cancels with the build's `orderId` or `idempotencyKey`. See [MCP tools](https://docs.layout.link/mcp-tools). - **Location.** `near` takes a city or town name, and distances are measured from its centre, not from the person. A neighbourhood or landmark is usually not placed: send coordinates. See [Finding the nearest store](https://docs.layout.link/carts#nearest-store). - **Silent sign-in.** `prompt=none` can also answer `unauthorized_client` or `invalid_target`, which showing the button will not fix, and always answers `consent_required` to a `localhost` redirect. See [Returning people](https://docs.layout.link/sign-in-with-layout#returning-people). - **Testing with your own account.** Before your application is approved, your own Layout account cannot connect to your client. Test with a sign-in test account. See [Authentication](https://docs.layout.link/authentication). ## Breaking changes None. Nothing in the API changed with these corrections; the pages now describe what it already does. --- # Pickup details on orders October 10, 2026 · API Everything a person needs at the counter is now on the order, and the confirm code is no longer a mystery. **The order read is complete.** `GET /v1/orders/:id` adds three blocks: The `fulfillment` details are the store's own words, null until the order is `placed` and null when the store did not state them. `type` is `pickup` today; read it, because more types are coming. `charged_minor` is what the person's card is charged; `total_minor` is still the store's total without Layout's fee. See [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). **`order.updated`.** Some stores state the ready time or the name a minute after the order is placed. When that happens, one `order.updated` follows `order.placed`, with `state: "placed"`. Like every event it carries no details: read the order again. Add it to your endpoint's events in the console to receive it. See [Events](https://docs.layout.link/events). **Update 2026-10-21:** `order.updated` now carries `order.ready_eta` and `order.fulfillment` as the order read when the event was written. See [Example payload](https://docs.layout.link/events#example-payload). **`codeReason`.** A cart now says why its confirm will need the person's code: `over_limit`, `code_outstanding`, `first_order_consent` (build grants), or `unverified`, and null when none is needed. `400 code_required` carries the same field, plus `risk_check`. `codeRequired` is now checked each time you read the cart, so it matches what the confirm will do. See [When the answer is code_required](https://docs.layout.link/carts#code-required). **A live status link.** The confirm's `202` now carries `order.trackUrl`, Layout's live status page for the order. It is view-only, safe to show the person, and stops answering 15 minutes after the order ends. **`test_place_code`.** A sandbox test restaurant whose confirm answers `code_required` until it carries the code `000000`. Nothing is texted. `layout test-order --place test_place_code` runs it end to end from CLI 0.3.1. See [Sandbox](https://docs.layout.link/sandbox#the-test-restaurants). ## Why it matters An app could not tell the person what to say at the counter, which number to quote, or what they paid without keeping the cart around. And a cart could say a code was coming when the person's settings meant none would be, leaving an app waiting on a text that never came. ## What to do Show `fulfillment` once the order is `placed`, and read the order again on `order.updated`. Show the person why a code is needed from `codeReason`, and keep handling `code_required` on every confirm. Run `test_place_code` before going live. ## Breaking changes None. Every field is new. One count is corrected: `items`, on the order and on every webhook, no longer counts Layout's own tax-and-fees line, so a one-item order reads 1. --- # Places lists open places only October 10, 2026 · API · Breaking [GET /v1/places](https://docs.layout.link/api#get-v1-places) lists only restaurants that are open right now. A closed place, or one whose hours Layout does not hold, is never listed. When everything matching is shut, `places` is empty with `emptyBecause: "all_closed"`. The `openNow` query parameter is gone, and so is the `notOpenNow` list that came back beside the open places. A request that still sends `openNow` is answered as if it had not, and is never refused. [GET /v1/places/:placeId](https://docs.layout.link/api#get-v1-places-id) still reads any place, open or not. ## Why it matters The list is where an order starts, and an order at a closed store is refused anyway. Offering one only sent the person down a path that could not finish. ## What to do Stop reading `notOpenNow`. If you showed closed places from it, read them by id with `GET /v1/places/:placeId` instead. Sending `openNow` is harmless; drop it when convenient. ## Breaking changes This applies to version `2026-10-01` from 2026-10-09. `notOpenNow` is no longer sent, and `openNow` no longer changes the answer. Nothing that sends `openNow` is refused. --- # Confirmed carts stay confirmed October 9, 2026 · MCP Two changes to the `order` tool and `order_status`, both about a cart that has already been confirmed. **A confirmed cart is never reported as expired.** Polling a build whose cart was confirmed used to answer `cart_expired` with "nothing was charged", even while the order was still being placed. It now answers with `placement`, the same object `order_status` returns for an `orderId`, and no `build`: A replayed `build` with the same `idempotencyKey` answers the same way, with `"idempotency": "replay"`. `cart_expired` now means only what it says: the cart was never confirmed, or its order failed or was refunded. **A new build waits for a paying order.** While an order is confirmed and paying at a store, a build for the same user at that store answers `order_in_progress` with that order's `orderId`. Nothing is built and nothing is charged. ## Why it matters An agent told "nothing was charged" while the card was held will offer to start again, and the person ends up with two orders and two charges. Your agent now hears that the first order is still going, and which one it is. ## What to do If your code reads `build.status` from a status answer, also read `placement` when it is present, and poll `order_status` with its `orderId` for the outcome. When `order_in_progress` carries an `orderId`, poll that order instead of building again. Agents that relay `userMessage` and follow `assistantInstruction` need no change. See [order_status output](https://docs.layout.link/mcp-tools#tool-order-status-output). ## Breaking changes None. The answers that changed were wrong before: they said a cart had expired while its order was being placed. --- # Finding the nearest store October 9, 2026 · Docs A new section, [Finding the nearest store](https://docs.layout.link/carts#nearest-store), says how to find the store closest to the person before you build a cart. The quickstart and the cart example now build with a `placeId` rather than a restaurant name and a city. ## Why it matters Coordinates measure distance from the person. A city sent as `near` measures it from the centre of that city instead, so the first result is not necessarily the closest to the person. A neighbourhood or landmark is usually not placed at all. And a street address sent as `near` on `POST /v1/carts` is read as the store's own address, so sending the person's home address there builds at whichever branch matches it, which may not be the nearest. ## What to do - Ask for location permission and send the device's coordinates as `lat` and `lng` to [GET /v1/places](https://docs.layout.link/api#get-v1-places). - Without permission, ask for a street address, turn it into coordinates with your platform's geocoder, and send those. - Use a city name only when you have nothing better, and let the person choose from the results. Turn a ZIP code or neighbourhood into coordinates first. - Build with the `placeId` of the store the person picks. ## Breaking changes None. No endpoint or field changed. --- # CI tokens for the CLI October 5, 2026 · Console The CLI no longer needs a person at a browser. Create a token on the console's CLI page and set it as `LAYOUT_CLI_TOKEN`, and every command runs without `layout login`. It is sandbox only, shown once, and revocable. `layout init` picks or creates your application, keeps its sandbox key, runs a test order and prints the environment variables to save. `layout listen` now forwards your sandbox events with no webhook endpoint, so you can test a receiver on your own machine before you deploy one. ## Why it matters An agent asked to integrate Layout can now go from install to a placed sandbox order on its own. ## What to do Update with `npm i -g @layoutplatforms/cli`. See [CI tokens](https://docs.layout.link/cli#ci-tokens). ## Breaking changes None. --- # Sign in with Layout October 5, 2026 · API · Breaking Sign in with Layout is one button, in black or white, labelled *Continue with Layout* or *Order with Layout*, in three pill sizes and a circle. It sends the person to Layout's approval page, or stays on your screens with a code Layout texts them. The [guide](https://docs.layout.link/sign-in-with-layout) covers both, and the button files are ready to download. **Returning people go straight through.** A person signed in to Layout who already approved your app is sent back to your redirect URI with a code and sees no page. `prompt=consent` always shows the page, and `prompt=none` never does, answering `login_required` or `consent_required` instead. **`login_hint`** takes the person's phone number in E.164 and fills it in on Layout's sign-in step. It never sends a code by itself. **Headless verify recognises returning people.** `verify/start` on someone already connected to your app texts a fresh code, and `verify` with the right code answers the same `id`. Every `verify` answer carries `id`. If the person's Layout account no longer holds the number you provisioned, `start` answers `409` `phone_changed` and texts nobody. **The approval page says:** *"{your app} will be able to place orders for you, paid with the card on file in your Layout account. You can disconnect {your app} anytime in Layout."* ## Why it matters A person can sign in to your app with Layout once and come back on any device without approving again, and a person who changes phones is still the same person to you. ## What to do Nothing, unless you want the button. If your code treated a `409` from `verify/start` as "already verified", check the code the person reads back instead. ## Breaking changes `verify/start` answers `200` where it answered `409 already_claimed` for a person already connected to your app. --- # Pickup time on orders October 4, 2026 · API `GET /v1/orders/:id` now returns `ready_eta`: the pickup time as the store's confirmation stated it, such as `Ready by 6:15 PM`. It is there once the order is `placed`, and null before then or when the confirmation named no time. ## Why it matters A person who just ordered wants to know when to walk over. You can now tell them without asking Layout's app or the store. ## What to do When you receive `order.placed`, read the order and show `ready_eta` as written. It is not a timestamp, so do not parse it. Sandbox test restaurants answer `Ready in about 15 minutes`. See [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). ## Breaking changes None. The field is new, and the webhook payload is unchanged. --- # CLI production reads October 3, 2026 · Console The Layout CLI 0.2 covers the rest of the build: any endpoint from your terminal, and production when you need to see what happened there. It still never changes production or moves money. ## What changed - `layout login --live` asks to read production for 12 hours. The console marks the request as production and asks for a sign-in from the last 15 minutes before you can approve it. - `--live` works on `listen`, `deliveries`, `keys list` and `webhooks list`. Live keys, the live signing secret and every production setting stay in the console. - `layout api get /v1/places?query=coffee` calls any REST endpoint and prints the answer. It uses your sandbox key, a token you pass with `--token`, or `LAYOUT_SECRET_KEY`. In production it only reads, and only with your own live secret. - `LAYOUT_SECRET_KEY=sk_test_...` runs `api` and `test-order` without `layout login`, so they work in CI. - A new `layout login` revokes the session it replaces. A failed one leaves you signed in. - The CLI tells you once a day when a newer version is out. ## Why it matters You can debug a production webhook from your own machine the same way you built it in sandbox, and an agent can try any endpoint without writing a client first. ## What to do Update with `npm i -g @layoutplatforms/cli`. See [Production](https://docs.layout.link/cli#production) and [Call the API](https://docs.layout.link/cli#call-the-api). ## Breaking changes None. --- # Connecting is agreeing October 3, 2026 · API **Update 2026-10-21:** There is no per-person code setting, so no person "keeps codes on". The connect screen quoted below is today's wording. See [When a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). A build grant used to need the person's code on their first order through your application, because that code was how they agreed to let it order for them. The agreement now happens when they connect your application, and Layout says so at that moment. After that, a build grant's confirm asks for the code exactly when ordering anywhere else in Layout would. ## What changed - The connect screen now says, in Layout's words: `Acme Assistant will be able to place orders for you, paid with the card on file in your Layout account. You can disconnect Acme Assistant anytime in Layout.` Approving it is the person's agreement. - The headless verify text now reads `Layout: 123456 links this number to Acme Assistant, which can then place orders for you. It expires in 3 minutes. If you didn't ask for this, ignore this text.` Reading that code back to you is the person's agreement. Both name your application as it appears in the console. - Once a person has connected, a build grant's confirm needs the code when they keep codes on, the total is over Layout's amount limit for codes, which can change, Layout's risk checks ask for one, or a code is already out for the cart. Otherwise the confirm without a code answers `202 placing` over REST and `placing` over MCP. `codeRequired` on the cart reads the same rule. - People who connected before this change have not agreed yet. Their first order through your application still needs the code, and its text still says that sharing it lets your application place their future orders without one. - The agreement ends when the person disconnects your application or you deprovision them. Connecting again gives it again. ## Why it matters The person agrees once, at the moment they choose to connect your application, and is told plainly what that allows. Their first small order then places when they say yes in your product, the same as every order after it. ## What to do Nothing. Keep calling confirm with no code first and handle `code_required` on every confirm: the confirm decides. See [Confirming with a build grant](https://docs.layout.link/carts#relayed-code) and [Provisioning](https://docs.layout.link/provisioning). ## Breaking changes None. A confirm that already handles `code_required` and `202 placing` works unchanged. If your product told people to expect a code on their first order, that is no longer always true. --- # Carts confirmed elsewhere October 2, 2026 · API The person can confirm your cart without you: a YES to Layout's cart-ready text, the Layout app, or Layout's tracking page. Your confirm on that cart now says so, names the order and its state now, and never sends a second order. ## What changed - `POST /v1/carts/:id/confirm` and `/confirm/resend-code` on a cart whose order was already confirmed answer `409 already_confirmed`. Nothing is confirmed or charged again. `error.order` is `{ id, status, confirmedVia? }`, where `status` is `placing`, `placed`, `unconfirmed` or `refunded`, and `error.userMessage` is a sentence for the person. - `confirmedVia` is `sms` (a YES to Layout's text) or `tracking_link` (Layout's tracking page) when Layout knows, on the error and on `GET /v1/carts/:id`'s `order` for a `closed` cart. It is absent for a confirm in the Layout app or through the API or MCP. - A retry of your own confirm with the same `idempotencyKey` still gets the answer that call earned. - The MCP `order` tool's `confirm` result carries `alreadyConfirmed: true`, `orderState` and, when known, `confirmedVia`, plus `complete: false` and `nextAction: "order_status"` while the order is `placing` or `unconfirmed`. ``` 409 { "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." } } ``` ## Why it matters A confirm that lost to the person's own YES used to answer `202 placing` as if your call had placed it, even once the order was placed. On an order whose placement Layout could not verify, it answered `409 cart_closed` and suggested building a new cart, which is how a person ends up with two orders. Now your application can show the person what actually happened. ## What to do On `already_confirmed`, show `error.userMessage`, follow the order with `GET /v1/orders/:id` or the `order.*` webhooks, which go out whichever surface confirmed it, and do not confirm or build it again. Treat `unconfirmed` as pending, never as failed. See [Already confirmed elsewhere](https://docs.layout.link/carts#confirmed-elsewhere) and [Errors](https://docs.layout.link/errors). ## Breaking changes None under the [versioning policy](https://docs.layout.link/versioning): `already_confirmed` is a new error code on a 409. One answer moves: a confirm that lost the race to another surface used to answer `202 placing`, and an already confirmed cart whose order is `unconfirmed` or `refunded` used to answer `409 cart_closed`. Both now answer `409 already_confirmed` with the same order id. --- # Cancel while building October 2, 2026 · API A cancel your application sends while a cart is still building is kept and honoured. A request whose `placeId` and `restaurantName` disagree is refused instead of quietly ordering from the `placeId`'s store. And the store's address now travels with its name everywhere you and the person see it. ## What changed - `POST /v1/carts/:id/cancel` on a cart that is still building answers `202 { "cart": { "id": "crt_…", "status": "canceling" } }` instead of `409 cart_not_ready`. Layout releases the cart the moment the build lands, never offers it to the person, and never sends Layout's cart-ready text for it. `GET /v1/carts/:id` reads `canceling`, then `canceled`. A confirm on a `canceling` or `canceled` cart is `409 cart_closed`. - A cancel on a cart that was already confirmed is still `409 cart_closed`, now with `error.order`: the order and its state now (`placing`, `placed`, `unconfirmed` or `refunded`). - The MCP `order` tool's `cancel: true` takes the build's `idempotencyKey` while the cart is still building, and answers `cancel.status: "canceling"`. - `POST /v1/carts` and `/answer` with a `placeId` and a `restaurantName` that name different stores answer `409 store_mismatch`. Nothing is built. `error.store` is `{ placeId, name, address }` for the store the `placeId` names, and `candidates`, when Layout could find them, are stores matching the name. Over MCP, the build answers `storeMismatch`. - The cart's `store` carries the address whenever Layout knows it, on ready, failed, answered and closed carts. `GET /v1/orders/:id` and every webhook payload add `store_address`, null when unknown. A sandbox cart's `confirmationCard` names the address. - Layout's cart-ready text names the store and its address, and for a cart your application built it opens with your application's name from the console: `Acme Assistant built your order at Layout test kitchen, 1 Sandbox Way, San Francisco, CA 94107:`. ``` 409 { "error": { "code": "store_mismatch", "message": "placeId names Layout Test Kitchen North, which does not match restaurantName. Nothing was built. Ask the person which store they mean and send only that store's placeId, or only restaurantName with near or geo.", "store": { "placeId": "test_place_branch_north", "name": "Layout Test Kitchen North", "address": "31 Sandbox Way, San Francisco, CA 94107" } } } ``` ## Why it matters A cancel sent while a cart was building used to be refused, and the person was then texted to confirm a cart your application had abandoned. A `placeId` and a name that disagree now stop the build instead of ordering from the wrong store, and the address lets the person tell two branches apart before they pay. ## What to do Treat `canceling` as on its way to `canceled`, and never confirm it. On `store_mismatch`, ask the person which store they mean and build again with only that store's `placeId`. Show `store.address` beside the store's name. See [POST /v1/carts/:id/cancel](https://docs.layout.link/carts#cancel) and [Errors](https://docs.layout.link/errors). ## Breaking changes None under the [versioning policy](https://docs.layout.link/versioning): `canceling` is a new cart status, `store_mismatch` a new error code on a 409, and `store_address` a new field. One answer moves: a cancel on a building cart used to answer `409 cart_not_ready` and now answers `202 canceling`. A request that used to build at the `placeId`'s store despite naming another now answers `409 store_mismatch`. --- # Codes on first orders October 2, 2026 · API **Update 2026-10-21:** There is no per-person code setting. On `build_only`, a person who has not verified answers `verify_required`: verify them with `verify/start` and `verify`. A person you provisioned gets no `url` on `card_required`: mint a [card link](https://docs.layout.link/card-links). See [Every error code](https://docs.layout.link/errors#every-error-code). **Since then:** connecting your application is the person's agreement, so a person who connects after Layout began saying so owes no code just for it being their first order; the other reasons in [When a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due) still apply. See [Connecting is agreeing](https://docs.layout.link/changelog/connecting-is-agreeing). The rest of this entry is as it was published. A build grant used to need the code Layout texts the person on every live confirm. It now needs it until the person approves an order through your application with it. After that the code is asked for exactly when ordering anywhere else in Layout would ask for it. ## What changed - Until a person has placed an order through your application with the code Layout texts them, every live confirm needs the code, whatever the total and whatever their own code setting. Only that code counts: an order placed by replying to Layout's text, in the Layout app, or through an OAuth connection does not, and consent to another application does not carry over. It ends when the person disconnects your application, you deprovision them, or they connect again. - After that, a build grant's confirm needs the code when the person keeps codes on, the total is over Layout's amount limit for codes, which can change, Layout's risk checks ask for one, or a code is already out for the cart. Otherwise the confirm without a code answers `202 placing` over REST and `placing` over MCP. - A build grant's cart reads `codeRequired` from the same rule. `resend-code` on a cart that needs no code answers `409 code_not_needed` and texts nothing. - A `403 build_only` confirm now carries `error.next`: `step` is `sign_up_required`, `connect_required`, `link_expired`, `phone_required` or `card_required`, and `url`, when present, is the Layout page where the person finishes it. The MCP `confirm` result carries the same `next`. - Provisioning's `session.expiresIn` now states 900, the 15 minutes a build session lasts. ``` 403 { "error": { "code": "build_only", "message": "This person has not finished joining Layout, so the cart cannot be confirmed yet. They need to sign up to Layout with this number and add a card, then you confirm again. Nothing was charged.", "orderId": "ord_7c21e4b9a0d3", "next": { "step": "sign_up_required", "url": "https://account.layout.link/join" } } } ``` ## Why it matters The first code is how the person agrees to let your application order for them. Once they have, a second small order does not need a text, the same as in the Layout app. And your code can tell the person what to do next without reading the message. ## What to do Keep calling confirm with no code first, and handle `code_required` on every confirm: the confirm decides. On `build_only`, switch on `error.next.step` and send the person `error.next.url` when there is one. See [Confirming with a build grant](https://docs.layout.link/carts#relayed-code) and [Errors](https://docs.layout.link/errors). ## Breaking changes None. A confirm that already handles `code_required` and `202 placing` works unchanged. On MCP, a grant's `build_only` confirm result used to carry `next` as the string `"connect"` or `"provision"`; it is now the object above. --- # Grants read their own orders October 2, 2026 · API ## What changed - **A build grant reads its own orders.** `GET /v1/orders/:id` with a build grant used to answer `403`. It now returns the order when a build grant from your application built it for that same person, in the same environment. Any other order, including one another application built or one built for another person, is a `404`, the same answer as an order that does not exist. See [GET /v1/orders/:id](https://docs.layout.link/api#get-v1-orders-id). - **whoami lists each scope once.** With your application secret, `GET /v1/whoami` returned `order_status` beside `order:status`. `scopes` is now `["order:preview", "order:build", "order:status", "places"]`. See [GET /v1/whoami](https://docs.layout.link/api#get-v1-whoami). - **A fired test webhook names its order.** `POST /v1/sandbox/webhooks/fire` with an `orderId` sends that sandbox order's real id, store, item count and total, still with `"test": true`. The response now carries `order`, the id, state and store the event described, so you can see which order was sent. An `orderId` that is not one of your application's sandbox orders is still a `404`. See [Fire a test webhook](https://docs.layout.link/sandbox#fire-a-test-webhook). - **How long a build takes.** The Carts page now gives typical build times and how to poll: every 2 to 3 seconds, and stop holding the person after about 4 minutes. See [How long a build takes](https://docs.layout.link/carts#build-time). ## Breaking changes None. If your code looked for the bare `order_status` string in `scopes`, look for `order:status` instead. --- # The Layout CLI October 2, 2026 · Console The Layout CLI signs in to your developer account with a code you approve in the console, then works in sandbox from your terminal. It never touches production. ## What changed - `layout login` prints a code and opens the console's new [CLI](https://console.layout.link/cli) page, where you check the code and approve it. The session lasts 30 days. - `layout apps create` and `layout keys create` make an application and a sandbox key. - `layout listen` forwards sandbox webhook events to a local URL, signed like real ones. `layout trigger` fires a test event. - `layout test-order` runs a whole sandbox order at a test restaurant and prints each step. - Every command takes `--json` and exits with a stable code, so a coding agent can drive it. - The console's CLI page lists every signed-in CLI, each with **Revoke**. ## Why it matters You, or an agent working in your repository, can go from nothing to a tested sandbox integration without opening the console more than once. ## What to do Install it with `npm i -g @layoutplatforms/cli`, then run `layout login`. A CLI token cannot create live keys, read live secrets, change production settings or move money. See [Layout CLI](https://docs.layout.link/cli) and [what a CLI token can do](https://docs.layout.link/cli#security). ## Breaking changes None. --- # Self-serve sign-in test accounts October 2, 2026 · Console Testing an OAuth client end to end used to need a sign-in test account that Layout set up for you by email. Your own Layout account places real orders, and the test accounts you provision cannot sign in. Now you create one yourself in the console. ## What changed - The [OAuth clients](https://console.layout.link/oauth-clients) page has a **Sign-in test accounts** section. **New test account** gives you a reserved `+1 500 555` phone number and a six-digit sign-in code, shown once. - The account is sandbox for its whole life. Its confirms answer `"simulated": true`: no restaurant, no card, no charge, and nothing is ever texted to its number. - It connects only to the OAuth clients of the application that created it, before your application is approved. Other developers cannot see or use it. - You can hold 10 at a time across your applications. Deleting one ends its connections at once. ## Why it matters You can now walk the whole flow a person takes through your client, from consent to a placed order and its webhooks, without waiting on Layout and without spending money. ## What to do Create an account, start your authorization as usual, and sign in on the consent screen with its number and code. See [Sign-in test accounts](https://docs.layout.link/authentication#sign-in-test-accounts). ## Breaking changes None. --- # Texts grow with your users October 2, 2026 · API · Breaking Verification and approval texts used to have fixed numbers, inside a 300-an-hour ceiling every developer shared. An approved application in production now gets an hourly allowance that grows on its own as you verify people, under one Layout-wide ceiling that is much higher. ## Your hour grows with verified people Verification texts and approval texts count together, per developer account, over a rolling hour: 30 an hour, plus one for every 20 people you have verified, up to 1,000. Your daily room on each grows by the same factor, from 6,000 up to 24,000. A verified person is someone on any of your applications in production who read your code back to you, signed up with the number you provisioned, or approved your application, and has not disconnected it. Your number updates within a few minutes. Sandbox and applications awaiting approval keep their numbers. See [Texts per hour grow with verified people](https://docs.layout.link/limits#texts-per-hour). ## One Layout-wide ceiling Layout sends at most 2,000 verification and approval texts an hour across every developer, together, and no developer account can use more than half of it. Across every approved developer, each of verification and approval texts allows 48,000 a day. ## See it in GET /v1/usage `rates` lists `texts` with `scope: "developer"`: your hourly number, the `verifiedPeople` it grew from, the `max` it grows to, and `next`, the verified people at which it grows again. `texts_platform` is Layout's 2,000. When your hour or Layout's is reached, `verify/start` answers `429 rate_limited` with `Retry-After` and `error.limit`, which now carries `"period": "hour"` on an hourly ceiling. Without `period`, a limit is per day, as before. ## Breaking changes In `GET /v1/usage` `rates`, `verification_texts_platform` and `approval_texts_platform` are replaced by `texts_platform`, and `texts` is new. A new approved application starts at 30 texts an hour rather than 250 verification starts an hour; it passes 250 at 4,400 verified people. Nothing else changes shape. --- # API versions and request ids October 1, 2026 · API Two headers for running an integration in production: one that says which version of the API you are on, and one that lets support find your request. ## What changed - **`Layout-Version`** is a date. The current and only version is `2026-10-01`, and a request without the header gets it. Every answer from an endpoint says which version answered. A version the API does not answer is `400 unsupported_version`. - **`X-Request-Id`** is on every answer from an endpoint, errors included. Quote it when you write to [developer@layout.link](mailto:developer@layout.link). - **A versioning policy.** A breaking change ships as a new dated version, is marked breaking here, and the old version keeps working for at least 180 days. ## Why it matters Pin a version and a breaking change never reaches you until you choose it. ## What to do Send `Layout-Version: 2026-10-01` in production, and log `X-Request-Id` beside your own request logs. See [Versioning](https://docs.layout.link/versioning). ## Breaking changes None. Both headers are new, and a request without `Layout-Version` behaves as before. --- # Clearer previews and menu retries October 1, 2026 · MCP Two changes to what the MCP tools return, both about saying plainly what Layout already knows. ## preview.outcome A `preview` from the `order` tool now carries `preview.outcome`: `ready`, `needs_location`, `closed`, `closing_soon`, `not_orderable` or `blocked`. Branch on it instead of combining `needsLocation`, `blocked` and the note fields yourself. ```json { "action": "preview", "preview": { "outcome": "ready", "resolved": { "name": "Layout test kitchen", "address": "1 Market St, San Francisco, CA", "placeId": "…" }, "sayToUser": "On it: Layout test kitchen at 1 Market St, San Francisco." } } ``` When the person names a street and exactly one open branch sits on it, the preview now resolves that branch instead of asking which one. A branch they already picked is never replaced. See [What a preview concluded](https://docs.layout.link/mcp#what-a-preview-concluded). ## When to read a menu again If your app connects a person over OAuth and calls `menu`, a menu Layout has just read is answered again from that read. When there is nothing to reuse, reading the same menu again within three minutes comes back `limit_reached` with `retryAfterSec`, the seconds until another read is allowed. Relay `sayToUser`, or order the item directly. Once the person's daily menu reads are used up, `menu` answers from general knowledge when it can (`status: "knowledge"`, `live: false`), and otherwise comes back `limit_reached` with no retry time. See [Reading a menu](https://docs.layout.link/mcp#reading-a-menu). ## What to do Nothing is required. If you branch on preview fields today, switching to `outcome` is simpler and keeps working as fields are added. ## Breaking changes None. `outcome` and `retryAfterSec` are new fields. A preview that used to ask which branch over a single option now comes back resolved for that branch, usually `ready` (or `closing_soon` when it is near closing). --- # Clearer sandbox build limits October 1, 2026 · Sandbox A sandbox build has to clear several daily limits, and until now a refusal did not say which one it hit. The account-wide sandbox limit was also low enough to cap every one of your apps together, and it shared a counter with production. All three are fixed. ## What changed - **250 sandbox builds a day per developer account**, across every application you have created. It was 50. - **Its own counter.** Production builds no longer use up your sandbox builds. - **A refused build spends nothing else.** When your app's own sandbox allowance is already used up, the build is refused before any shared limit is counted, so one app retrying past its allowance cannot drain the room your other apps share. - **Refusals that name the limit.** Every sandbox limit refusal says which limit stopped it and the number, and your account's limits also say how to raise them. ## What each refusal tells you When Layout's own capacity for the day is reached, the refusal says it is Layout's limit, not yours. Every limit resets at midnight UTC. A refused build is not an HTTP error. The `order` tool answers normally with no cart and a `userMessage` such as: ``` "This test user has reached today's limit of 25 builds per person for this credential. It resets at midnight UTC. To keep testing today, provision another test user." ``` ## Production stays plain In production the person reading the refusal is your end user, so the `userMessage` stays plain and never mentions developer limits, and the `assistantInstruction` names the limit for your model and tells it not to mention developer limits to the person. For how production builds are pooled, see [Safer codes, fairer limits](https://docs.layout.link/changelog/safer-codes-fairer-limits). ## The rate limits page [Rate limits and idempotency](https://docs.layout.link/limits) now lists every limit that can refuse you, what each counts, and which ones share a counter between sandbox and production. Provisioned users and build sessions still count sandbox and production together. Sandbox sends no verification texts, so it uses none of that limit. ## What to do Nothing. If you were working around the old limit of 50, you have five times the room. Usage raises one application's allowance only; to raise the account limit, email us. ## Breaking changes None. Refusal messages have new wording, so if you matched on the old text, match on the absence of a cart instead. --- # Confirm with the person's code October 1, 2026 · API **Since then:** a build grant's confirm asks for the code only when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due), not on every confirm. See [Connecting is agreeing](https://docs.layout.link/changelog/connecting-is-agreeing). The rest of this entry is as it was published. Until now a build grant could build a cart for someone you provisioned, but never confirm it: the person had to reply YES to Layout's text or open the Layout app. Now your application can confirm it, with the code Layout texts the person for that cart. ## What changed - `POST /v1/carts/:id/confirm` accepts a live build grant. The first call without a code answers `400 code_required` and Layout texts the person a 6-digit code. Send the same request again, with the same `idempotencyKey`, plus `code`, and it answers `202 placing`. - The `order` tool's `confirm` action works the same way on a production build session over MCP. The first call answers `code_required`. `codeSent: true` means a code just went out; `codeSent: false` means one texted in the last 10 minutes is still good, so ask the person for it. - `POST /v1/carts/:id/confirm/resend-code` and the MCP `resend` stage text a fresh code to a build grant's person. The new code replaces the last. Layout texts at most four codes per cart, and each application has a daily code allowance; past either the answer is `429 rate_limited`. - `POST /v1/carts/:id/cancel`, and `cancel: true` with an `orderId` over MCP, release a cart your application built. - A build grant's cart reads `codeRequired: true`. ``` POST /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x/confirm { "expectedTotalMinor": 750, "idempotencyKey": "7f3c2a9e-confirm-1" } 400 { "error": { "code": "code_required", "message": "Layout just texted the person a 6-digit code. Ask them for it and send this same request again with code.", "orderId": "ord_7c21" } } POST /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x/confirm { "expectedTotalMinor": 750, "idempotencyKey": "7f3c2a9e-confirm-1", "code": "482913" } 202 { "order": { "id": "ord_7c21", "status": "placing" } } ``` ## How the code works - On a build grant the code is asked for on every live confirm, whatever the total and whatever the person's own code setting. It is how the person, not your application, agrees to the charge. - Layout writes the text, from Layout's number: `Layout: 482913 approves $7.50 at Layout test kitchen on your Visa ending 4242. Sharing this code places the order.` It names the total, the store and the card that will be billed. Nothing your application sends appears in it. - A confirm without a code texts a new code only when none texted in the last 10 minutes is still good. A text that could not be sent is `502 send_failed`: retry shortly. - The code works once, for that cart and that total, for 10 minutes. A wrong code is `400 code_invalid`. One that expired, was used, was replaced, or was texted for another total is `400 code_expired`. - It cannot sign anybody in to Layout. Never ask the person for a Layout sign-in code. - 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 or released. - The person must have finished joining Layout. Until then the confirm answers `403 build_only`, says which step is missing (sign up with this number and add a card, approve a connect link because the number already has a Layout account, be provisioned again, verify their number, or add a card), and texts nothing. - Sandbox is unchanged: a sandbox grant confirms with no code, as a simulation. **Update 2026-10-21:** `build_only` now names `verify_required` for a person who has not verified. See [Every error code](https://docs.layout.link/errors#every-error-code). ## Why it matters Your own chat, app or assistant can now carry an order from the cart to the confirm without sending the person to Layout. If you turned off the [cart-ready text](https://docs.layout.link/changelog/turn-off-cart-ready-texts), this is how your application confirms. ## What to do Show the person the cart's items, store and `totalMinor`. When they say yes, call confirm with no code, ask them for the code Layout texts them, and confirm again with it. Handle `code_required`, `code_invalid`, `code_expired`, `send_failed`, `rate_limited` and `build_only`. See [Confirming with a build grant](https://docs.layout.link/carts#relayed-code) and [Confirm with the person's code](https://docs.layout.link/mcp#confirm-with-a-relayed-code). ## Breaking changes None for an integration that only builds. A live build grant's confirm used to be refused with `403 build_only` and did nothing; it now texts the person a code, so call it only after the person has said yes. `build_only` now means the person has not finished joining Layout. A cancel over MCP on a build session must name the `orderId`. --- # Daily limits you can read October 1, 2026 · API A 429 from a per-minute brake and a 429 from a spent daily ceiling call for different handling. Your code can now tell them apart without reading the message. ## What changed - **`error.limit`** is on every refusal by a daily ceiling: `name` (what it counts), `scope` (`person`, `application`, `account` or `platform`), `max` and `resetsAt`. A per-minute brake has none. - **Every daily refusal names its number.** Provisioning, build sessions and verification texts used to say only that a limit was reached. - **A build budget Layout cannot read** is now `503 unavailable` with `Retry-After`, not `429 build_limit`. Nothing was built, and the person has not reached a limit. Retry with the same key. - **The MCP `order` tool** returns `refusal` when it refuses to start a build: `code` (the REST code), `retryable`, and the same `limit`. ## Why it matters A client that is not a model, such as a texting service or an app, can stop retrying until `resetsAt` and tell the person why. ## What to do When `error.limit` is present, do not retry before `resetsAt`. See [Handling a 429](https://docs.layout.link/limits#handling-a-429). ## Breaking changes None for a spent limit: its status code and error code are unchanged, and the fields are additive. A build budget Layout could not read used to answer `429 build_limit`; it now answers `503 unavailable`, which a client that retries a 5xx already handles. --- # Docs that match the API October 1, 2026 · Docs Several pages disagreed with the API, or with each other. They now match what the API sends. ## What changed - **One ordering story.** REST carts and the MCP `order` tool are two doors to the same ordering. A person who connected your application builds and confirms; a build grant builds, reads, and confirms only with the code Layout texts the person for that cart (see [Confirm with the person's code](https://docs.layout.link/changelog/confirm-with-the-persons-code)). Pages that said ordering was MCP only, or that every confirm happens on a Layout page, are corrected. - **Every error code.** [Errors and status](https://docs.layout.link/errors#every-error-code) lists every code in the `Error` schema, with its status, which endpoints send it and what to do. It also names the three flat answers that come from in front of the endpoints. - **Failure codes.** Every value `failure_code` takes is published in [Failure codes](https://docs.layout.link/errors#failure-codes), and in the `Order` schema. An older order that stored a code outside the list reads as `UNKNOWN`, and one that stored `PAYMENT_DECLINED` reads as `CARD_DECLINED`. - **Webhook payload.** [Event types](https://docs.layout.link/events) said `order.carted` carries items, modifiers and a pickup time. It carries the store's name, the item count and the total, like every event. Only `order.failed` adds a reason, `order.failure_code` (see [Event ids on every webhook](https://docs.layout.link/changelog/event-ids-on-every-webhook)). [Webhooks](https://docs.layout.link/webhooks#payload) no longer mentions a richer payload, which does not exist. - **Verify.** A wrong code is `401 bad_code`. - **Connect.** The field that returns the consent link is `connectDelivery`, as in the specification. One example used another name. - **Grants.** Provisioning returns no credential, a build grant lasts 15 minutes unless you refresh it, and a build grant cannot read `GET /v1/orders/:id` (it follows its own order on the cart). - **Events feed.** The home page and the reference now name the same `GET /v1/events` parameters, `cursor` and `limit`. - **Ids.** In [openapi.json](https://docs.layout.link/openapi.json) a webhook event's ids accept the fixed ids a console test delivery carries, a path id keeps the shape every route checks, and every example matches its own pattern. - **MCP tools.** [MCP tools](https://docs.layout.link/mcp-tools) has every tool's input and output JSON Schema, which session may call what, and raw JSON-RPC calls. The same contract is machine-readable at [mcp-tools.json](https://docs.layout.link/mcp-tools.json). ## Why it matters A developer, or an agent integrating for one, builds from what the docs say. Where they said something the API does not do, code written from them failed. ## What to do If you parsed `order.carted` for items or a pickup time, read the cart with `GET /v1/carts/:id` instead. If you generate types from the specification or from `mcp-tools.json`, regenerate them. ## Breaking changes None. Apart from the `failure_code` mapping above, the API's behaviour did not change; the docs now describe it. --- # Endpoint verification and secret rotation October 1, 2026 · Webhooks Two additions to webhooks: Layout checks that an endpoint answers before it sends it order events, and a signing secret can now be replaced on your own schedule. ## What changed - **Endpoint verification.** Saving a new subscription sends one signed `endpoint.verification` event. The subscription stays off until your endpoint answers it with a 2xx, then turns on by itself. It carries `"test": true` and names no order. It is signed with the environment selected in the console first, and again with the other environment's secret if your endpoint answers that with a 4xx or 5xx. **Verify** on the subscription sends another. - **Changing a URL drops nothing.** A verified subscription keeps delivering to its current URL until the new one answers its verification event with a 2xx, then switches over. - **Secret rotation with overlap.** **Rotate** on the Webhooks page replaces the signing secret for the selected environment. For the next 24 hours every delivery carries two signatures, `t=,v1=,v1=`. A receiver that accepts any matching `v1` verifies with either secret. A second rotation inside that window is refused unless you choose to end the overlap, and rotating a production secret needs a sign-in from the last 15 minutes. - **The delivery contract is written down** in [Delivery and retries](https://docs.layout.link/webhooks#delivery-and-retries): at least once, no ordering guarantee, a 3xx counts as a failure, and an attempt fails if your endpoint sends nothing for 5 seconds or has not finished answering within 10. Retry waits are approximate. - **The verification snippet is corrected.** The one published before this compared the signature with the `=` of `v1=` still attached, so it could not pass. Use the verifier in [Verify the signature](https://docs.layout.link/webhooks#verify-the-signature). ## Why it matters A typo in a URL now shows up in the console the moment you save it, rather than as a dead-lettered order event later. Rotation lets you replace a secret on your own schedule: deploy the new one any time inside the 24 hours. ## What to do Before you rotate, make sure your verifier accepts the request when **any** `v1` matches. One that reads only the first `v1` fails on every delivery from the moment you rotate until it holds the new secret: ```js const signatures = header.split(",") .filter((p) => p.startsWith("v1=")) .map((p) => p.slice(3)); const ok = signatures.some((s) => safeEqual(s, expected)); ``` The full verifier, with the 5 minute timestamp check your endpoint should enforce, is in [Verify the signature](https://docs.layout.link/webhooks#verify-the-signature). If your handler returns an error for events it does not recognise, return 2xx for `endpoint.verification` too, or acknowledge anything with `"test": true`. ## Breaking changes None. Subscriptions that already exist stay on without being verified again, and a delivery carries one signature unless you rotate. --- # Event ids on every webhook October 1, 2026 · Webhooks Delivery is at least once, so the same event can reach you more than once. Every delivery now carries an id for the event itself, which makes deduplicating a one-line check. ## What changed - **`event_id`** names one event: this order reaching this state. Every delivery of it carries the same id, to every endpoint, on every retry and every replay. - **`occurred_at`** is when the order changed. `sent_at` is still when Layout wrote the delivery. - **`order.failure_code`** is on `order.failed`: the same stable code `GET /v1/orders/:id` returns, or `null` when Layout recorded none. - **`GET /v1/events`** takes `after`, an `event_id` you already handled, and returns what came next. It also filters by `order_id`, `user_id`, `event` and `since`. `cursor` keeps working. ## Why it matters Keep the last `event_id` you processed. After an outage, ask the feed for everything after it, and skip any `event_id` you have already seen. ## What to do Deduplicate on `event_id` and order events by `occurred_at`. See [Payload](https://docs.layout.link/webhooks#payload) and [GET /v1/events](https://docs.layout.link/api#get-v1-events). ## Breaking changes None. The new fields are additive. Events written before this change carry them too, in the feed, on a retry still queued, and in a replay from the console. --- # Finish an order in sandbox October 1, 2026 · Sandbox **Since then:** a production build session confirms too, with the person's code when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). See [Confirm with the person's code](https://docs.layout.link/changelog/confirm-with-the-persons-code). The rest of this entry is as it was published. Sandbox used to stop at a priced cart. You could build against real restaurant sites, but the last step, the one that turns a cart into an order, could only be exercised in production. Now a sandbox user can be taken all the way to a placed order, and every place that reports it marks it as simulated. ## What changed - **Verify sends nothing.** With an `sk_test_` key, `verify/start` texts nobody and answers with `"sandbox": true`. `verify` accepts the fixed code `000000`. - **A sandbox build session can confirm.** The `order` tool's `confirm` action works on a sandbox build session. It answers `"status": "placed"` with `"simulated": true`. No code is needed. - **The order says it was simulated.** The `order.placed` webhook and `GET /v1/orders/:id` for that order carry `simulated: true`. ## The whole flow Provision a user on a reserved test number, `+1 500 555` followed by four digits, then verify them with the sandbox code. ``` POST /v1/users/usr_4b8e/verify/start 200 OK { "ok": true, "id": "usr_4b8e", "phoneHint": "+1 (500) •••-0142", "expiresInSec": 180, "sandbox": true } POST /v1/users/usr_4b8e/verify { "code": "000000" } 200 OK { "verified": true, "authorized": true, "status": "active", "claimed": true } ``` Mint a build grant, build over MCP as you already do, and confirm the carted build with its `totalMinor` and a fresh idempotency key. ``` order { "action": "confirm", "orderId": "0b6f2a54-…", "expectedTotalMinor": 1850, "idempotencyKey": "a7c1e0d2-…" } → { "action": "confirm", "confirm": { "status": "placed", "orderId": "0b6f2a54-…", "subtotalMinor": 1850, "simulated": true } } ``` - The total still has to match the cart. A stale one is refused with `price_changed`, exactly as in production, so a simulated order still proves the cart you built. - Asking for a code to be resent answers `code_not_needed` and places nothing, because sandbox has no code. - If Layout cannot check that the session is a sandbox one, the confirm answers with the error `unavailable`. Retry it: it is never a permanent refusal and never a real order. - Cancelling is not available on a build session, in sandbox or production. **Update 2026-10-21:** A build session can cancel its own builds, with the build's `orderId` or `idempotencyKey`. See [MCP builds match REST](https://docs.layout.link/changelog/mcp-builds-match-rest). ## Why it matters You can test your whole integration before you have production keys: the confirm call, the `order.placed` handler, the order read and whatever your product does once an order exists. The cart is real, built on the restaurant's own site, so the test proves what production will do up to the moment the restaurant would receive it. ## What to do If your code treats every `order.placed` as food being made, check `order.simulated` first. Test for `true`: a real order never carries `simulated: true`. Never describe a simulated order to a person as a real one. The sandbox session's own instructions tell your model the same. ## Breaking changes None. A production build session still cannot confirm: the person confirms the order themselves, by replying to Layout's text or in the Layout app. See [Finishing an order in sandbox](https://docs.layout.link/environments#finishing-an-order-in-sandbox). --- # Introducing the changelog October 1, 2026 · Docs This page is where changes that affect your integration are written down: the API, MCP, webhooks, the console and sandbox. Each entry says what changed, why it matters to you, what you need to do, and whether anything breaks. ## How to follow it - [Atom feed](https://docs.layout.link/changelog/feed.xml) for any feed reader, with the full article in every entry. - Markdown for agents: every entry has a twin at the same address with `.md` added, and the list is in [llms.txt](https://docs.layout.link/llms.txt). - Docs search finds entries too, below the reference pages they relate to. ## What gets an entry A new endpoint, field, event, error code or limit, a change in how an existing one behaves, a new console capability, and a docs correction that changes what you should do. Entries go up once the change is live, never before. ## Breaking changes None. A breaking change will always be marked as one, here and in the feed. --- # Introducing the Images API October 1, 2026 · API Layout now finds the photo a restaurant published of any item on its menu and serves it from its own CDN. It is never a stock or generated image. ## What changed - [POST /v1/images/items](https://docs.layout.link/api#post-v1-images-items) takes an `item` in the person's words and a restaurant by `placeId` or `query`. A photo Layout already holds answers at once. Otherwise Layout reads the restaurant's own site and answers `200` when it finds one within about 12 seconds, or `202` with an `imageId` to poll for free. - Layout stops looking 35 seconds after your request. A poll after that answers `no_photo` with reason `not_found_in_time`, so a person is never left waiting on a picture. - Every `/v1/menus` item Layout already holds a photo for carries an `image`, with no extra request and no extra allowance. - An OAuth connection over MCP has the same lookup as the `item_photo` tool. ## Why it matters A person deciding what to eat wants to see it. A chain needs no branch and no open store, because its photos are the same everywhere, and the item's spelling and phrasing need not match the menu. ## What to do Call it with a person credential, the same as places and menus. Show the `cdn.layout.link` URL rather than copying the file: when a restaurant asks Layout to take its photos down, the URLs stop serving everywhere at once. A search that opens the restaurant's site counts toward a new daily allowance of image searches, 300 in sandbox and 2,000 for an approved production application; a photo Layout already holds never counts. See [limits](https://docs.layout.link/limits#place-and-menu-reads). ## Breaking changes None. `image` is a new optional field on menu items, and the routes are new. --- # Introducing the REST API October 1, 2026 · API **Since then:** a build grant also confirms a live order the person said yes to, with their code when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due), releases a cart it built, and reads the orders its application built for that person. See [What each door can do](https://docs.layout.link/how-it-works#what-each-door-can-do). The rest of this entry is as it was published. Until now, ordering through Layout meant driving an MCP session. A backend that already knows what the person wants had to run a model loop to get there. The REST API takes the same tools and puts each one behind an endpoint, so your server can find a restaurant, read its menu, build a cart and confirm it with ordinary HTTP calls. Nothing about an order changes on the way. The endpoints call the same build, the same confirm and the same checks the MCP tools do, so a cart built over REST is the same real cart, built on the restaurant's own site, and it is held to the same price lock, daily limit and confirmation. ## What it is Two more endpoints read for one person: `GET /v1/orders/:id`, which now also accepts a connected person's token for that person's orders your application drove, and `GET /v1/users/me/profile`, which returns their dietary switch and preference notes and nothing that identifies them. [openapi.json](https://docs.layout.link/openapi.json) is generated from the same shapes the API validates every request with. A cart is built and unpaid. Its id starts with `crt_`. Once it is confirmed it becomes an order, with the `ord_` id your webhooks and `GET /v1/orders/:id` already use. ## Two ways in The REST API opens the same two doors MCP does, and nothing more. Your application secret is not one of them: it still acts as your application, for provisioning, grants, events and order reads. A live build grant that tries to confirm is refused with `403 build_only` before anything is read. The person confirms that cart themselves, by replying to Layout's text or in the Layout app. ## Authentication Send the credential in the `Authorization` header. The API never reads a cookie for these routes. **A connected person.** Run the authorization code flow with PKCE against an OAuth client from the console's OAuth clients page, and ask for the API as the resource. A token for `https://mcp.layout.link` is refused by the API, and a token for the API is refused by MCP. ``` https://api.layout.link/v1/oauth/authorize ?response_type=code &client_id=$CLIENT_ID &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback &code_challenge=$CODE_CHALLENGE &code_challenge_method=S256 &scope=order &resource=https%3A%2F%2Fapi.layout.link &state=$STATE ``` ```bash curl -X POST https://api.layout.link/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=$CODE \ -d client_id=$CLIENT_ID \ -d redirect_uri=https://yourapp.example/callback \ -d code_verifier=$CODE_VERIFIER \ -d resource=https://api.layout.link ``` Access tokens last ten minutes. Refresh keeps the resource the person approved. When the person disconnects your application, their token is refused on the next request. **A provisioned person.** Mint a build grant with your secret, exactly as you do for MCP, and send the `lgb_` token as the bearer. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \ -H "Authorization: Bearer $LAYOUT_SECRET" ``` **Check either one** with `GET /v1/whoami`. It answers `kind: "user"` for a connected person and `kind: "grant"` for a build grant, with the environment and the `usr_` id your webhooks carry for that person. Called with your application secret it answers `kind: "app"`. ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" 200 OK { "kind": "user", "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "live", "user": { "id": "usr_4b8e" }, "scopes": ["order"] } ``` Production needs your application approved. A production person on an application that is not approved yet is refused with `403`. ## A whole order, start to finish Every sample below is a connected person in production, ordering an iced oat latte at Layout test kitchen. The base URL is `https://api.layout.link`, and every request carries `Authorization: Bearer $LAYOUT_PERSON_TOKEN`. ### 1. Build a cart Name the store with a `placeId` from a places search, an `orderUrl`, or a `restaurantName` with `near` or `geo`. Say what the person wants in `request`, or as a list in `items`. `idempotencyKey` is required: 8 to 100 letters, digits, `.`, `_`, `:` or `-`, unique per real intent. ``` POST /v1/carts { "request": "an iced oat latte", "placeId": "ChIJbloomCoffee", "idempotencyKey": "7f3c2a9e-cart-1" } 202 Accepted { "cart": { "id": "crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x", "status": "building" } } ``` The build has started on the restaurant's site, and nothing is charged. Sending the same key with the same body again returns the same cart; the same key with a different body is `409 idempotency_conflict`. The cart id carries your key, so never put anything private in one. If more than one store matches a name, the answer is `409 store_ambiguous` with up to five `candidates`. Ask the person which one, and send its `placeId`. ### 2. Poll it Read the cart until its `status` is no longer `building`. Here the restaurant needs a size before it can price the drink. ``` GET /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x 200 OK { "cart": { "id": "crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x", "status": "needs_answer", "store": null, "items": [], "currency": "usd", "totalMinor": null, "fees": null, "card": null, "codeRequired": false, "confirmationCard": null, "decision": { "kind": "choices", "question": "What size would you like?", "item": "Iced Oat Latte", "groups": [{ "name": "Size", "required": true, "options": ["Small", "Medium", "Large"] }], "answerWith": { "field": "choices" } }, "failure": null, "order": null, "expiresAt": null } } ``` ### 3. Answer its question Send the person's answer in their own words with a new key. A `pickup_time` decision is answered with `"acceptPickupTime": true` instead, or by building at one of its `alternatives`. An answer is a new cart: poll the id it returns. ``` POST /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x/answer { "choices": "Large", "idempotencyKey": "7f3c2a9e-cart-2" } 202 Accepted { "cart": { "id": "crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y", "status": "building" } } ``` Poll the new id until it is ready. ``` GET /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y 200 OK { "cart": { "id": "crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y", "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 }, "card": { "brand": "visa", "last4": "4242" }, "codeRequired": true, "confirmationCard": "…", "decision": null, "failure": null, "order": { "id": "ord_7c21", "status": "carted" }, "expiresAt": "2026-10-01T17:15:00.000Z" } } ``` **`totalMinor` is the only total.** It is the restaurant's total plus Layout's service fee when the fee is not waived, and it is the number confirm takes back. Never add up `fees` yourself. Show the person the total, the items and the card before they confirm; `confirmationCard` is Layout's own wording of all three, to show as written. `card` and `confirmationCard` are only on a connected person's token. A ready cart can be confirmed for 15 minutes. ### 4. Confirm, and relay the code Send the cart's `totalMinor` as `expectedTotalMinor` with a key for this confirm. A live confirm takes the 6-digit code Layout texts the person exactly when ordering anywhere else in Layout would: they keep codes on, the restaurant's total is over $50, Layout's risk checks ask for one, or a code is already out for this cart. Otherwise the first call answers `202 placing` and you are done. `codeRequired` on the cart says what to expect, and the confirm decides. This person keeps codes on, so the first call answers `code_required` and Layout texts them. **Update 2026-10-21:** There is no per-person code setting. A code is due when the restaurant's total is over $50, Layout's risk check asks for one, or a code is already out for this cart; in this example, the risk check asks. See [When a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due). ``` POST /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y/confirm { "expectedTotalMinor": 763, "idempotencyKey": "7f3c2a9e-confirm-1" } 400 Bad Request { "error": { "code": "code_required", "message": "Layout just texted the person a 6-digit code. Ask them for it and send this same request again with code.", "orderId": "ord_7c21" } } ``` Ask the person for the code, and send the same request with it added. ``` POST /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y/confirm { "expectedTotalMinor": 763, "idempotencyKey": "7f3c2a9e-confirm-1", "code": "482913" } 202 Accepted { "order": { "id": "ord_7c21", "status": "placing" } } ``` Asking again without a code while one is already out does not text another. To text a fresh one, call `POST /v1/carts/:id/confirm/resend-code`, which answers `{ "codeSent": true }`, or `code_not_needed` on a cart that needs no code. A wrong code is `code_invalid`, and one that is no longer good is `code_expired`. If the person's number replied STOP to Layout, no code can reach them and the answer is `409 texts_blocked`: they reply START to the number Layout texts from, then you confirm again. **Confirm answers `placing`, never `placed`.** The order is on its way to the restaurant, and it is not placed until Layout has evidence that it was. ### 5. Hear how it ended Your webhook endpoint receives `order.placed` once it is placed, or `order.failed` or `order.unconfirmed` if it is not, signed exactly like every other delivery. ``` POST https://yourapp.example/layout/webhooks User-Agent: Layout-Webhooks/1 X-Layout-Event: order.placed X-Layout-Delivery: dl_351eacf74201 X-Layout-Environment: live X-Layout-Signature: t=1790874182,v1=5f2b… { "event": "order.placed", "delivery_id": "dl_351eacf74201", "order": { "id": "ord_7c21", "state": "placed", "store": "Layout test kitchen", "items": 1, "total_minor": 710, "currency": "usd" }, "user": { "id": "usr_4b8e" }, "sent_at": "2026-10-01T17:03:02Z" } ``` Or read the order with the same person token, or with your secret. ``` GET /v1/orders/ord_7c21 200 OK { "id": "ord_7c21", "state": "placed", "store": "Layout test kitchen", "items": 1, "total_minor": 710, "currency": "usd", "created_at": "2026-10-01T16:58:41Z", "placed_at": "2026-10-01T17:03:02Z", "failure_code": null } ``` An order's `total_minor` is the restaurant's total. The cart's `totalMinor` adds Layout's service fee when it is not waived, so the two can differ. ## Rules that do not move - **The price lock.** If the total moved since you read the cart, confirm is `409 price_changed` and nothing is charged. Read the cart again and show the person the new total. - **The person's limits.** Their daily spending limit applies (`over_daily_cap`), and builds draw on the same daily build limits as builds over MCP (`429 build_limit`, with a message that names the limit). - **Confirm only what the person agreed to.** A confirm meets the same checks as ordering through MCP, so it often needs no code. Never confirm a cart the person has not seen. - **`placed` is the only state that means the order exists.** `unconfirmed` means Layout cannot tell either way: do not retry it and do not show it as failed. - **When an answer is uncertain, read before you act.** A `500 internal` from confirm means the outcome is not known. Read `GET /v1/orders/:id` first, and do not build the order again until it reports a final state. A `503 unavailable` means a check could not run and nothing was confirmed or charged: retry the same request. - **One error shape.** Every refusal is `{ "error": { "code", "message" } }`, with `orderId` when there is an order and `candidates` on `store_ambiguous`. Switch on `code`; never parse `message`. - **Isolation.** A cart or order from another application, or another person, is a `404`. ## Sandbox With a sandbox person or a sandbox build grant, a cart is built on the real restaurant site as in production, `codeRequired` is `false`, and confirm takes no code. It answers `202` with `"simulated": true` on the order. No restaurant receives it and nothing is charged, and the order's webhooks and reads carry `simulated: true`. `resend-code` answers `code_not_needed`. ## What to do Nothing, if you build over MCP: it is unchanged. To order over REST, create an OAuth client for people who connect your app, or keep using build grants for people you provision, and follow the five steps above. [Carts](https://docs.layout.link/carts) has every field and error code. Before you confirm, show the person the total, the items and the card, and treat `placing` as in flight until a webhook or an order read says `placed`. ## Breaking changes None. These are new endpoints. `POST /v1/orders` is still retired, and every existing endpoint answers as before for your application secret. --- # Limits built for a launch October 1, 2026 · API **Since then:** the texts ceiling is one shared ceiling of 2,000 an hour, and your own room grows with the people you have verified. See [Texts grow with your users](https://docs.layout.link/changelog/texts-grow-with-your-users). The rest of this entry is as it was published. The limits on an approved application in production were sized for a pilot. They are now sized for a launch, and every one is visible to you before you meet it. ## Higher production limits - **Users provisioned:** 600 an hour per credential, up from 60, and 10,000 a day per developer account, up from 500. - **Build sessions minted:** 1,200 an hour per credential, up from 120, and 50,000 a day, up from 2,000. - **Verification and approval texts:** 6,000 a day each, up from 600, and 250 verification starts an hour. - **Requests:** 3,000 a minute per credential across every endpoint, up from 600. Sandbox numbers, and those for an application awaiting approval, are unchanged. Sandbox now counts users provisioned, build sessions and texts on its own counter, so higher production traffic cannot use up the sandbox day. Layout's hourly ceiling on the texts it sends for every developer together is unchanged at 300 verification texts and 300 approval texts an hour, and in a burst it is reached before your own numbers are. It now has its own refusal message with `Retry-After`, and `GET /v1/usage` lists it. See [Per-minute and per-hour limits](https://docs.layout.link/limits#per-minute-limits) and [Users, sessions and texts](https://docs.layout.link/limits#users-and-texts). ## One address is not a limit Requests that authenticate no longer count against your calling address, so a backend that sends everything through one address gets the same room as one that does not. Only credentials Layout has no record of count against an address, at 300 a minute, and a credential that authenticated recently keeps working from an address that is over it. A revoked secret or an expired grant or token never counts against its address; it counts against itself, at 600 a minute. Wrong verification codes count against your developer account, so another developer's guesses never lock your user's number. ## Build grants refresh Send the current grant back as `refresh` to `POST /v1/users/:id/grant` before it expires, and the same token lasts another 15 minutes, up to 24 hours from when it was minted. A refresh never counts toward your build-session limits. A grant that cannot be refreshed answers `409 grant_expired`. ``` POST /v1/users/usr_4b8e/grant { "refresh": "lgb_9f3a2c" } 200 OK { "token": "lgb_9f3a2c", "expiresAt": "2026-10-01T18:15:41Z", "scope": "build", "refreshed": true } ``` ## See every limit - **`GET /v1/usage`** answers every daily pool with your limit, what you used today and what is left, the ceiling across every developer, your application's own build allowance, the per-person limits and every rolling window. See [Seeing your limits and usage](https://docs.layout.link/limits#seeing-your-usage). - **`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`** come back on every answer to a request that authenticated, for your per-credential minute. A `429` from a per-endpoint limit carries that limit's numbers instead, and a `429` from a daily ceiling or the address limit carries none. - A refusal at a daily limit names the limit and its number. ## Raising a limit Layout can now raise any developer pool `GET /v1/usage` lists for your account, including order builds, live menu reads and place searches. Ask from Usage in the console, saying which limit, or email developer@layout.link. A raised number shows in `GET /v1/usage` with `custom: true`. ## What to do Nothing is required. If you mint a new grant every 15 minutes for a long session, switch to `refresh`. If you throttle yourself on fixed numbers, read `GET /v1/usage` or the `RateLimit` headers instead. ## Breaking changes None. `refreshed`, the `RateLimit` headers, `GET /v1/usage` and `grant_expired` are new. `POST /v1/users/:id/grant` still takes no body: with none, or without a `refresh` token, it mints as before. A `refresh` that is not a grant token is a new `400 invalid_request`. Refusal messages at daily and hourly limits now name the number. --- # OAuth clients for your app October 1, 2026 · Console Until now, the only orders that reached your application were orders built for users you provisioned. If people signed in to your app or assistant with their own Layout account, their orders were invisible to you: no webhooks, no order reads, nothing in the console. A new console page fixes that. ## What changed - **An OAuth clients page.** The console has a new **OAuth clients** page. Create a client with a name and up to ten redirect URIs, and you get a `client_id`. Each application can have up to ten clients. - **Public clients with PKCE.** Authorization code with PKCE (S256), no client secret to keep. Each redirect URI must be https, or http on localhost for development, and Layout sends the code only to an exact match. - **Their orders reach you.** An order a person places through your client reaches your [webhooks](https://docs.layout.link/webhooks), `GET /v1/events`, `GET /v1/orders/:id` and the console's Orders page, the same as an order for a user you provisioned. ## How it connects The person approves your app on Layout's consent screen, and your MCP client then acts as that person. ## What you see for each person - **A stable id.** `user.id` on a webhook is a `usr_` id that stays the same for that person in your application and differs in every other one. It is not a provisioned user, so `POST /v1/users/:id/grant` does not accept it. - **Webhooks while they are connected.** Once a person disconnects your app, their new events stop. `GET /v1/orders/:id` and the console keep showing the orders they already placed. - **Deleting a client** closes it to new connections and stops every connected person from refreshing. An access token already issued keeps working for up to ten minutes, and orders it places in that time send no webhooks. Every order the client placed stays readable. ## Sandbox and production The environment follows the person, not the client. An order is sandbox only when the person is a sandbox account that can sign in, and Layout issues those by hand. The test accounts on the console's Sandbox test accounts page are for users you provision and cannot sign in through OAuth. **Testing with your own Layout account places a real order.** Your account is a production account, so it cannot connect through your client until your application is approved: the consent step returns `unauthorized_client`. Once it is approved, confirming charges your card and sends the order to the restaurant. ## What to do If people sign in to your product with their own Layout account, create a client on the OAuth clients page and use its `client_id` in your MCP client's authorization flow. A client you registered yourself through dynamic registration still connects, but it belongs to no application, so its orders never reach you, and there is no way to claim one. Move to a client you create in the console. For a sign-in sandbox account, email [developer@layout.link](mailto:developer@layout.link). If your webhook handler looks every `user.id` up among the users you provisioned, let it accept ids it has not seen: those are people who connected through your client. ## Breaking changes None. Nothing changes for an application that creates no OAuth client. See [OAuth clients](https://docs.layout.link/authentication#oauth-clients). --- # Places and menus over REST October 1, 2026 · API The REST API can now do the reading half of an order, for one person: find a restaurant, look at one, and read its live menu. These are the same answers the MCP `places`, `place_facts` and `menu` tools give. ## What changed - `GET /v1/places?query=&near=&lat=&lng=&openNow=&limit=` searches restaurants by name or kind of food. - `GET /v1/places/:placeId` returns one place, its dietary, service and good-for facts, and an `orderability` verdict. - `POST /v1/menus` reads a restaurant's live menu, named by `placeId` or by `query` and `near`. A read that is not done answers `202` with a `menuId`; poll `GET /v1/menus/:menuId`, which is free. A poll that could not be checked is a `503` with `Retry-After`, and the read is not lost. - [openapi.json](https://docs.layout.link/openapi.json) is now generated from the shapes the API validates every request with, and covers every endpoint in this reference. - Every error `code` is one of a published list. The 400 code is `invalid_request`; the reference said `invalid_input`, which no developer endpoint sends. ## Why it matters You can show a person where they can eat and what a place serves without driving an MCP session. These routes read and never build, confirm or charge. ## What to do Call them with a person credential, not your application secret: the access token of someone who connected your application, or an `lgb_` build grant. ```bash curl -X POST https://api.layout.link/v1/menus \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "placeId": "ChIJbloomCoffee" }' 202 Accepted Retry-After: 5 { "status": "reading", "menuId": "mnu_Q2hJSmJsb29t…", "assumedNearest": false, "retryAfterSec": 5, "restaurant": { "name": "Layout test kitchen", "address": "1194 Roseville Pkwy, Rocklin, CA 95677, USA" } } ``` Each developer account has a daily allowance, counted across all its applications: 5,000 place reads, 200 live menu reads and 100 knowledge answers in sandbox and for an application not yet approved, and 50,000, 1,000 and 1,000 for an approved production application. A spent allowance is a `429` that names the limit and says whether it was your account's limit or the pool's ceiling across every developer. Open Usage in the console to ask for more. See the [API reference](https://docs.layout.link/api#get-v1-places) and [limits](https://docs.layout.link/limits#place-and-menu-reads). ## Breaking changes None. These are new endpoints, and the spec describes the existing ones as they already behave. --- # Retired route names carts October 1, 2026 · API · Breaking `POST /v1/orders` is still retired, and still answers `410` for every method before it reads any credential. Its error now names the door that builds carts today: ```bash HTTP/1.1 410 Gone { "error": { "code": "use_carts", "message": "POST /v1/orders no longer builds. Build a cart with POST /v1/carts, using a connected person's token or a build grant from POST /v1/users/:id/grant, or over MCP at https://mcp.layout.link. See https://developer.layout.link/reference/carts." } } ``` ## Why it matters The old answer said ordering had moved to MCP only. Carts are on REST as well now, so an integration, or an agent reading the error, was sent to one door when there are two. ## What to do Build with [POST /v1/carts](https://docs.layout.link/carts), or over MCP. If your code switches on the old code, accept both: `use_carts` and `moved_to_mcp` are both in the published list. See [Every error code](https://docs.layout.link/errors#every-error-code). ## Breaking changes Yes, for code that matched `moved_to_mcp` exactly: the code is now `use_carts`. The status, the method coverage and the shape are unchanged. --- # Retry any POST safely October 1, 2026 · API A request that times out leaves you guessing whether it ran. Every `POST` on the REST API now takes an `Idempotency-Key` header, so the safe move after a timeout is always the same: send the same request again with the same key. ## What changed - **Every POST takes the header**: provisioning, verification, connect, grants, carts, answers, confirms, code resends, cancels and menus. The retired `POST /v1/orders` is the only exception. - **Same key, same body.** The first success is stored for 24 hours and comes back with `Idempotent-Replayed: true`. Layout does not run the request again. - **Same key, different body** is `409 idempotency_conflict`. While the first request is still running, the same request is `409 in_progress` with `Retry-After`. - **Only a success is stored.** After an error the key is free again, so a confirm answered with `code_required` is retried with the same key and the person's `code` added. A `4xx` ran nothing. A `5xx` may have, so after a `500` on a confirm, read the order before sending anything changed. - **A replay is the first answer.** A stored `202 building` stays `building`. Read the cart for its state. - **The body's `idempotencyKey` keeps working.** On carts, answers and confirms it is now optional when you send the header, whose value then stands in for it. When you send both, the body's key names the cart or the confirm. ## Why it matters A confirm is where a careless retry costs the most. If one times out, retry the same call with the same key, never a new one: the retry gets the answer the first call earned, and the person is charged once. ## What to do Send a fresh random `Idempotency-Key` with every POST that changes something, and carry it into every retry of that call. See [Idempotency](https://docs.layout.link/limits#idempotency). ## Breaking changes None. The header is optional, and a request without it behaves as before. --- # Revocation and one consent October 1, 2026 · API · Breaking Servers that act for many people get the standard OAuth pieces they expect: revocation, one consent for both resources, discovery that names the right resource, and a challenge header on a refused person credential. ## What changed - **Token revocation.** `POST /v1/oauth/revoke` takes a refresh token or an access token with your `client_id` and ends that person's connection to your client, on the API and on MCP. An expired access token still works for this. It is advertised as `revocation_endpoint` in the authorization server metadata. - **One authorization for both resources.** Send `resource=https://api.layout.link` and `resource=https://mcp.layout.link` on one authorize request. The person approves once, one refresh token covers both, and each token request names the one resource its access token is for. Keep one refresh token for the person, not one per resource, and refresh one at a time. - **Discovery names the API.** `https://api.layout.link/.well-known/oauth-protected-resource` now describes the REST API, with `"resource": "https://api.layout.link"`. MCP clients keep reading `https://mcp.layout.link/.well-known/oauth-protected-resource`, which has not changed. - **A real `WWW-Authenticate` on API refusals.** A 401 from an endpoint that takes a person's credential (places, menus, images, carts, orders, profile and whoami) now carries `WWW-Authenticate` itself, with `resource_metadata` pointing at the API's metadata. Endpoints that take only your application secret answer 401 without it. - **MCP refusals use the API's error envelope.** A 401, 403 or 503 from `mcp.layout.link`, before the request reaches the protocol, is now `{ "error": { "code", "message" } }`. The `WWW-Authenticate` challenge is unchanged. - **A retried refresh keeps the connection.** Retrying a refresh within 20 seconds, for instance after its response was lost, now returns a new pair instead of ending the connection. - **A disconnect stops MCP tokens at once.** When a person disconnects your app, you revoke their connection, or you delete the client, an MCP access token already issued is refused on its next request, as on the API, instead of working until it expires. ## Why it matters When someone signs out of your product, you can end their Layout connection from your server instead of asking them to disconnect in Layout. A server that uses both the API and MCP no longer needs two consent screens. And a standard OAuth client library can now discover the API and read its challenges without special cases. ## What to do Nothing is required. To revoke on sign-out, or to ask for both resources at once, see [Revoking a connection](https://docs.layout.link/authentication#revoking-a-connection) and [One authorization for both](https://docs.layout.link/authentication#one-authorization-for-both). Refresh lifetimes and rotation are spelled out in [Tokens and refresh](https://docs.layout.link/authentication#tokens-and-refresh). ## Breaking changes - The body of an MCP 401 was `{ "error": "unauthenticated" }` and is now `{ "error": { "code": "unauthorized", "message": "…" } }`. If your code reads that body, switch on `error.code`. MCP clients that follow the `WWW-Authenticate` header are unaffected. - The API's protected resource metadata no longer names `https://mcp.layout.link`. If you read MCP's metadata from the API's origin, read it from `https://mcp.layout.link` instead. - An MCP access token for a disconnected person or a deleted client is now refused on its next request rather than up to ten minutes later. --- # Safer codes, fairer limits October 1, 2026 · API · Breaking A security issue with the codes Layout texts people is fixed. The fix changes what one response says and what the texts read, and a few limits now keep testing traffic and production traffic apart. ## Codes do one thing - **A verification code verifies.** The code `POST /v1/users/:id/verify/start` texts is good for that person's number, for your application, for 3 minutes from the text and five tries. With no text sent there is no code to check. - **An order code approves one order.** The code a confirm asks for approves that order at that amount, once. - **The texts say what they are for.** The order text names the total the card is charged, fees and credit included, and the store as Layout knows it. It never carries the card, and never words your application sent. The person now reads: ``` Layout: 482913 verifies this number for an app you are connecting to Layout. It expires in 3 minutes and does not sign you in. If you did not ask for it, ignore this text. Layout: 482913 approves $7.50 at Layout test kitchen. It expires in 10 minutes. If you did not just order, ignore this text. ``` The request and response shapes for confirming are unchanged on MCP. If the person once replied STOP to Layout, no code can reach them: `POST /v1/users/:id/verify/start` answers `409 texts_blocked`, and over MCP the order answer says the code could not be texted. They reply START to the number Layout texts from before you try again. ## Headless verify in production **Update 2026-10-21:** A good live verify now makes the person's Layout account, and answers `status: "active"` and `claimed: true`. See [Verifying makes the Layout account](https://docs.layout.link/changelog/verifying-makes-the-account). A good code on a production user now connects your application to that person and does nothing else. It no longer creates a Layout account, so the response says the person is still provisioned: ``` POST /v1/users/usr_4b8e/verify { "code": "482913" } 200 OK { "verified": true, "authorized": true, "status": "provisioned", "claimed": false } ``` - `verified` means the code was right, or the person has since signed up to Layout themselves. `authorized` says whether you may build. When it is `true` you can mint a build grant straight away. - When the number belongs to an existing Layout account, `authorized` is `false` and the answer carries `next: "connect"`: send the owner the connect link. - After five wrong tries, or three minutes, `verify` answers `400 code_expired`. Text a new code. - `claimed` becomes `true` once the person signs up to Layout themselves, and your connection carries over when they do. - Once you are connected, calling `verify` again answers `verified: true` without a fresh code. - Sandbox, with the code `000000`, answers as before, now with `authorized: true`. ## Signing up connects only who they approved When a person you provisioned signs up to Layout themselves, your application stays connected only if it already was: a good headless verify, or the person approving your app on the connect screen. Otherwise `POST /v1/users/:id/grant` answers `409` until they approve it, and you send them the connect link as you do for an existing account. ## Limits of their own - **Place search.** In sandbox, and for an application not yet approved, `GET /v1/places` searches only the places Layout already knows, so it can return fewer results than the same search in production. An approved production application can look further, within a daily allowance for your developer account. - **Build capacity.** Sandbox builds, and builds for an application not yet approved, share a pool of 2,000 a day that is separate from approved production builds, and each developer account may use 250 of it a day in each environment. Testing can no longer use up the room production orders need, and no one developer can use up the testing pool. A refusal names the pool and its number. - **Read allowances are per developer account.** Place reads, live menu reads and knowledge answers count every application on your account together, in the same two pools. A live menu read through the API draws its own per-person allowance, separate from the Layout app's. When a limit stops a place search at the places Layout already knows, the answer says so in `searchLimit`. - **An allowance that cannot be read refuses.** If Layout cannot read your sandbox build allowance, your allowance of place reads or live menu reads, or your allowance of knowledge answers when you ask for one, the request is refused and safe to retry. Over REST that is `503 unavailable` with `Retry-After`. ## Smaller fixes - An OAuth client your application created is refused for MCP, as it already was for the API, when the application is deleted or suspended, or for a production person while the application is not approved. - A failed cart on a build grant no longer mentions the person's card. - An unexpected failure on any `/v1` developer route answers the published error shape, `{ "error": { "code", "message" } }`. - A build grant never sees the person's card on MCP either: no card brand, no last four, and no confirmation card text. ## What to do If your code waits for `claimed: true` or `status: "active"` after a headless verify in production before it builds, wait for `authorized: true` instead. If a person you provisioned without verifying signs up to Layout and your grant call starts answering `409`, send them the connect link. If you tell people what the text will say, use the wording above. ## Breaking changes Yes, for production headless verify. A good code used to answer `"status": "active", "claimed": true` for a new number, and now answers `"status": "provisioned", "claimed": false` until the person signs up themselves. A person who signs up no longer connects an application they never verified with or approved. A code is checked only against the last one Layout texted, for five tries. Nothing else changes in sandbox. --- # Sandbox test restaurants October 1, 2026 · Sandbox A sandbox build used to drive a real restaurant's site. That made it slow, it could not be told to fail, and it could not run in CI. Sandbox now has test restaurants, the way a payment processor has test cards: name one by its `placeId` and you get the outcome it scripts, every time. ## What changed - **Twelve test restaurants.** `test_place_carted`, `test_place_needs_choices`, `test_place_needs_location` and its two branches, `test_place_closed`, `test_place_price_changed`, `test_place_placed`, `test_place_failed`, `test_place_unconfirmed`, `test_place_challenge` and `test_place_slow`. [Sandbox test restaurants](https://docs.layout.link/sandbox) lists what each one does. - **Real orders, simulated.** Carts, confirm, `GET /v1/orders/:id`, the event feed and your webhooks treat them as orders, with `simulated: true`, and the events arrive in seconds. - **Findable.** In sandbox, `GET /v1/places?query=layout test` lists them, up to `limit`, and `POST /v1/menus` returns their test menu, with known prices. - **Their own allowance.** 10,000 test restaurant builds a day for each developer account. They never draw your sandbox build allowance. - **Fire a test webhook.** `POST /v1/sandbox/webhooks/fire` with your `sk_test_` key sends one signed event of any type except `order.needs_approval` to your subscribed endpoints, optionally describing one of your sandbox orders. ``` POST /v1/carts { "placeId": "test_place_failed", "items": ["latte"], "idempotencyKey": "ci-4812-cart" } POST /v1/sandbox/webhooks/fire { "event": "order.unconfirmed" } ``` ## Why it matters You can test the paths that matter most and happen least: a declined order, an order Layout cannot confirm either way, a price that moved, a cart that takes a while. Your CI can run them on every commit without a real restaurant or a charge. ## What to do Nothing, unless you want them. Point your tests at a `test_place_` id with a sandbox credential, and give each run its own idempotency keys. A production credential that names one gets the same answer as for any place that does not exist. ## Breaking changes None. --- # Turn off cart-ready texts October 1, 2026 · Console When a cart is ready and nothing confirms or cancels it, Layout texts it to the person so the order is not lost. Some applications already carry the confirm in their own chat and want the person to hear only from them. Those applications can now switch the texts off. ## What the texts are - About 15 seconds after a cart is ready, if nothing has confirmed or cancelled it, Layout texts the person the same cart from Layout's number: each item, the total and the card it goes on. They reply YES to place it. - If the cart is still waiting a few minutes later, Layout sends one reminder that asks for the same YES. - A cart already confirmed or cancelled gets no text, and neither does a number that opted out of Layout texts. ## What changed The console's **App details** page has a **Cart-ready text** setting. It is on for every application, and turning it off stops both the first text and the reminder. - It covers only the orders your application starts, whether for users you provision or through an [OAuth client you created](https://docs.layout.link/changelog/oauth-clients-for-your-app). - Before it switches off, the console asks you to confirm and shows the share of real orders placed on Layout in the last 30 days, outside a text conversation, that a reply to one of these texts placed, with the size of the sample it was measured across. When there are too few orders to measure, no figure is shown. - Turning it back on takes effect at once. - If Layout cannot read the setting, the text is sent. Only a setting you turned off silences it. ## Why it matters A person who missed your message would otherwise lose the cart, so the texts are worth keeping in most integrations, and the figure in the confirm step shows how much they catch. If your product already follows up on an unconfirmed cart itself, turning them off means the person gets one message from you instead of two from different senders. ## What to do Nothing, unless you want them off. We recommend keeping them on. See [If the person misses the card](https://docs.layout.link/mcp#if-the-person-misses-the-card). ## Breaking changes None. The texts stay on until you turn them off. --- # Week of September 28 October 1, 2026 · MCP ## order.challenge is sent `order.challenge` was reserved and never sent. It now fires when Layout texts the person a link to approve a purchase and the order is waiting on them. - It is a pause, not a new state. `order.state` is where the order was when it paused: `building`, `carted` or `placing`. - It is sent once per order, and only while the order is still in flight. - It is off by default. Turn it on for a subscription on the console's Webhooks page. `order.needs_approval` is still reserved and not sent. You can subscribe to it, but do not build a handler that waits on it. ## The ord_ id in MCP results Once an `order` result names an order, from `carted` on, it also carries `publicOrderId`: the `ord_` id that `GET /v1/orders/:id` and your webhooks use. It is on `build`, `status` and `confirm` results, and only for orders your build session or your OAuth client drove. ```json { "action": "status", "build": { "status": "carted", "orderId": "0b6f2a54-…", … }, "publicOrderId": "ord_7c21" } ``` While a build is still `building` there may be no order yet. Keep polling, or take the id from the `order.building` webhook. The id is for your own systems; never read it to the person. ## How long build waits The MCP reference said `build` returns `building` immediately. It holds for up to about 15 seconds, so a build that finishes inside that window comes back ready, and otherwise it returns `building` and you poll `status`. Nothing changed in the API; the [reference](https://docs.layout.link/mcp) now says what it does. ## Breaking changes None. `publicOrderId` is a new field, and `order.challenge` reaches only subscriptions that turn it on. --- # Card-free sandbox builds September 22, 2026 · Sandbox A sandbox build used to stop at the card step when the test person had no card on file, which in sandbox is always. Sandbox places nothing and charges nothing, so the card guarded nothing there. It is gone. ## What changed - **No card in sandbox.** A sandbox build for a test person runs straight through to a priced cart. Confirming it is simulated: nothing is placed and nobody is charged. - **Its own daily allowance.** Each application gets a sandbox build allowance, 50 a day unless we have raised it for you, resetting at midnight UTC. It is separate from your production no-card allowance, and the two never draw on each other. - **A clear stop.** When the day's sandbox builds are spent, the next build is refused with a message that says so: *"You have reached today's sandbox build limit of 50. It resets at midnight UTC. To raise it, open Usage in your Layout developer console."* ## The Usage page A new **Usage** page in the console shows, for each environment: - today's builds against the daily allowance, as a meter, - builds today, this month and all time, and how many were placed. Production shows its meter once your application is approved. **Request a higher limit** sends us a request with an optional target and a note. Asking twice before we answer tells you a request is already open rather than filing another. ## What to do Nothing. If you were waiting on us to enable no-card builds before you could test, you can build in sandbox now. ## Breaking changes None. Production builds are unchanged. --- # Orders in the console September 21, 2026 · Console The console has an **Orders** page. It shows every order your application's keys caused, in the environment you have selected, newest first. ## What you see - **A row per order**: when it was created, the restaurant, the number of items and the outcome. - **A filter by outcome**: placed, awaiting confirmation, failed or canceled. - **The full order** when you open a row: outcome, items, total, when it was requested, when it was placed, and why it did not complete if it did not. Reasons are written for you, not for us: "Item unavailable", "Store not accepting orders", "Pickup slot expired", "Card declined" and so on. Every failed order on the page charged nothing. ## Awaiting confirmation is not failed When Layout cannot yet tell whether an order reached the restaurant, the page says **Awaiting confirm** and never shows it as a failure. Retrying an order in that state could place it twice. The same rule holds in the API, where the state is `unconfirmed`; see [Errors and status](https://docs.layout.link/errors). ## Why it matters Until now, seeing what your integration had done meant reading your own logs or calling `GET /v1/orders/:id` one id at a time. Now you can check a build from your phone in the time it takes to open a page. ## Breaking changes None. The page reads the same orders the API returns, and shows only the ones your keys caused. --- # Week of September 14 September 15, 2026 · Console ## Invite someone again When a person disconnected your application from their Layout account, there was no way back: provisioning the same number failed and resending the link did too. Now provisioning that number again, or pressing **Resend link**, starts a fresh invite on the same application. Someone with their own Layout account still has to approve you again before you can build for them. ## The right link to the right person - Provisioning and resending from the console texts the approval link to a number that already has Layout, and the sign-up link to a new number. Before, both got the sign-up link, which signed the person in without connecting them to you. - Sandbox never texts an approval link. Where the API would have texted an approval link in production, a sandbox call answers `next: "connect"` with the link for you to open yourself. ## Guides corrected - [Ordering over MCP](https://docs.layout.link/mcp) describes connecting with a build grant token and the three tools that session can call. - [Provisioning users](https://docs.layout.link/provisioning) shows that Layout texts the approval link by default, with `next: "awaiting_user_approval"`. - [Event types](https://docs.layout.link/events) marks `order.needs_approval` and `order.challenge` as reserved and not sent today. - [Rate limits and idempotency](https://docs.layout.link/limits) lists the daily build ceilings the API actually enforces. ## Breaking changes None. If you built a handler that waits on `order.needs_approval` or `order.challenge`, it will not fire yet. --- # A signing secret per environment September 13, 2026 · Webhooks · Breaking Each application used to have one webhook signing secret for both environments. Now sandbox and production each have their own, and every delivery carries an `X-Layout-Environment` header of `sandbox` or `live`. ## What changed - **Two secrets.** Sandbox kept the secret it already had. Production was given a new one that is different from it. - **A header that names the environment**, sent with `X-Layout-Signature`, `X-Layout-Event` and `X-Layout-Delivery` on every delivery, retries and replays included. - **The Webhooks page follows the environment switch**, showing the signing secret for whichever environment is selected. A test delivery is signed with that environment's secret. ## Why it matters Each environment now has its own signing secret, so trust in sandbox and trust in production are fully separate. ## What to do Copy the production signing secret from the Webhooks page into your production configuration, then pick the secret by the header before you verify: ```js const secrets = { sandbox: process.env.LAYOUT_WEBHOOK_SECRET_SANDBOX, live: process.env.LAYOUT_WEBHOOK_SECRET_LIVE, }; const secret = secrets[req.headers["x-layout-environment"]]; if (!secret || !verify(req, secret)) return res.status(401).end(); ``` `verify` is the signature check from [Verify the signature](https://docs.layout.link/webhooks#verify-the-signature). A handler that only ever sees one environment can keep a single secret, as long as it is that environment's. ## Breaking changes Yes, for production. A production handler still verifying with the old shared secret rejects every production delivery from this release on. Sandbox verification keeps working unchanged. --- # Check readiness with whoami September 13, 2026 · API From a `200` on a sandbox call there was no way to know whether a production call would work. The first signal was a `403` in production. `GET /v1/whoami` answers the question up front. ## The endpoint ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_SECRET" 200 OK { "app": { "id": "lyt_app_3f9c0d2e7a41b65c08e9d1f2" }, "environment": "sandbox", "approvalStatus": "pending", "scopes": ["order:preview", "order:build", "order:status", "order_status", "places"] } ``` - `environment` is decided by the key's prefix. - `approvalStatus` is the application's real status, so a sandbox key on an unapproved application answers `pending`. That is the thing to check before you switch keys. - `scopes` is what a build grant from this application may do, so you can code against the boundary instead of discovering it at a tool call. It reads nothing beyond the key itself, so it is safe to call from a deploy script. ## Production stays sealed until approval - A new application's production secret is not handed out until the application is approved. Sandbox credentials are available immediately. - Before approval, revealing or rotating the production secret answers `not_approved`. - When your application is approved, you get an email saying so. ## What to do Add a `whoami` check to the step that switches you to production, and fail the deploy unless it answers `approved`: ```bash curl -s https://api.layout.link/v1/whoami -H "Authorization: Bearer $LAYOUT_SECRET" \ | jq -e '.approvalStatus == "approved"' > /dev/null || exit 1 ``` ## Breaking changes None. Production was already refused before approval; this only says so sooner. --- # Connections that last September 13, 2026 · API Before this release, the moment a provisioned person signed in to Layout on their own, your application lost the ability to build for them. Now the connection lasts until the person ends it. ## What changed - **Durable connections.** Once a person is connected, you keep building carts for them after they open the Layout app, add a card, or sign in anywhere else. Every charge is still theirs to approve. - **Existing Layout accounts can connect.** An account that already exists is attached to you only when its owner approves, on the same consent screen a Layout AI assistant uses, while signed in with the very number you provisioned. - **Layout sends the approval link.** By default Layout texts the owner a short approval link and you simply wait. Pass `connectDelivery: "developer"` to get the link and deliver it yourself. - **The person stays in control.** Connected apps appear in their Layout account, where they can disconnect you at any time. After that, your next build for them is refused. ## Branch on `next` `POST /v1/users` now tells you the one step left for each person: | `next` | What you do | | --- | --- | | `verify` | A new number. Verify it with `verify/start` and `verify`. | | `awaiting_user_approval` | An existing account, and Layout has texted its owner. Nothing to send. | | `connect` | An existing account where you deliver the link: send its owner to `connect.connectUrl`. | | `connected` | Already connected. Start building. | ## Ask again later `POST /v1/users/:id/connect` asks an existing account's owner for approval at any time. The approval link is good for ten minutes, and an optional `https` `returnUrl` brings them back to you afterwards. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/connect \ -H "Authorization: Bearer $LAYOUT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "returnUrl": "https://your.app/connected", "connectDelivery": "developer" }' 200 OK { "next": "connect", "connectUrl": "https://account.layout.link/authorize?request_id=…", "expiresInSec": 600 } ``` Leave out `connectDelivery` and Layout texts the person instead, answering `next: "awaiting_user_approval"` with a masked `awaitingApproval.phoneHint`. If the text cannot be sent, you get the link back so the person is never unreachable. A person already connected answers `connected: true`. ## Breaking changes None for new connections. Code that treated `next` as only `verify` should handle all four values. A person who signed in before this release still needs to approve you once. --- # Headless phone verification September 13, 2026 · API You already gave us the person's number when you provisioned them, so there is no reason to make them type it again. Two new endpoints let them prove it from inside your product. ## The two calls ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/verify/start \ -H "Authorization: Bearer $LAYOUT_SECRET" 200 OK { "ok": true, "id": "usr_4b8e", "phoneHint": "+1 (916) •••-0142", "expiresInSec": 180 } ``` ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/verify \ -H "Authorization: Bearer $LAYOUT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "code": "246810" }' 200 OK { "verified": true, "status": "active", "claimed": true } ``` - `start` texts a six-digit code, good for three minutes. The response carries only a masked hint of the number you already gave us. - `verify` takes the code the person read back. A wrong code is a `401` and costs an attempt, so ask them for the current one and try again. - Once the person is claimed, calling `verify` again answers `verified: true` without a fresh code. If the number belonged to an existing account, `claimed` is `false` and a repeat call needs a current code. - An id that does not belong to your application, in this environment, is a `404`. ## What a good code does Exactly what the person signing in themselves would have done. If the number is new to Layout, the account becomes theirs, `claimed` is `true`, and you are connected to them from then on. If the number already belongs to someone's own Layout account, headless verify does not attach you to it. That only happens when the owner approves you; see [Connections that last](https://docs.layout.link/changelog/connections-that-last). Either way you never receive a session or anything about an account that already existed. ## Why it matters Verification used to mean sending the person to a Layout page. Now it can be one field in your own onboarding, and the code still goes to their real handset, so possession of the phone is still proven. ## Breaking changes None. See [Verify their phone](https://docs.layout.link/provisioning#verify-their-phone). --- # Ordering moves to MCP September 13, 2026 · MCP · Breaking **Since then:** carts are built over REST as well, beside MCP. The two are doors to the same ordering, with the same checks behind both. A build grant also confirms a live order itself, with the person's code when [a code is due](https://docs.layout.link/how-it-works#when-a-code-is-due), rather than the person confirming on a Layout page. See [Introducing the REST API](https://docs.layout.link/changelog/introducing-the-rest-api) and [Confirm with the person's code](https://docs.layout.link/changelog/confirm-with-the-persons-code). The rest of this entry is as it was published. Building a cart over REST has moved to Layout's MCP server. `POST /v1/orders` now answers `410`: ```bash HTTP/1.1 410 Gone { "error": { "code": "moved_to_mcp", "message": "Building an order over REST has moved to Layout's MCP interface. Mint a build session with POST /v1/users/:id/grant, connect it to https://mcp.layout.link, and drive the `order` tool (preview, then build). Provisioning (POST /v1/users), order reads (GET /v1/orders/:id) and the events feed are unchanged." } } ``` ## Why it moved Ordering is rarely one request. Which branch, which size, an item that is out of stock today, a choice the menu insists on: a single REST call has nowhere to put those questions, so it stalled or guessed. An MCP tool loop can ask, using the restaurant's own options, and build again with the answer. ## What to do Mint a build grant for the person, then connect it to the MCP server as a bearer token. There is no OAuth step for a server-minted grant. ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \ -H "Authorization: Bearer $LAYOUT_SECRET" 201 Created { "token": "lgb_9f3a2c…", "expiresAt": "2026-09-13T18:00:41Z", "scope": "build" } ``` ```json { "mcpServers": { "layout": { "url": "https://mcp.layout.link", "headers": { "Authorization": "Bearer lgb_9f3a2c…" } } } } ``` Then drive the `order` tool: `preview` resolves a real store, `build` assembles the cart, and `status` follows it. Pass an `idempotencyKey` on `build` and carry it into any retry. The session builds and reads; confirming and paying happen on Layout's hosted page. See [Ordering over MCP](https://docs.layout.link/mcp). ## Honest tool discovery A build grant session used to list every tool on the server and refuse most of them when called. It now lists only the three it can use: `order`, `order_status` and `places`. The boundary itself has not moved; discovery just stopped advertising tools you could not call. ## Breaking changes Yes. Any call to `POST /v1/orders` now fails with `410 moved_to_mcp`. Provisioning, `GET /v1/orders/:id`, `GET /v1/events` and webhooks are unchanged, and orders built over MCP appear in all of them. --- # Builds before a card September 12, 2026 · API A person you just provisioned has never met Layout and has no card on file. Asking for one before they have seen a price is the wrong order of events, so production builds no longer require it, within limits. ## What changed - **A daily no-card allowance per application.** Up to that many builds a day run for people with no card. Layout drives the real restaurant, assembles the cart and hands it back priced. Nothing is charged and nothing is owed. - **Seeded at approval.** The allowance starts at zero and is set when your application is approved, from the volume you gave us during onboarding. It applies only to an approved application. - **Then it asks for a card.** Once the day's allowance is spent, the next build for someone without a card comes back asking for one instead of running. A build for someone who already has a card is never counted against it. - **The card page names you.** The Layout page where the person adds a card says that your application asked them to. The card is entered on Layout's page and held by Layout, and it never reaches your servers. - **An optional `email`** on `POST /v1/users`. It is kept with your provisioning link, unverified, and is never written to the person's own Layout account. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+19165550142", "email": "dana@example.com" }' ``` ## Why it matters The first thing a new user sees can now be their actual order at an actual price, not a card form from a company they have not heard of. The card is only needed for the step that places the order, and that step is always theirs. ## What to do Nothing to start. If your traffic grows past the allowance, ask us to raise it. See [Building without a card](https://docs.layout.link/provisioning#building-without-a-card). ## Breaking changes None. A build for someone with a card behaves exactly as before. --- # Docs for people and agents September 12, 2026 · Docs The reference was rebuilt around two kinds of reader: a developer looking for one field, and an agent that has been asked to integrate with us and will read everything. ## Search that reads the pages Search now covers every heading and paragraph of every page, and links into the section that matched rather than the top of the page. It understands the way people search an API: `order.unconfirmed`, `sk_test_`, `idempotencyKey` and `429` all find their answer. Press `⌘K` or `/` from anywhere. ## Built for agents | URL | What it is | | --- | --- | | [/llms.txt](https://docs.layout.link/llms.txt) | A short map of the docs, with a one-line summary of every page. | | [/llms-full.txt](https://docs.layout.link/llms-full.txt) | Every page as one file, in one fetch. | | `/reference/.md` | A Markdown twin of each page, announced in its head. | | [/openapi.json](https://docs.layout.link/openapi.json) | The REST endpoints as OpenAPI 3.1. | All four are served with the right content type and open CORS, so a tool can fetch them directly. ```bash curl https://docs.layout.link/llms.txt ``` ## New and corrected pages - [API reference](https://docs.layout.link/api) lists every endpoint with a real request and response. - [Environments and approval](https://docs.layout.link/environments), [Authentication](https://docs.layout.link/authentication) and [Rate limits and idempotency](https://docs.layout.link/limits) are new. - [Ordering over MCP](https://docs.layout.link/mcp) lists only tools that exist. An earlier version named tools that never shipped. - [Webhooks](https://docs.layout.link/webhooks) documents the retry schedule, the delivery headers and test deliveries. ## Breaking changes None. If you built against the earlier MCP page, check your tool names against the current one. --- # Keys you can read again September 12, 2026 · Console ## Secrets on demand A client secret used to be shown once, at creation. Lose it and the only way back was to rotate. Now the Credentials page reveals the current secret whenever you need it. - Sandbox secrets reveal straight away. - Revealing a production secret asks you to sign in again if your last sign-in was more than fifteen minutes ago. - Reveals are rate limited, and every one is recorded against your account. Rotation works as before: the old secret stops working the moment the new one exists, so deploy first and rotate second. ## Approval, stated plainly Each application shows its status and what it means for each environment, instead of leaving you to infer it from a `403`. | Status | Sandbox | Production | | --- | --- | --- | | Under review | Works | Refused | | Approved | Works | Works | | Sandbox only | Works | Refused | | Suspended | Refused | Refused | A review that does not enable production leaves sandbox working, so nothing you have built stops. See [Environments and approval](https://docs.layout.link/environments). ## Also in this release - **Logo upload.** The logo field takes a PNG, JPEG or WebP file instead of a URL, and Layout hosts it. It is shown beside the Layout mark when a person approves your application. - **New application ids** look like `lyt_app_` followed by 24 hex characters. ## Breaking changes None. If you validate application ids against a pattern, accept the `lyt_app_` form, or better, treat ids as opaque strings. --- # Send a test webhook September 12, 2026 · Webhooks Every webhook subscription in the console now has **Send test event**. It sends one real delivery to your endpoint, now, through the same path a real order takes: the same signature, the same headers, the same retries, and an entry in the delivery log. ## What arrives A test delivery is an `order.placed` event with `"test": true` at the top level. The ids inside are fixed placeholders that match nothing real: the order is always `ord_test_000000000000` and the user `usr_test_000000`. ```json { "event": "order.placed", "delivery_id": "dl_351eacf74201", "order": { "id": "ord_test_000000000000", "state": "placed", "store": "Layout test kitchen", "items": 1, "total_minor": 1234, "currency": "usd" }, "user": { "id": "usr_test_000000" }, "sent_at": "2026-09-13T10:14:03Z", "test": true } ``` ## Why it matters Until now the first delivery you could see was the first real order. A signature check that was subtly wrong, an endpoint behind the wrong firewall rule, or a handler that crashed on the payload all surfaced at the worst possible moment. Now you can find out on day one. ## What to do Ignore test deliveries in production handlers, so a test never reads as a real order: ``` if (payload.test) return res.status(200).end(); ``` Look a placeholder id up and you get nothing back. That is the right answer, not a bug. See [Test deliveries](https://docs.layout.link/webhooks#test-deliveries). ## Breaking changes None. A test delivery only ever goes to the subscription you sent it from. --- # Why a key was refused September 12, 2026 · Console Every refused call to the API returns the same body, whatever the reason: ```json { "error": { "code": "unauthorized", "message": "Invalid API credentials." } } ``` That is deliberate. Saying which wall a key hit would tell someone guessing at keys how close they got. But it also meant that when your own integration was refused, you had nothing to go on. ## What changed The Credentials page now has a **Refused requests** table. It appears only when something was refused, and lists the reason in words, which key it was, how many times it happened, and when it was last seen. You have already signed in to see it, so there is nothing left to hide from you. | Reason | What happened | | --- | --- | | `live_not_approved` | A production key on an application that is not approved yet. | | `credential_revoked` | The secret was rotated, and something is still holding the old one. | | `app_suspended` | This application is suspended. | | `partner_suspended` | The whole account is suspended. | ## Why it matters The most common case is a rotation that outran a deploy. Before this, a rotated secret looked exactly like a wrong one. It is now recorded as `credential_revoked`, and the count tells you how much of your fleet is still holding it. A secret that matches no application at all is refused and recorded nowhere. Otherwise anyone could fill your table with noise. ## What to do Nothing. The next time a call comes back `401` or `403`, open the application's Credentials page before you open a support ticket. [Errors and status](https://docs.layout.link/errors) has the full list. ## Breaking changes None. Status codes and response bodies are exactly what they were. --- # Layout for Developers opens September 11, 2026 · API developer.layout.link is open to anyone. You can sign up, create an application and start building against sandbox the same day, with no call to book first. This is the first release of the platform behind it: provision a person, build a real cart at a real restaurant for them, and hear about every change of state on a signed webhook. ## What shipped - **Self-serve accounts.** Sign up with an email and a password, confirm the address from the email we send, and finish a short onboarding form. Your developer session is its own session: signing out of the console never signs you out of a Layout account you order with, and the reverse. - **Applications and keys.** Every application gets a sandbox pair (`lp_test_` client id, `sk_test_` secret) and a production pair (`lp_live_`, `sk_live_`). The prefix picks the environment, so there is no flag to set. - **`POST /v1/users`** provisions someone you can order for, from a name and a phone number. - **`GET /v1/orders/:id`** reads one order your application caused, and **`GET /v1/events`** is a cursor-paged feed of the same events your webhooks receive. - **Signed webhooks**: five attempts over about an hour, then a dead letter you can replay from the console's delivery log. ## Why it matters Layout drives each restaurant's own ordering site, so there is no merchant to onboard and no menu to sync. The platform gives your product that reach without giving it the power to spend. Everything a partner key can do is build and read. Adding a card, confirming and paying happen on a Layout page, in front of the person whose money it is. ## Try it Create an application in the [console](https://console.layout.link/applications), copy its sandbox secret, and provision a test person. Sandbox takes a reserved number in the `+1 500 555` range. ```bash curl https://api.layout.link/v1/users \ -H "Authorization: Bearer $LAYOUT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' ``` From there the [Quickstart](https://docs.layout.link/quickstart) walks the rest of the way to a built cart. ## Breaking changes None. This is the first release. One part of it has since moved: carts were first built with `POST /v1/orders`, which now answers `410`. See [Ordering moves to MCP](https://docs.layout.link/changelog/ordering-moves-to-mcp). --- # Your logo on consent September 4, 2026 · MCP The consent screen is the one place a person decides to let a client place orders for them. It now shows who they are trusting, not just the words. ## What changed When a person approves an MCP client that connects through OAuth, the screen that says *"You are giving {client} permission to place orders on your behalf"* shows the client's logo and the Layout mark side by side. - **The logo comes from your redirect host.** It is the logo for the host your redirect URI points at, which is where the authorization is delivered. There is nothing to upload or configure. - **No logo is never an error.** A `localhost` redirect, a host with no recognisable brand, or a logo that cannot be found shows the Layout mark alone, exactly as the screen looked before. - **A different logo domain, on request.** If your callback host is not where your brand lives, a verified client can ask us to set the domain its logo is drawn from. ## Why it matters People approve what they recognise. Seeing your mark next to Layout's on the screen that grants ordering makes the connection feel like yours, and because the logo follows the host that receives the authorization, it always belongs to the client the person is approving. ## What to do Nothing, if your redirect URI is on your own domain. To check it, start a connection from your client and look at the consent screen. Clients that build a session with a Layout build token instead of OAuth never see this screen; see [Connect](https://docs.layout.link/mcp#connect). ## Breaking changes None. The consent flow, its scopes and its redirect behaviour are unchanged.