Introducing the REST API

API

Until now, ordering through Layout meant driving an MCP session. A backend that already knows what the person wants had to run a model loop to get there. The REST API takes the same tools and puts each one behind an endpoint, so your server can find a restaurant, read its menu, build a cart and confirm it with ordinary HTTP calls.

Nothing about an order changes on the way. The endpoints call the same build, the same confirm and the same checks the MCP tools do, so a cart built over REST is the same real cart, built on the restaurant's own site, and it is held to the same price lock, daily limit and confirmation.

What it is

Over MCPOver REST
placesGET /v1/places
place_factsGET /v1/places/:placeId
menuPOST /v1/menus, then GET /v1/menus/:menuId while it reads
order buildPOST /v1/carts
order statusGET /v1/carts/:id
Building again with the person's choicesPOST /v1/carts/:id/answer
order confirmPOST /v1/carts/:id/confirm
order confirm with stage: "resend"POST /v1/carts/:id/confirm/resend-code
Cancelling a ready cartPOST /v1/carts/:id/cancel

Two more endpoints read for one person: GET /v1/orders/:id, which now also accepts a connected person's token for that person's orders your application drove, and GET /v1/users/me/profile, which returns their dietary switch and preference notes and nothing that identifies them. openapi.json is generated from the same shapes the API validates every request with.

A cart is built and unpaid. Its id starts with crt_. Once it is confirmed it becomes an order, with the ord_ id your webhooks and GET /v1/orders/:id already use.

Two ways in

The REST API opens the same two doors MCP does, and nothing more. Your application secret is not one of them: it still acts as your application, for provisioning, grants, events and order reads.

CredentialWho it acts forWhat it can do
OAuth access token for https://api.layout.linkA person who connected your application on Layout's consent screen, through an OAuth client you created in the consoleSearch, read, build, confirm and cancel. A live confirm takes the 6-digit code Layout texts the person when ordering anywhere else in Layout would.
Build grant, lgb_…A person you provisionedSearch, read and build. It cannot confirm, cancel, read orders or read the profile. In sandbox its confirm is a simulation.

A live build grant that tries to confirm is refused with 403 build_only before anything is read. The person confirms that cart themselves, by replying to Layout's text or in the Layout app.

Authentication

Send the credential in the Authorization header. The API never reads a cookie for these routes.

A connected person. Run the authorization code flow with PKCE against an OAuth client from the console's OAuth clients page, and ask for the API as the resource. A token for https://mcp.layout.link is refused by the API, and a token for the API is refused by MCP.

https://api.layout.link/v1/oauth/authorize
  ?response_type=code
  &client_id=$CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
  &code_challenge=$CODE_CHALLENGE
  &code_challenge_method=S256
  &scope=order
  &resource=https%3A%2F%2Fapi.layout.link
  &state=$STATE
curl -X POST https://api.layout.link/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d client_id=$CLIENT_ID \
  -d redirect_uri=https://yourapp.example/callback \
  -d code_verifier=$CODE_VERIFIER \
  -d resource=https://api.layout.link

Access tokens last ten minutes. Refresh keeps the resource the person approved. When the person disconnects your application, their token is refused on the next request.

A provisioned person. Mint a build grant with your secret, exactly as you do for MCP, and send the lgb_ token as the bearer.

curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \
  -H "Authorization: Bearer $LAYOUT_SECRET"

Check either one with GET /v1/whoami. It answers kind: "user" for a connected person and kind: "grant" for a build grant, with the environment and the usr_ id your webhooks carry for that person.

curl https://api.layout.link/v1/whoami \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN"

200 OK
{
  "kind": "user",
  "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" },
  "environment": "live",
  "user": { "id": "usr_4b8e" },
  "scopes": ["order"]
}

Production needs your application approved. A production person on an application that is not approved yet is refused with 403.

A whole order, start to finish

Every sample below is a connected person in production, ordering an iced oat latte at Layout test kitchen. The base URL is https://api.layout.link, and every request carries Authorization: Bearer $LAYOUT_PERSON_TOKEN.

1. Build a cart

Name the store with a placeId from a places search, an orderUrl, or a restaurantName with near or geo. Say what the person wants in request, or as a list in items. idempotencyKey is required: 8 to 100 letters, digits, ., _, : or -, unique per real intent.

POST /v1/carts
{
  "request": "an iced oat latte",
  "placeId": "ChIJbloomCoffee",
  "idempotencyKey": "7f3c2a9e-cart-1"
}

202 Accepted
{ "cart": { "id": "crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x", "status": "building" } }

The build has started on the restaurant's site, and nothing is charged. Sending the same key with the same body again returns the same cart; the same key with a different body is 409 idempotency_conflict. The cart id carries your key, so never put anything private in one.

If more than one store matches a name, the answer is 409 store_ambiguous with up to five candidates. Ask the person which one, and send its placeId.

2. Poll it

Read the cart until its status is no longer building. Here the restaurant needs a size before it can price the drink.

GET /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x

200 OK
{
  "cart": {
    "id": "crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x",
    "status": "needs_answer",
    "store": null,
    "items": [],
    "currency": "usd",
    "totalMinor": null,
    "fees": null,
    "card": null,
    "codeRequired": false,
    "confirmationCard": null,
    "decision": {
      "kind": "choices",
      "question": "What size would you like?",
      "item": "Iced Oat Latte",
      "groups": [{ "name": "Size", "required": true, "options": ["Small", "Medium", "Large"] }],
      "answerWith": { "field": "choices" }
    },
    "failure": null,
    "order": null,
    "expiresAt": null
  }
}
statusWhat it means
buildingLayout is on the restaurant's site. Read it again shortly.
needs_answerThe restaurant needs a choice, or the earliest pickup is not today. decision says which.
readyPriced and confirmable until expiresAt.
failedIt could not be built. failure.code is stable and failure.message is written for the person.
expired, canceled, closedNothing left to confirm. A closed cart usually means it was confirmed: read its order.

3. Answer its question

Send the person's answer in their own words with a new key. A pickup_time decision is answered with "acceptPickupTime": true instead, or by building at one of its alternatives. An answer is a new cart: poll the id it returns.

POST /v1/carts/crt_9b1f0c2a4eN2YzYzJhOWUtY2FydC0x/answer
{ "choices": "Large", "idempotencyKey": "7f3c2a9e-cart-2" }

202 Accepted
{ "cart": { "id": "crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y", "status": "building" } }

Poll the new id until it is ready.

GET /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y

200 OK
{
  "cart": {
    "id": "crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y",
    "status": "ready",
    "store": { "name": "Layout test kitchen", "address": "1 Market St, San Francisco, CA" },
    "items": [{ "name": "Iced Oat Latte", "quantity": 1, "priceMinor": 650, "modifiers": ["Large"] }],
    "currency": "usd",
    "totalMinor": 750,
    "fees": {
      "subtotalMinor": 650,
      "taxAndFeesMinor": 60,
      "merchantTotalMinor": 710,
      "serviceFeeMinor": 40,
      "serviceFeeWaived": false,
      "creditMinor": 0
    },
    "card": { "brand": "visa", "last4": "4242" },
    "codeRequired": true,
    "confirmationCard": "…",
    "decision": null,
    "failure": null,
    "order": { "id": "ord_7c21", "status": "carted" },
    "expiresAt": "2026-10-01T17:15:00.000Z"
  }
}

totalMinor is the only total. It is the restaurant's total plus Layout's service fee when the fee is not waived, and it is the number confirm takes back. Never add up fees yourself. Show the person the total, the items and the card before they confirm; confirmationCard is Layout's own wording of all three, to show as written. card and confirmationCard are only on a connected person's token. A ready cart can be confirmed for 15 minutes.

4. Confirm, and relay the code

Send the cart's totalMinor as expectedTotalMinor with a key for this confirm. A live confirm takes the 6-digit code Layout texts the person exactly when ordering anywhere else in Layout would: they keep codes on, the restaurant's total is over $50, Layout's risk checks ask for one, or a code is already out for this cart. Otherwise the first call answers 202 placing and you are done. codeRequired on the cart says what to expect, and the confirm decides. This person keeps codes on, so the first call answers code_required and Layout texts them.

POST /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y/confirm
{ "expectedTotalMinor": 750, "idempotencyKey": "7f3c2a9e-confirm-1" }

400 Bad Request
{
  "error": {
    "code": "code_required",
    "message": "Layout just texted the person a 6-digit code. Ask them for it and send this same request again with code.",
    "orderId": "ord_7c21"
  }
}

Ask the person for the code, and send the same request with it added.

POST /v1/carts/crt_3d8a61f0b2N2YzYzJhOWUtY2FydC0y/confirm
{ "expectedTotalMinor": 750, "idempotencyKey": "7f3c2a9e-confirm-1", "code": "482913" }

202 Accepted
{ "order": { "id": "ord_7c21", "status": "placing" } }

Asking again without a code while one is already out does not text another. To text a fresh one, call POST /v1/carts/:id/confirm/resend-code, which answers { "codeSent": true }, or code_not_needed on a cart that needs no code. A wrong code is code_invalid, and one that is no longer good is code_expired. If the person's number replied STOP to Layout, no code can reach them and the answer is 409 texts_blocked: they reply START to the number Layout texts from, then you confirm again.

Confirm answers placing, never placed. The order is on its way to the restaurant, and it is not placed until Layout has evidence that it was.

5. Hear how it ended

Your webhook endpoint receives order.placed once it is placed, or order.failed or order.unconfirmed if it is not, signed exactly like every other delivery.

POST https://yourapp.example/layout/webhooks
User-Agent: Layout-Webhooks/1
X-Layout-Event: order.placed
X-Layout-Delivery: dl_351eacf74201
X-Layout-Environment: live
X-Layout-Signature: t=1790874182,v1=5f2b…

{
  "event": "order.placed",
  "delivery_id": "dl_351eacf74201",
  "order": {
    "id": "ord_7c21",
    "state": "placed",
    "store": "Layout test kitchen",
    "items": 1,
    "total_minor": 710,
    "currency": "usd"
  },
  "user": { "id": "usr_4b8e" },
  "sent_at": "2026-10-01T17:03:02Z"
}

Or read the order with the same person token, or with your secret.

GET /v1/orders/ord_7c21

200 OK
{
  "id": "ord_7c21",
  "state": "placed",
  "store": "Layout test kitchen",
  "items": 1,
  "total_minor": 710,
  "currency": "usd",
  "created_at": "2026-10-01T16:58:41Z",
  "placed_at": "2026-10-01T17:03:02Z",
  "failure_code": null
}

An order's total_minor is the restaurant's total. The cart's totalMinor adds Layout's service fee when it is not waived, so the two can differ.

Rules that do not move

  • The price lock. If the total moved since you read the cart, confirm is 409 price_changed and nothing is charged. Read the cart again and show the person the new total.
  • The person's limits. Their daily spending limit applies (over_daily_cap), and builds draw on the same daily build limits as builds over MCP (429 build_limit, with a message that names the limit).
  • Confirm only what the person agreed to. A confirm meets the same checks as ordering through MCP, so it often needs no code. Never confirm a cart the person has not seen.
  • placed is the only state that means the order exists. unconfirmed means Layout cannot tell either way: do not retry it and do not show it as failed.
  • When an answer is uncertain, read before you act. A 500 internal from confirm means the outcome is not known. Read GET /v1/orders/:id first, and do not build the order again until it reports a final state. A 503 unavailable means a check could not run and nothing was confirmed or charged: retry the same request.
  • One error shape. Every refusal is { "error": { "code", "message" } }, with orderId when there is an order and candidates on store_ambiguous. Switch on code; never parse message.
  • Isolation. A cart or order from another application, or another person, is a 404.

Sandbox

With a sandbox person or a sandbox build grant, a cart is built on the real restaurant site as in production, codeRequired is false, and confirm takes no code. It answers 202 with "simulated": true on the order. No restaurant receives it and nothing is charged, and the order's webhooks and reads carry simulated: true. resend-code answers code_not_needed.

What to do

Nothing, if you build over MCP: it is unchanged. To order over REST, create an OAuth client for people who connect your app, or keep using build grants for people you provision, and follow the five steps above. Carts has every field and error code. Before you confirm, show the person the total, the items and the card, and treat placing as in flight until a webhook or an order read says placed.

Breaking changes

None. These are new endpoints. POST /v1/orders is still retired, and every existing endpoint answers as before for your application secret.

All changes