# 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).
