# Rate limits and idempotency

Every ceiling that can refuse you, when to come back after a 429, and the key that stops a retry from running twice.

## Per-minute and per-hour limits

Counted per credential (your client id) over a rolling window, unless the row says per person. Your calling address does not count against any of them.

| Endpoint | Approved, in production | Sandbox |
| --- | --- | --- |
| `POST /v1/users` | 600 / hour | 600 / hour |
| `POST /v1/users/:id/grant` | 1,200 / hour. A refresh does not count | 120 / hour |
| `POST /v1/users/:id/verify/start` | 250 requests an hour per credential. Texts actually sent are capped by your account's [texts per hour](#texts-per-hour), which starts at 30 | 120 / hour |
| `POST /v1/users/:id/verify` | 1,500 / hour | 300 / hour |
| `POST /v1/users/:id/connect` | 250 requests an hour per credential. Texts actually sent are capped by your account's [texts per hour](#texts-per-hour), which starts at 30 | 120 / hour |
| `POST /v1/users/:id/links` | 10 per 10 minutes, per person, and 600 / hour per credential | 10 per 10 minutes, per person, and 600 / hour per credential |
| `GET /v1/orders/:id`, `GET /v1/orders` | 600 / minute with your secret, shared | 600 / minute with your secret, shared |
| `GET /v1/places`, `GET /v1/places/:placeId` | 30 / minute, per person, shared. The first query in an area can take several seconds while it answers `stillChecking` | 30 / minute, per person, shared |
| `GET /v1/events` | 120 / minute | 120 / minute |
| `GET /v1/whoami` | 120 / minute with your secret | 120 / minute with your secret |
| `GET /v1/usage` | 60 / minute | 60 / minute |
| `POST /v1/sandbox/webhooks/fire` | Sandbox only | 30 / minute, and 1,000 a day per developer account |
| `POST /v1/menus` | 20 per 10 minutes, per person, shared with that person's own Layout app | 20 per 10 minutes, per person, shared with that person's own Layout app |
| `POST /v1/images/items` | 20 per 10 minutes, per person | 20 per 10 minutes, per person |
| `GET /v1/menus/:menuId` | 120 / minute, per person | 120 / minute, per person |
| `GET /v1/images/items/:imageId` | 120 / minute, per person | 120 / minute, per person |
| `POST /v1/carts/:id/cancel` | 6 per 10 minutes, per person, counting only cancels that find nothing to stop | Same |

### Every request, per credential

Every call also counts toward one limit per credential across every endpoint together: **3,000 a minute** for an approved application in production, and 600 a minute for sandbox, or for an application not yet approved. A credential is your secret, a person's access token, or a build grant, each counted on its own.

Credentials Layout has no record of count against the calling address, at 300 a minute. A refused credential Layout issued, such as a revoked secret, counts against itself, at 600 a minute.

### RateLimit headers

Every answer to a request that authenticated carries the per-credential count:

```
RateLimit-Limit: 3000
RateLimit-Remaining: 2987
RateLimit-Reset: 41
```

`RateLimit-Reset` is the seconds until `RateLimit-Remaining` next rises. A `429` from a per-endpoint limit carries that endpoint's numbers, and one from a daily ceiling carries none.

## Seeing your limits and usage

`GET /v1/usage` with your secret answers every limit on this page as it applies to you, in that key's environment, with what your developer account has spent today against each daily one. It is free.

```bash
curl https://api.layout.link/v1/usage \
  -H "Authorization: Bearer $LAYOUT_SECRET_KEY"

200 OK
{
  "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" },
  "environment": "live",
  "approvalStatus": "approved",
  "resetsAt": "2026-10-02T00:00:00.000Z",
  "daily": [
    { "name": "builds", "description": "Builds on the restaurant's own site. In sandbox, only engine real", "scope": "developer",
      "limit": 5000, "used": 412, "remaining": 4588, "poolLimit": 20000, "custom": false },
    { "name": "users_provisioned", "description": "Users provisioned", "scope": "developer",
      "limit": 10000, "used": 1250, "remaining": 8750, "poolLimit": 50000, "custom": false },
    { "name": "cardless_builds", "description": "Builds for a person with no card on file", "scope": "application",
      "limit": 150, "used": 9, "remaining": 141 }
  ],
  "perPerson": [ { "name": "builds", "period": "day", "limit": 25 } ],
  "rates": [
    { "name": "requests", "scope": "credential", "period": "minute", "limit": 3000 },
    { "name": "texts", "scope": "developer", "period": "hour", "limit": 50,
      "verifiedPeople": 400, "max": 1000, "next": { "verifiedPeople": 420, "limit": 51 } },
    { "name": "texts_platform", "scope": "platform", "period": "hour", "limit": 2000 }
  ]
}
```

The example is shortened: `daily` lists every daily ceiling on this page. `poolLimit` is the ceiling across every developer. `custom` is `true` when Layout set that number for your account. A pool closed to the key, such as verification texts in sandbox, has a `limit` of 0 and a `note` saying why.

## Handling a 429

Wait `Retry-After` (also `error.retryInSeconds`). No `Retry-After` means a daily ceiling: `error.limit` names it and it resets at midnight UTC. A spent build ceiling on `POST /v1/carts` or `/answer` is `build_limit` instead of `rate_limited`. Carry the same idempotency key into the retry.

```bash
HTTP/1.1 429 Too Many Requests

{
  "error": {
    "code": "rate_limited",
    "message": "Your developer account has reached its daily limit of 200 users provisioned. It resets at midnight UTC. GET /v1/usage shows every limit; request more from Usage in the console.",
    "limit": { "name": "users_provisioned", "scope": "account", "max": 200, "resetsAt": "2026-10-02T00:00:00Z" }
  }
}
```

| Field | Meaning |
| --- | --- |
| `name` | What the ceiling counts: `builds`, `users_provisioned`, `build_sessions`, `verification_texts`, `place_reads`, `menu_reads`, `menu_knowledge`, `image_searches` or `test_events`. |
| `scope` | Whose ceiling it is: `person`, `application`, `account` (your developer account, across its applications) or `platform` (Layout's own, across every developer). |
| `max` | The ceiling, per day, or per rolling hour when `period` is `hour`. |
| `period` | Only on the hourly ceiling on texts: `hour`. Absent means per day. |
| `resetsAt` | The next midnight UTC, or on an hourly ceiling when the oldest text in the hour stops counting. |

A 429 with neither `Retry-After` nor `limit`, such as an [order code limit](#order-codes), needs the message read and a backoff of at least a minute. Over MCP, the `order` tool answers a refused build with no cart and a `refusal` object whose `limit` has the same shape.

## Daily ceilings

Every one resets at midnight UTC, and sandbox and production are counted separately. Your developer account is every application you have created, together. When a ceiling across every developer refuses you, the message says it is Layout's limit rather than yours.

### Order builds

A build is one `POST /v1/carts`, one `POST /v1/carts/:id/answer`, or one MCP `order` `build` that starts a cart. The first row it hits refuses it. A sandbox cart opens no browser unless it is sent with `"engine": "real"`, so it counts only toward its own 10,000 a day per developer account. See [Sandbox](https://developer.layout.link/reference/sandbox#limits).

| Counted per | Sandbox | Sandbox, `"engine": "real"` | Production |
| --- | --- | --- | --- |
| Person, per credential | No ceiling | 25 | 25 |
| Application | No ceiling | 50, raised from Usage | No ceiling for a person with a card. For a person without one, see [No-card builds](#cardless-builds) |
| Developer account | 10,000 | 10 | 5,000 once your application is approved; 250 until then |
| Credential, every person | No ceiling | 50,000 | 50,000 |

A build with `"engine": "real"` counts toward both its application's 50 and its developer account's 10, and the first one spent refuses it. With the 10 shared by every application on the account, the 50 matters only once Layout has raised the 10. `GET /v1/usage` lists the sandbox 10,000 as `simulated_builds`, and the 10 as `builds`. To raise the 10, email [developer@layout.link](mailto:developer@layout.link), or ask from Usage in the console (see [Raising a limit](#raising-a-limit)).

### Users, sessions and texts

Counted per developer account. Applications awaiting approval count on a separate counter, so they never use up an approved application's room.

| Ceiling | Sandbox | Production |
| --- | --- | --- |
| Users provisioned | 200 | 10,000 |
| Build sessions minted | 500 | 50,000 |
| Verification texts | None sent; the sandbox code is `000000` | 6,000, growing to 24,000 with [verified people](#texts-per-hour) |
| Approval texts Layout sends | None sent; you get the link | 6,000, growing to 24,000 with [verified people](#texts-per-hour) |

Users provisioned counts `POST /v1/users` and the console's Provisioned users page together, and only a call that provisions a new person counts. Until an application is approved, provisioning from the console is held to 25 a day. Layout sends approval texts only for an approved application; otherwise `connect.connectUrl` comes back for you to deliver.

### Verified people raise hourly texts

Verification and approval texts count together, per developer account, over a rolling hour. For an approved application in production the hour starts at **30** and grows by one for every 20 people you have verified, up to **1,000**. `GET /v1/usage` shows yours under `rates` as `texts`, and the next step as `next`. Sandbox keeps 120 an hour per credential. Past the hour, `verify/start` answers `429 rate_limited` with `Retry-After` and `error.limit` with `"period": "hour"`, and `connect` hands you the link to deliver instead of refusing. Ask for more hourly texts in the same way, from **Request a higher limit** on Usage in the console.

### Build grants last 15 minutes

A build grant from `POST /v1/users/:id/grant` lasts 15 minutes. Send the current grant back as `refresh` before it expires to extend the same token another 15 minutes, up to 24 hours from when it was minted. A refresh never counts toward build sessions minted or the hourly limit. A grant that expired, was revoked, or is 24 hours old answers `409 grant_expired`; mint a new one without `refresh`.

```bash
curl -X POST https://api.layout.link/v1/users/usr_4b8e/grant \
  -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "refresh": "lgb_9f3a2c" }'

200 OK
{ "token": "lgb_9f3a2c", "expiresAt": "2026-10-01T18:15:41Z", "scope": "build", "refreshed": true }
```

### Order codes for build grants

Codes Layout texts for a confirm have their own limits, separate from verification and approval texts. Sandbox texts nobody but applies the per-cart and per-person limits.

| Ceiling | Limit |
| --- | --- |
| Codes texted for one cart | 4 |
| Codes texted per credential, per day | 3,000, or 100 until your application is approved |
| Codes texted to one number, per hour | 6 |
| Codes texted to one person, per hour | 10 |

Each refusal is `429 rate_limited` on `/confirm` or `/confirm/resend-code`, and nothing was confirmed or charged. A spent cart or daily ceiling has no `Retry-After`: ask the person for the newest code Layout already texted, or build a new cart.

## Place and menu reads

Per developer account, on top of the person's own. A live menu read or an image search counts only when Layout opens the restaurant's site; anything Layout already holds is free. An image search also counts toward the person's 30 live reads. In sandbox a menu read answers only with the menu Layout holds, in either mode, never reads one live, never gives a knowledge answer, and counts toward none of the menu rows.

| Allowance | Sandbox, or not yet approved (each environment) | Approved, in production |
| --- | --- | --- |
| Place reads, per developer account per day | 5,000 | 50,000 |
| Live menu reads, per developer account per day | None in sandbox. 200 for an application not yet approved | 1,000 |
| Live menu reads, per person per day | None in sandbox. 30 for an application not yet approved | 30 |
| Knowledge answers, per developer account per day | None in sandbox. 100 for an application not yet approved | 1,000 |
| Image searches, per developer account per day | 300 | 2,000 |
| Place searches beyond Layout's registry, per developer account per day | None: search covers only the places Layout already holds | 200 |

A spent allowance is a `429 rate_limited` whose message names the limit. Reading the same menu again within three minutes, when Layout holds no read of it, is a `429` with `Retry-After`. A spent place-search allowance is not an error: `GET /v1/places` answers from Layout's registry alone and says so in `searchLimit`.

## No-card builds, per day

In production, each application has its own daily number of builds for a person with **no card on file**. It starts at zero and is set from the volume you gave when you applied. Spend it, and the next card-less build comes back asking for a card instead of running. Builds for someone with a card never touch it, and sandbox never asks for a card. See [Building without a card](https://developer.layout.link/reference/provisioning#building-without-a-card).

## Raising a limit

Every daily pool `GET /v1/usage` lists can be raised for your developer account, up to that pool's ceiling across every developer (`poolLimit`), and so can your hourly texts, and your application's own sandbox and no-card build limits. Open [Usage](https://developer.layout.link/console/usage) in the console, choose **Request a higher limit**, and say which limit and by how much. Or email [developer@layout.link](mailto:developer@layout.link). A raised number shows in `GET /v1/usage` with `custom: true` within a minute. A raised hourly texts number shows the same `custom: true` on `texts` under `rates`, with `expiresAt` while Layout set it for a limited time.

## Idempotency

Every `POST` takes an `Idempotency-Key` header, except the retired `POST /v1/orders`. Send one on every call that changes something, and carry the same key into every retry of that call. A random UUID per real intent is the simplest choice.

```bash
curl https://api.layout.link/v1/users \
  -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \
  -H "Idempotency-Key: 4f1d2c8e-7b6a-4e5f-9c3d-2a1b0e9f8d7c" \
  -H "Content-Type: application/json" \
  -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }'
```

- **Same key and body** returns the stored answer for 24 hours, with `Idempotent-Replayed: true`, and nothing runs again. A replay is the first answer, not the current state: read the resource for that.
- **Same key, different body** is `409 idempotency_conflict`. Nothing runs.
- **Still running** is `409 in_progress` with `Retry-After`. On a confirm, read `GET /v1/orders/:id` instead of sending it again.
- **Only successes are stored**, so an error frees the key. A `5xx` may have run: read the resource before you send anything changed.

A key is 1 to 255 visible ASCII characters, scoped to your application and environment, and to the person on a person credential. Over MCP, pass `idempotencyKey` to `order` `build` the same way.

### Retrying a confirm

If `POST /v1/carts/:id/confirm` times out, **send the same call again with the same key. Never a new key.** The retry gets the stored `202 placing`, or `409 in_progress` while the first is still running: read `GET /v1/orders/:id` then. Either way the person is charged once. A `500 internal` means the outcome is not known yet, not that it failed. `expectedTotalMinor` is the cart's `totalMinor`, fee included.

```bash
curl https://api.layout.link/v1/carts/$CART_ID/confirm \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
  -H "Idempotency-Key: 7f3c2a9e-confirm-1" \
  -H "Content-Type: application/json" \
  -d '{ "expectedTotalMinor": 763 }'
```

A `code_required` answer is an error, so it is not stored: send the same request again with the same key and the person's `code` added.

### The body's idempotencyKey

`POST /v1/carts`, `/answer` and `/confirm` also take an `idempotencyKey` in the body, which names the cart or the confirm: 8 to 100 letters, digits, `.`, `_`, `:` or `-`. It is optional when you send the header, whose value then stands in for it and must meet the same rules. When you send both, the body key names the cart or the confirm and the header replays the HTTP answer. They need not match. A body key still in progress answers `409 in_progress` without `Retry-After`: wait a couple of seconds.
