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, generated from the shapes the API validates with.

Every request

HeaderDirectionNotes
Idempotency-KeyYou sendOn 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.
Layout-VersionBothOptional 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.
X-Request-IdLayout sendsLayout’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.
Idempotent-ReplayedLayout sendstrue 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.

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.

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.

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.

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. 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), 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 for what each one means.

FieldRequiredNotes
firstNameYes1 to 100 characters. Kept on the account Layout holds for the person.
lastNameYes1 to 100 characters. Kept on the account Layout holds for the person.
phoneYesE.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.
emailNoKept 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.
labelNoYour own name for this person, shown in the console. Up to 120 characters.
connectDeliveryNo"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.

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 for the limits and every error, and Texts Layout sends for the text itself.

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 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 before the first build (see 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.

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.

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.

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 (see Confirming with a build grant). 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.

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.

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.

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, whose card comes from Layout. Register return URLs on the console's App details page (see Return URLs). A person who has not verified with your application is 409 person_not_ready. See 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.

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" }
}
FieldNotes
authorizedtrue when you may build for this person.
cardnull until authorized is true: before that, Layout says nothing about the person's account.
card.onFiletrue when the person's Layout account holds a card.
card.orderabletrue 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.
card.brand, card.last4The card Layout would charge, or null with no card. Nothing else about a card is ever returned.
textsBlockednull 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 (see 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.

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.

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. In sandbox, a search and a browse list only restaurants whose menu Layout holds, since a sandbox cart is built from that menu.

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
}
ParameterRequiredNotes
queryYes, or ids, or lat and lng to browseA 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 and answers at most 10 places.
idsYes, or query, or lat and lng to browseUp to 25 placeIds 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 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.
nearNoA 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, lngNoWhere 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.
includeNoclosed 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.
orderingNoconfirmed lists only places where Layout has found an online ordering route.
limitNo1 to 25. Default 5. One name answers at most 10; several names fill up to 25. With ids, every place you sent is answered.
sortNorelevance (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.
cursorNoA browse’s nextCursor, for the next page. See Browse nearby.

An empty list is not proof there is nothing there. Read these before saying so.

FieldNotes
searchedfalse when Layout did not look this time.
emptyBecauseall_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.
localityMatchedfalse 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.
searchLimitSent 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.
stillCheckingtrue while Layout is still finding out whether more places there take online orders, with stillCheckingCount when known. Ask again in a minute.
matchedOnOn 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.
cuisinesFiner 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.
openStatusopen in a search. With include=closed or ids, and on GET /v1/places/:placeId, it can be closed, or unknown when Layout holds no hours, which never means closed.
closesAtIso, opensAtIsoWhen 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, lngWhere the store is, for a map. null when Layout does not hold it.
missingWith ids: the ids Layout holds no place for, or no longer shows. Present only when there is one.
orderingconfirmed (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.

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.

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

statusMeaning
menuThe live menu. confidence: partial means it may not be all of it. Prices are minor units.
categoriesThe site splits its menu into sections. Send one back as category.
readingStill reading. Poll the menuId.
knowledgeGeneral 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.
unreadableLayout could not read this menu cleanly. The person can still order by naming what they want.
not_foundNo online ordering site was found for that name.
needs_locationSend 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.

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.

statusMeaning
photoShow image.url. item is the name as the restaurant writes it.
findingStill looking. Poll the imageId.
no_photoreason: 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_menuLayout read the whole menu and the item is not on it.
needs_itemitem named no dish, only the restaurant or a word like food. Ask the person which item. Layout never picks one for them.
needs_locationA restaurant with branches that differ. Send near, coordinates, or one of candidates as placeId.
not_foundNo restaurant site was found for that name.
removedThe 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); 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.

GET /v1/orders

The orders your application caused in this secret’s environment, newest first, each in the shape of 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.

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": "…" }
ParameterNotes
userIdOptional. Only this person’s orders. A person who is not your application’s in this environment is 404 not_found.
cursorOptional. The nextCursor of the previous page, opaque. nextCursor is null on the last page.
limitOptional. 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.

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.

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 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 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:

FieldNotes
subtotal_minor, tax_minorThe store's own, when it stated them. Null otherwise.
merchant_total_minorThe store's total, the same as total_minor.
service_fee_minorLayout's service fee.
service_fee_waivedtrue when the person's plan waives the fee.
credit_minorLayout credit taken off.
courier_fee_minor, tip_minor0 on a pickup order.
charged_minorWhat 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. 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 didready_etaEvent
Stated a time on its confirmation screenSet when the order is placedorder.placed
Stated a time later, for instance in its emailNull at placed, then setorder.updated when it arrives
Never stated a timeStays nullNone 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).
  • 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).
  • A sandbox order answers Ready in about 15 minutes. A cart built with "simulate": "pickup_later" places the order with no time and sends it later, with its own order.updated.

States

StateMeaning
buildingLayout is on the restaurant's site assembling the cart.
cartedPriced and ready. Show the person the total, then confirm it or let them approve it.
placingThey confirmed. Submission is in flight.
placedIt reached the merchant, with evidence. The only state that means the food is coming.
failedIt was not placed. failure_code says why: see 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.
unconfirmedLayout cannot tell either way. Do not retry and do not say “failed”.
refundedDeprecated 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_…. 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

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.

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…"
}
ParameterNotes
afterAn 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.
cursornextCursor from the previous page, to keep walking. Send cursor or after, not both.
sinceOnly events Layout recorded at or after this time, as an ISO 8601 timestamp with a zone, e.g. 2026-10-01T00:00:00Z.
order_idOnly this order's events.
user_idOnly this person's events, by the usr_ id the payload carries.
eventOnly this event type, e.g. order.failed.
limit1 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 for the fields.

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.

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.