# Introducing the REST API

October 1, 2026 · 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

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](https://developer.layout.link/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.

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

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

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

```bash
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
  }
}
```

### 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](https://developer.layout.link/reference/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.
