# Layout developer documentation Every page of https://developer.layout.link/reference, concatenated, then the changelog. Base URL: https://api.layout.link/v1 # Layout for Developers Layout API and MCP documentation. ## Explore the platform Pick the surface you're building on. ### Quickstart Provision a user, build a cart over MCP, and hand off the confirmation link in a few minutes. ### Environments and approval Sandbox works the moment you sign up. Production is reviewed by hand, and a decline keeps sandbox running. ### Authentication One bearer token. Key prefixes, rotation, and what a refusal does and does not tell you. ### Ordering over MCP Point any MCP client at mcp.layout.link and drive a full order with tool calls. ### Webhooks Signed, real-time order events delivered to your backend, with retries and replay. ### API reference The full event catalog, error codes, and the status semantics that keep orders honest. --- # Quickstart Your first sandbox order, end to end, in about five minutes. Nothing you do here places an order or charges anyone. ## 1. Get your credentials Create an application in the [console](https://developer.layout.link/console/applications) and copy its client id and secret from the [Credentials](https://developer.layout.link/console/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://developer.layout.link/reference/environments). ```bash export LAYOUT_CLIENT_ID="lp_test_9c2f8a1b7d3e" export LAYOUT_SECRET="sk_test_…" ``` ## 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" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }' ``` ## 3. Build a cart over MCP Ordering runs over Layout\u2019s MCP interface, not REST \u2014 a cart is a conversation (which branch, which size, an out-of-stock swap), and that belongs in a tool loop. Mint a build session and drive the `order` tool: ```bash curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \ -H "Authorization: Bearer $LAYOUT_SECRET" # -> { "token": "lgb_...", "expiresAt": "...", "scope": "build" } ``` Connect that `lgb_` token to `https://mcp.layout.link` (it authenticates the session directly, no OAuth dance) and call the `order` tool: `preview` to resolve the store, then `build` with what they asked for in their own words. Use a fresh random `idempotencyKey` per build. The session builds and reads; it cannot confirm, pay, or cancel. See [Ordering over MCP](https://developer.layout.link/reference/mcp). ## 4. Hand off the link The `build` returns a tracking link, and an `order.carted` webhook fires with the priced cart. Show them the total exactly as it comes, and send them to the link. They add a card there and confirm there, so their card never touches your servers or ours. > In sandbox, orders run the real engine against real restaurant sites, but they never place and never charge. No live orders, no money, no texts to the person. ## 5. Get the outcome When the person confirms, an `order.placed` webhook fires with evidence. If it can't be placed you get `order.failed`; if placement is genuinely uncertain you get `order.unconfirmed`, which must never be treated as failed. --- # 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 both keys up front. 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_` | Orders run the real engine against real restaurant sites. Nothing is ever placed, nothing is charged, and the person is never texted. | | Production | `sk_live_` | Real orders, real money, real people. Requires approval. | Sandbox is not a mock. Same stores, same live menus, same failures in the same places. The only thing it will not do is press the last button. ## 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. | > A declined review leaves sandbox working. It is not a shutdown, and you do not need to change any code to keep developing. ## What a refused production call looks like A production key on an application that is not approved is refused with **403** and this body, which is byte-for-byte what every other refusal returns: ```json { "error": { "code": "unauthorized", "message": "Invalid API credentials." } } ``` Telling you *why* would also tell an attacker which guess got closest, so the answer is the same every time. You still get the real reason: it lands on your application's **Refusals** page in the console, with a count and a last-seen time. [Errors and status](https://developer.layout.link/reference/errors) lists what you can see there. ## 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. Swap the secret for the `sk_live_` one. That is the entire migration. Read both secrets from config and never branch on which one you have. There is nothing else different between the two environments to branch on. --- # Authentication One bearer token on every request. Your secret is the identity, so there is no session to keep, no cookie to carry, and no token exchange to build. ## 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" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+19165550142" }' ``` ## 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 any time on the Credentials page; a production secret asks you to sign in again first if your last sign-in was more than fifteen minutes ago. | | Webhook signing secret | On the Webhooks page | Different secret, different job. See [Webhooks](https://developer.layout.link/reference/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 starts orders, 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. There is no overlap window — the instant the new secret exists, the old one stops working. So deploy the new secret to your fleet first and rotate second, or take the gap on purpose at a quiet hour. Revealing or rotating a production secret makes you sign in again if your session is more than fifteen minutes old. Someone who walks up to your unlocked laptop should not leave with a live credential. 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. The Refusals page is how you tell them apart: a stale key shows up as `credential_revoked`, with a count that tells you how much of your fleet is still holding it. ## Two tokens that are not this one | Token | What it is | | --- | --- | | `lgb_…` | A build grant, returned when you provision a user. It authenticates a build-only session for that one person and can never add a card, confirm, or spend. | | MCP OAuth | MCP clients register dynamically and use PKCE. No client secret lives in the assistant. See [Ordering over MCP](https://developer.layout.link/reference/mcp). | ## When a call is refused Every refusal returns the same body with either **401** or **403**: - **401** — the secret was not recognised, was malformed, or has been rotated. - **403** — the secret is real, and this application is not permitted to do that. Almost always production access on an application that is not approved. The body will not say which. The Refusals page will. --- # API reference Five endpoints. Base URL `https://api.layout.link/v1`, bearer auth on every call, JSON in and out. ## 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 the link that person uses to claim the 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" }' ``` ``` 201 Created { "id": "usr_4b8e", "status": "provisioned", "next": "verify", "session": { "scope": "build", "expiresIn": 3600 }, "handoffUrl": "https://account.layout.link/join" } ``` The id field is `id`, not `userId`. `session` carries no token by design: a build session for MCP is minted deliberately at `POST /v1/users/:id/grant`. | Field | Required | Notes | | --- | --- | --- | | `firstName` | Yes | Shown to the person on the consent screen. | | `lastName` | Yes | Shown to the person on the consent screen. | | `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 reserved one in live. | | `email` | No | Optional. Kept with the provisioning link, unverified; never written to the person's own Layout account and never shown to another partner. | Calling this twice with the same phone number gives you the same user back rather than a duplicate. ### `next` — what to do for this person `next` is the one step left to connect this person to your app, and the whole account-linking state machine. Branch on it. | `next` | Meaning | What you do | | --- | --- | --- | | `verify` | A new number, no Layout account yet. | Text them a code with `POST /v1/users/:id/verify/start`, or send them `handoffUrl`. | | `connect` | The number already has a Layout account, and you passed `connectDelivery: "developer"`. | Send its owner to `connect.connectUrl` to approve. | | `awaiting_user_approval` | An existing account; Layout has texted the person the approval link itself (the default). | Nothing. A masked `awaitingApproval.phoneHint` confirms the number. React when the connection lands. | | `connected` | Already connected. | Nothing. Build for them. | An existing account is only ever connected by its owner approving on Layout's screen. You choose who to ask; the person, signed in and proven, chooses whether to connect. ## Ordering runs over MCP `POST /v1/orders` is retired. It answers `410 moved_to_mcp` and points here. Ordering is ambiguous work — which branch, which size, an out-of-stock item, a choice the menu forces — and that belongs in a conversational tool loop, not a single REST call. So an order is built over Layout's MCP interface: 1. Mint a build session: `POST /v1/users/:id/grant` returns an `lgb_` token. 2. Connect it to `https://mcp.layout.link`. The grant authenticates the session directly; there is no OAuth dance for a server-minted build token. 3. Drive the `order` tool: `preview` resolves a real store, `build` assembles a real cart and returns a tracking link, `status` follows it. The session builds and reads; it cannot confirm, pay, or cancel. See [Ordering over MCP](https://developer.layout.link/reference/mcp) for the full tool contract. Webhooks still tell your backend where every order is, whether it was built over MCP or not. ## GET /v1/orders/:id Everything Layout will tell you about one order. ```bash curl https://api.layout.link/v1/orders/ord_7c21 \ -H "Authorization: Bearer $LAYOUT_SECRET" ``` ``` 200 OK { "id": "ord_7c21", "state": "placed", "store": "Sweetgreen, Market St", "items": 2, "total_minor": 2140, "currency": "usd", "created_at": "2026-09-12T17:00:41Z", "placed_at": "2026-09-12T17:03:02Z", "failure_code": null } ``` Field names match the webhook payload (`id`, `total_minor`, `created_at`, `placed_at`, `failure_code`), not the camelCase some SDKs default to. Totals are minor units: `2140` is $21.40. An order belonging to another application is a `404`, never a `403` — you cannot tell the difference between someone else's order and one that never existed, which is the point. ### States | State | Meaning | | --- | --- | | `building` | Layout is on the restaurant's site assembling the cart. | | `carted` | Priced and ready. Show the person the total and send them to the link. | | `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 did not go through. `failureCode` says why. Nothing was charged. | | `unconfirmed` | Layout cannot tell either way. Do not retry and do not say "failed". | | `refunded` | Money went back. | ## POST /v1/users/:id/grant Mint a build-only token for one person, for an MCP session. Ask for it deliberately, when you are about to hand an assistant the wheel. ```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-12T18:00:41Z", "scope": "build" } ``` The token builds carts and reads state. It cannot add a card, confirm, or spend anything — all of that happens on Layout's own page, in front of the person whose money it is. ## 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 — safe from a deploy script. ```bash curl https://api.layout.link/v1/whoami \ -H "Authorization: Bearer $LAYOUT_SECRET" ``` ``` 200 OK { "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" }, "environment": "sandbox", "approvalStatus": "pending", "scopes": ["order:preview", "order:build", "order:status", "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. ## GET /v1/events The same events your webhooks receive, as a cursor-paged feed. Useful when your endpoint was down, or when you would rather poll than run one. ```bash curl "https://api.layout.link/v1/events?limit=50" \ -H "Authorization: Bearer $LAYOUT_SECRET" ``` ``` 200 OK { "events": [ { "event": "order.placed", "delivery_id": "dl_9f3a2c", "order": { "id": "ord_7c21", "state": "placed", "store": "Sweetgreen, Market St", "items": 2, "total_minor": 2140, "currency": "usd" }, "user": { "id": "usr_4b8e" }, "sent_at": "2026-09-12T17:03:02Z" } ], "nextCursor": "ev_01J8Z…" } ``` Pass `nextCursor` back as `?cursor=` to walk backwards. `limit` defaults to 50 and caps at 100. When `nextCursor` is null you have reached the end. --- # Provisioning users Someone you can order for. You vouch for a name and a phone number; they prove the phone themselves the first time they open the link. ## Create a user ``` POST /v1/users { "firstName": "Dana", "lastName": "Whitfield", "phone": "+19165550142", "email": "dana@example.com" } → 201 { "id": "usr_4b8e", "status": "provisioned", "next": "verify", "session": { "scope": "build", "expiresIn": 3600 }, "handoffUrl": "https://account.layout.link/join" } ``` What you get back is **build-only**. It assembles carts and reads state. It cannot add a card, confirm, or move a cent, not through a bug, not through a clever request, not at all. `next` tells you how to establish the connection for this person: | `next` | What it means | | --- | --- | | `verify` | A new number. Prove it headlessly with the two calls below, or let them prove it on the hosted link. | | `awaiting_user_approval` | This number already has a Layout account, and Layout has texted its owner the approval link itself — the default. Nothing to send; a masked `awaitingApproval.phoneHint` confirms the number, and the connection lands when they approve (see [below](#connecting-an-existing-account)). | | `connect` | An existing account where you asked to deliver the link yourself (`connectDelivery: "developer"`), or where Layout could not text it. Send its owner to `connect.connectUrl` to approve. | | `connected` | You are already connected to this person. Nothing to do; start building. | ## Fields | Field | Notes | | --- | --- | | `firstName` | Required. Shown to the person on the consent screen. | | `lastName` | Required. Shown to the person on the consent screen. | | `phone` | Required. E.164, digits only (`+19165550142`). No verified phone up front; the person verifies it on the link. | | `email` | Optional, and worth sending when you have it. Kept with the provisioning link, unverified; it is never written to the person's own Layout account and never shown to another partner. Leave it out and nothing else changes. | ## Verify their phone The number you gave us is theirs to prove. They never type it and never open a sign-in screen: you send them a code, they read it back, you hand it to us. One step for them, two calls for you. ``` POST /v1/users/usr_4b8e/verify/start → 200 { "ok": true, "phoneHint": "+1 (916) •••-0142", "expiresInSec": 180 } POST /v1/users/usr_4b8e/verify { "code": "246810" } → 200 { "verified": true, "status": "active", "claimed": true } ``` `start` texts a six-digit code, good for three minutes. `verify` takes the code they read back. A wrong code is a `401` — ask them for the current one and try again. A good code marks the phone verified. If this is a new Layout account, it becomes theirs, `claimed` is `true`, and you are connected to them from here on. If the person already had their own Layout account, headless verify does not connect you to it; use the consent flow below instead, which is why a `connect` next-step routes there rather than here. Either way you get no session and never see anything about an account that already existed. Once the person is claimed, calling `verify` again answers `verified: true` without needing a fresh code. For a number that belonged to an existing account, `claimed` is `false` and a repeat call needs a current code. > You do not have to do this. A person can verify themselves on the hosted link the first time they open it. Verifying headlessly just removes the step while you already have them. ## Someone who already has Layout When the number already belongs to a Layout account, you cannot attach yourself to it on your own word, and headless verify will not do it. Its owner connects you, on the same consent screen a Layout AI assistant uses. **By default Layout texts the person the approval link itself** and provisioning returns `next: "awaiting_user_approval"` — you do nothing but wait for the connection. If you would rather deliver the link yourself, pass `connectDelivery: "developer"` when you provision, or mint one any time with the call below. ``` POST /v1/users/usr_4b8e/connect { "returnUrl": "https://your.app/connected" } → 200 { "next": "awaiting_user_approval", "awaitingApproval": { "phoneHint": "+1 (916) •••-0142", "expiresInSec": 600 } } POST /v1/users/usr_4b8e/connect { "returnUrl": "https://your.app/connected", "deliver": "developer" } → 200 { "next": "connect", "connectUrl": "https://account.layout.link/authorize?request_id=…", "expiresInSec": 600 } ``` By default Layout texts the link and you wait. If the text cannot be sent, the response is the `connect` form instead, so the person is never unreachable. A person who is already connected answers `connected: true`. When you have a `connectUrl`, open it in the person's browser. They sign in to their own Layout account and see *"You are giving {your app} permission to place orders on your behalf."* If they approve, and the account is theirs (they hold the very number you provisioned), you are connected and they land back at your `returnUrl`. `returnUrl` is optional and must be `https`; leave it out and they see a short confirmation on the Layout page instead. The link is good for ten minutes. This is the only way a pre-existing account is ever attached to you, and it is the account owner who does it. You get no session, no card, and nothing about their account back from it. ## The connection lasts Once a person is connected, whether they proved a new number or consented on an existing account, the connection is durable and surface-agnostic. You keep building carts for them, and they keep approving each one, even after they open the Layout app, add a card, or sign in somewhere else. Signing in no longer cuts you off. What stays true is the boundary: you build and propose, every charge is theirs to approve, and they can disconnect you at any time from their Layout account's connected apps. Do that and your next build for them stops. ## Building without a card Someone you just provisioned has no card on file, and for a while they do not need one. Ask for a build and Layout drives the real restaurant, assembles the cart, and hands it back priced — nothing charged, nothing owed. The card is the step that places the order, and that step is theirs. How many of these no-card builds an application may run in a day is a ceiling Layout sets per application. We seed it from the volume you gave us when you applied, so an approved application usually starts with room to work; as your traffic grows you can ask us to raise it. Until then it is a real limit: once the day's builds are spent, the next build for a card-less person comes back asking for a card instead of running. ## When a card is needed Two moments call for one: the person is ready to place an order, or the day's no-card builds are used up. Either way the response carries a Layout-hosted link. It opens a full-screen page that says who sent them — *"{your app} asked you to add a card"* — takes the card, and drops them back where you sent them from. The card is entered on Layout's page and held by Layout; it never touches your servers, and you never see it. You can carry a return URL into that page so the person lands back in your flow once the card is saved. It has to be an `https` address; anything else is dropped and they land on their Layout billing page instead. ## Reconciliation If a provisioned user later signs up to Layout on their own with the same number, the two are reconciled: their order history, saved card, and taste profile carry across. You never see a user's own orders, an order through another partner, or their card. > Provisioning mints nothing that can spend. A person builds without a card up to the app's daily allowance, and adds a card on the Layout-hosted link to place. --- # Ordering over MCP Ordering runs over MCP. 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; the person confirms and pays on Layout’s hosted page. ## Connect Mint a build session with `POST /v1/users/:id/grant` and present its `lgb_` token as a bearer credential. The grant authenticates the session directly, for that one provisioned user; there is no OAuth dance for a server-minted build token. ```json { "mcpServers": { "layout": { "url": "https://mcp.layout.link", "headers": { "Authorization": "Bearer lgb_9f3a2c…" } } } } ``` ## The tools Three tools. The one that orders is `order`, and it is a state machine rather than separate calls — `action` walks it through `preview`, `build` and `status`. A build session stops there: confirming and paying happen on Layout’s hosted page, not as a tool call. | Tool | What it does | | --- | --- | | `order` | Resolve a restaurant and build a cart. Driven by `action`, below. | | `order_status` | Long-poll a build or a placement until it settles. Read-only and free. | | `places` | Find restaurants by name or description. Answers from stored data, so it is free and instant. | ### The order actions | action | What it does | | --- | --- | | `preview` | Resolve the restaurant and estimate. Pass the brand name in `query`, not the food. | | `build` | Start building the cart. Returns `building` immediately; poll for the rest. | | `status` | Poll a build by `idempotencyKey`, or a placement by `orderId`. | ## Relay the card verbatim 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**, and 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. > The MCP session is build-only. The card, the phone step, and the payment all happen on Layout's hosted page. --- # 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, and never a private or internal host. Your signing secrets are on the [Webhooks](https://developer.layout.link/console/webhooks/subscriptions) page: sandbox and production sign with **different** secrets, so hold both and verify each delivery with the one for its environment. ## 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. Verify it, and check the timestamp to bound replay, before trusting a payload. Pick the secret by the delivery\u2019s environment: the `X-Layout-Environment` header is `sandbox` or `live`, each signed with that environment\u2019s own secret. Checking the header before you verify rejects a cross-environment forgery for free \u2014 a sandbox secret can never produce a valid signature for a live delivery. ```js import crypto from "node:crypto"; function verify(req, secret) { const [t, sig] = req.headers["x-layout-signature"].split(","); const ts = t.slice(2), signature = sig.slice(2); const expected = crypto .createHmac("sha256", secret) .update(ts + "." + req.rawBody) .digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; return fresh && crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } ``` ## Delivery and retries Return 2xx quickly. Anything else is retried on a fixed schedule, five attempts in total: | Attempt | Sent | | --- | --- | | 1 | Immediately | | 2 | 30 seconds later | | 3 | 2 minutes later | | 4 | 10 minutes later | | 5 | 1 hour later | - After the last attempt a delivery is dead-lettered. Replay it from the delivery log in the console. - Deliveries can arrive out of order or more than once. Key on the order id and the event, and make handlers idempotent. - 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. A test payload carries `"test": true` at the top level. Ignore those in production handlers: ``` if (payload.test) return res.status(200).end(); ``` The ids inside are fixed placeholders that match nothing real: the order is always `ord_test_000000000000` and the user `usr_test_000000`. If you look one up and get nothing back, that is the right answer, not a bug. ## Payload Every delivery has the same top-level shape. Default payloads carry ids, state, and totals only. **Card data never appears in a webhook.** Richer cart detail is available behind an explicit data agreement. ```json { "event": "order.placed", "delivery_id": "dl_351eacf74201", "order": { "id": "ord_87228f5aa7b9", "state": "placed", "store": "Layout test kitchen", "items": 1, "total_minor": 1234, "currency": "usd" }, "user": { "id": "usr_4b8e" }, "sent_at": "2026-09-13T10:14:03Z", "test": true } ``` Read the field names off this, not off intuition: the event type is `event` (not `type`), the delivery id is `delivery_id` (not `id`), and the order is nested under `order` (not `data`) as `id` / `store` / `items` / `total_minor` / `currency`. `order.id` is the same `ord_` id `GET /v1/orders/:id` returns; totals are minor units. `test` is `true` only on a console test delivery. --- # 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 store, items, modifiers, totals, and pickup time. | | `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` | The order could not be placed, with a reason code. Nothing was charged. | | `order.unconfirmed` | Placement is genuinely uncertain. Do not retry blindly and do not treat as failed. That is the double-charge trap. | > **Reserved, not yet sent:** `order.needs_approval` and `order.challenge` are defined for a future money-ceremony relay and are not emitted today. Do not build a handler that waits on them yet. ## Example payload ```json { "event": "order.placed", "delivery_id": "dl_9f3a2c", "order": { "id": "ord_7c21", "state": "placed", "store": "Sweetgreen, Market St", "items": 2, "total_minor": 2140, "currency": "usd" }, "user": { "id": "usr_4b8e" }, "sent_at": "2026-09-10T17:01:07Z" } ``` --- # 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 merchant, backed by a confirmation screen and upgraded by the receipt email. Safe to tell the person it's ordered. - **failed**: it did not go through, with a reason code. Nothing was charged; it is safe to try again. - **unconfirmed**: Layout could not verify either way. **Do not retry automatically and do not show "failed."** Surface it as pending and let the person or your support flow resolve it. Collapsing this into failed is what causes duplicate orders and double charges. ## Error shape Every error, on every endpoint, has the same shape. ```json { "error": { "code": "price_changed", "message": "The total moved since the cart was shown.", "orderId": "ord_7c21" } } ``` ## Status codes | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_input` | A field is missing, malformed, or too long. The message names it. | | 401 | `unauthorized` | The secret was not recognised, was malformed, or has been rotated. | | 403 | `unauthorized` | The secret is real and this application is not permitted to do that. Usually production on an unapproved application. | | 404 | `not_found` | No such order or user *for this application*. A resource belonging to another partner is a 404, never a 403. | | 405 | `method_not_allowed` | Wrong verb for that path. | | 409 | `already_claimed` | That person has claimed their own Layout account. See below. | | 429 | `rate_limited` | A per-minute or per-day ceiling. Read `Retry-After`. See [Rate limits and idempotency](https://developer.layout.link/reference/limits). | | 500 | `internal` | Our fault. Safe to retry with the same `idempotencyKey`. | | 502 / 503 | `unavailable` | A dependency is down or the platform is paused. Retry with backoff. | ## Order-level codes These arrive on a specific order rather than on the request. | Code | Meaning | | --- | --- | | `not_orderable` | The store cannot be ordered from right now: closed, removed, or unsupported. | | `price_changed` | The total moved since the cart was shown. Re-show it and confirm again. | | `needs_approval` | A money ceremony is required. The person completes it on the hosted link. | | `build_only` | The session tried to confirm or spend. That only ever happens on the hosted link. | On a failed order the reason is carried as `failure_code`. It is a stable machine code, not prose, and it is the only failure field the partner API exposes. ## Why a refusal never says why A 401 and a 403 carry the same body whatever the underlying reason: ```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 the answer never varies. You still get the real reason, on your application's **Refusals** page in the console, with a count and a last-seen time. | Reason | What happened | | --- | --- | | `live_not_approved` | A production key on an application that is not approved. See [Environments and approval](https://developer.layout.link/reference/environments). | | `credential_revoked` | The secret was rotated. 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 on the internet could fill your Refusals page with noise. ## 409 already_claimed Say you provision someone, and months later they sign up to Layout themselves on the same phone number. The account becomes theirs from that moment — their card, their history, their say. Calls against that user then return **409** `already_claimed`. Do not retry past it. They have graduated out of the arrangement you had, and ordering on their behalf is no longer yours to do. --- # Rate limits and idempotency Where the ceilings are, when to come back after a 429, and the one field that stops a retry from costing you twice. ## Idempotency Pass an `idempotencyKey` to the MCP `order` tool\u2019s `build` action. Skipping it is the most expensive habit you can pick up against this API. With a key, a retry returns the original build. Without one, a timeout you retry becomes **two builds**: two browser sessions, two runs against the restaurant\u2019s site, two draws on your daily budget. It cannot double-charge anyone, because building is not placing and placement happens on the hosted link. It can absolutely double your bill and confuse your user with two carts. Use a fresh random key for each genuinely new request, and carry that same key into any retry of it. When the person asks again, give it a new key. See [Ordering over MCP](https://developer.layout.link/reference/mcp). > The key is scoped to your application and the user, so it cannot collide with another partner's. ## Per-minute limits | Endpoint | Per credential | | --- | --- | | `POST /v1/users/:id/grant` | build sessions: 120 / hour | | `GET /v1/orders/:id` | 600 / minute | | `GET /v1/events` | 120 / minute | ## Daily ceilings These are the ones that actually stop you, and they reset at UTC midnight. Approval moves you up a tier. | Ceiling | Sandbox | Production | | --- | --- | --- | | Users provisioned, per day | 200 | 500 | | Build sessions, per account per day | 500 | 2,000 | | Order builds, per user per day | 25 | 25 | Sandbox ceilings are small on purpose. They are sized for building against, not for load testing — every sandbox order still opens a real browser on a real restaurant's website, and they did not sign up for your soak test. Point that at your own stubs. ## Sandbox builds, per application Each application also has its own daily sandbox build allowance, **50 by default**, and it is usually the one you meet first. When it is spent, the next sandbox build is refused with a message that says so and when it resets. Open **Usage** in the console to see today's count and to ask for more. Sandbox and production allowances never draw on each other. ## No-card builds, per day There is one more daily ceiling, and it is not in the table above because it is not the same for everyone: how many builds an application may run for a person with **no card on file**. We set it per application and seed it from the volume you gave us when you applied, so it is a number that fits your traffic rather than a flat cap. Spend it, and the next card-less build comes back asking for a card instead of running — a build for someone who already has a card is never touched by it. Ask us and we will raise it as you grow. See [Building without a card](https://developer.layout.link/reference/provisioning#building-without-a-card). ## Handling a 429 You get `rate_limited`, and a `Retry-After` in seconds whenever we can work one out. Use it rather than guessing. Hitting a daily ceiling gives you the same code with no retry time, because the honest answer is UTC midnight. ```bash HTTP/1.1 429 Too Many Requests Retry-After: 34 { "error": { "code": "rate_limited", "message": "Too many requests. Retry later." } } ``` Back off, and carry the same `idempotencyKey` into the retry. A backoff loop that mints a fresh key each time is how one slow request turns into fifty builds and a bill to match. --- # 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. --- # Week of September 21 September 22, 2026 · Console Two smaller changes from the week the Orders and Usage pages shipped. ## Reset a forgotten password The console sign-in page has a **Forgot password** link. Enter your email and, if it belongs to a developer account, you get a link to set a new password. - The link works once and expires after fifteen minutes. - The page answers the same way whether or not the email has an account, so it cannot be used to find out who builds on Layout. - Setting a new password signs out the sessions you already had open. ## A quickstart in four steps [Quickstart](https://developer.layout.link/quickstart) is now in the top navigation: connect an MCP client, mint a build grant for the person, send one sentence, and watch the order build. The step-by-step reference version is still at [/reference/quickstart](https://developer.layout.link/reference/quickstart). ## Breaking changes None. --- # 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://developer.layout.link/reference/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 Fixes from using the console every day, and a pass over the guides to make them match what the API does. ## 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. - The approval text carries a full link to tap, not a short code that looked like a verification code. - 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. - The console says what happened after you provision or resend: a link texted, already connected, or not texted because it is sandbox. ## Errors that explain themselves When the API rejects a console request with a reason, the console now shows that reason instead of a generic "Check the details and try again". A person who revoked your access, a send that failed and an opted-out number each get their own message. ## One search everywhere The console's search box now runs the same search as the docs: every section of every page, ranked, with the matching words highlighted and `⌘K` to open it. ## Guides corrected - [Ordering over MCP](https://developer.layout.link/reference/mcp) describes connecting with a build grant token and the three tools that session can call. - [Provisioning users](https://developer.layout.link/reference/provisioning) shows that Layout texts the approval link by default, with `next: "awaiting_user_approval"`. - [Event types](https://developer.layout.link/reference/events) marks `order.needs_approval` and `order.challenge` as reserved and not sent today. - [Rate limits and idempotency](https://developer.layout.link/reference/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://developer.layout.link/reference/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 headlessly, or send the person `handoffUrl`. | | `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://developer.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. The hosted link still works for anyone you would rather send there. See [Verify their phone](https://developer.layout.link/reference/provisioning#verify-their-phone). --- # Ordering moves to MCP September 13, 2026 · MCP · Breaking 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://developer.layout.link/reference/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. --- # Week of September 7 September 13, 2026 · Console The smaller changes from the platform's first week, alongside the releases that got their own posts. ## Subscriptions in a drawer Opening a webhook subscription, or adding one, now slides a drawer in from the right instead of covering the whole screen. The subscription's name is the title, with its state and event count under it and **Send test event** at the top. The body holds the notification URL, the events and the on or off switch, and Save sits at the bottom. On a phone the drawer takes the full width. ## Logo uploads go through Some uploads failed with "That file could not be read as an image" even when the file was fine. They now upload, whatever the browser does or does not decode. ## A test phone you cannot mistype The sandbox test account's phone field is a fixed `+1 (500) 555-` prefix and four digits, so a number outside the reserved sandbox range cannot be entered by accident. ## Guides - [Building without a card](https://developer.layout.link/reference/provisioning#building-without-a-card) and [Adding a card](https://developer.layout.link/reference/provisioning#adding-a-card) cover no-card builds and Layout's card page. - [Verify their phone](https://developer.layout.link/reference/provisioning#verify-their-phone) documents headless verification. - [Someone who already has Layout](https://developer.layout.link/reference/provisioning#connecting-an-existing-account) and [The connection lasts](https://developer.layout.link/reference/provisioning#the-connection-lasts) cover connecting existing accounts. - The Quickstart uses a reserved sandbox number, so copying it works first time. ## Breaking changes None. --- # 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://developer.layout.link/reference/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://developer.layout.link/llms.txt) | A short map of the docs, with a one-line summary of every page. | | [/llms-full.txt](https://developer.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://developer.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://developer.layout.link/llms.txt ``` ## New and corrected pages - [API reference](https://developer.layout.link/reference/api) lists every endpoint with a real request and response. - [Environments and approval](https://developer.layout.link/reference/environments), [Authentication](https://developer.layout.link/reference/authentication) and [Rate limits and idempotency](https://developer.layout.link/reference/limits) are new. - [Ordering over MCP](https://developer.layout.link/reference/mcp) lists only tools that exist. An earlier version named tools that never shipped. - [Webhooks](https://developer.layout.link/reference/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 The console is now somewhere you can work rather than a place you visit once to copy a key. ## 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://developer.layout.link/reference/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. - **Faster navigation.** Moving between console pages swaps only the page, so the sidebar and your application list stay put. - **Welcome email** arrives once onboarding is actually complete, not at sign-up. ## 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://developer.layout.link/reference/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://developer.layout.link/reference/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** from a durable outbox: 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://developer.layout.link/console/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://developer.layout.link/reference/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://developer.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://developer.layout.link/reference/mcp#connect). ## Breaking changes None. The consent flow, its scopes and its redirect behaviour are unchanged.