# 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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/carts#get-v1-carts-id) and [Failure codes](https://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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://developer.layout.link/reference/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.
