# Sandbox

In sandbox, a cart at a real restaurant is built from that restaurant's menu as Layout holds it. No browser opens, nothing is charged, no restaurant receives anything, and the order reaches whichever ending you ask for, so you can test every path your integration handles.

## Who can use it

Sandbox credentials only: your `sk_test_` key, a sandbox build grant, or a connected person's token where the person is a sandbox test user, such as a [sign-in test account](https://docs.layout.link/authentication#sign-in-test-accounts). Over MCP that means a session started from a sandbox build grant.

Production never simulates. `simulate`, `latency` and `engine` on a production cart are refused with `400 invalid_request`.

## Places and menus

A sandbox `GET /v1/places` search or browse lists only restaurants whose menu Layout holds with at least 3 items, and the test restaurants, so every place it returns can take a sandbox cart. When it finds matching places but holds the menu of none of them, the list is empty with `emptyBecause: "no_held_menu"`: search another kind of food or another area, or use a [test restaurant](#test-restaurants). Reading places by their `ids` is not filtered. Over MCP, the `places` tool lists the same.

Any menu read Layout holds for a place counts, however old, so a place a sandbox search lists keeps taking sandbox carts until its menu is wiped.

A sandbox `POST /v1/menus` answers only with the menu Layout holds, the same menu a sandbox cart there is priced from. It never reads a menu live, never starts a read, and never answers with a usual menu for the chain. `"mode": "knowledge"` answers with the same held menu, never a web answer. A restaurant with none is `409 menu_not_held`. Over MCP, a sandbox session has the `menu` tool, which answers the same way, is free, and can be called whenever you need to check what a place sells.

- An item marked sold out carries `soldOut: true`. A cart that asks for it fails with `ITEM_UNAVAILABLE`.
- An option with a price reads like `Oat Milk (+$0.75)`, or `No Rice (-$1.00)` when it takes money off. Send it back as written or as the name alone (`Oat Milk`): both match.
- Each item lists at most 12 option groups, and each group at most 24 options. `moreOptions` counts the options left out, and a cart cannot choose them.

## How a sandbox cart is built

Send `POST /v1/carts` as you will in production, with a `placeId` from a sandbox places search. Layout prices the request against that restaurant's menu:

- Words match whole, plural or singular. A short form such as `"guac"` counts only on a later word of an item name with more than one word (`"chips and guac"` is Chips & Guacamole), or when it names exactly one option on that item (`"oat"` is Oat Milk). It never names a one-word item: `"lime"` is not Limeade.
- An item is named by its whole name. Failing that, by the last word of its name and one more word it shares with the request. Failing that, by its last word, when only one item on the menu ends with that word: `"a coffee"` is Drip Coffee when that is the only coffee.
- The quantity is a number before the item (`"2 lattes"`), or `x2`, `2x` or `Qty 2` after it.
- One `items` entry that names two items is two lines.
- `no`, `without`, `hold`, `remove`, `skip` or `minus` before an option says no to it. An option the menu names `No Pickles` is chosen as it is.
- A group takes every option named in it, required groups too, so every topping named on a bowl lands. A group with nothing named takes its default. A required group with neither asks: the cart answers a `choices` decision naming only the groups still missing, with the restaurant's own options.
- When one option is in two groups, the group named just before it decides: `"Extra Protein: Roasted Chicken"` is the extra. After `extra`, `double`, `add` or `more`, it is the group for extras.
- The answer to a `choices` decision goes to every item still owing a choice, and each item takes what fits its own groups. On REST, send it with `POST /v1/carts/:id/answer`, and each answer adds to the ones before. Over MCP, build again with `choices` set to every answer given so far, joined with `"; "`.
- An item the menu does not list, or lists as sold out, fails the cart with `ITEM_UNAVAILABLE`, as a real build does. Read the menu for that `placeId` and build again with names it lists.
- An item the menu shows no price for is $5.00. Tax is 8.75%. The service fee is the same as production, and `totalMinor` includes both. Each line's `priceMinor` is the price of one of that item with its options.

A restaurant whose menu Layout does not hold is refused with `409 menu_not_held` before anything is built or counted. So is a sandbox cart that names no place: a cart sent with `restaurantName` and `near` is built only when Layout resolves them to a `placeId` whose menu it holds. When Layout cannot check for a held menu, the answer is `503 unavailable`: retry with the same `idempotencyKey`.

The cart, its confirm, the order, the event feed and your webhooks behave as in production. Every order and every event carries `"simulated": true`, and `card` is `null` on every sandbox cart, whatever card the person reads as holding (see [Card links](https://docs.layout.link/card-links)). The confirm needs no code unless the cart was built with `"simulate": "code"`.

## Choose the ending

Send `simulate` on `POST /v1/carts`, or on the MCP `order` tool's `build`. Without it the order is placed.

| `simulate` | What happens | Events, in order |
| --- | --- | --- |
| `placed` | The confirm answers `placing`, and the order is placed. | `order.building`, `order.carted`, `order.placing`, `order.placed` |
| `failed` | The confirm answers `placing`, and the order fails with `CARD_DECLINED`. | `order.building`, `order.carted`, `order.placing`, `order.failed` |
| `unconfirmed` | The confirm answers `placing`, and the order ends `unconfirmed`. Handle it as in production: never build it again, and never tell the person it failed. | `order.building`, `order.carted`, `order.placing`, `order.unconfirmed` |
| `challenge` | The confirm answers `placing` and sends `order.challenge`. The challenge clears by itself and the order is placed. While it waits, `GET /v1/orders/:id` carries `challenge`, with `sms_sent: false`. | `order.building`, `order.carted`, `order.placing`, `order.challenge`, `order.placed` |
| `code` | The cart reads `codeRequired: true` (`codeReason: "risk_check"`), and the confirm answers `400 code_required` until it carries `"code": "000000"`. Nothing is texted. Then the order is placed. | `order.building`, `order.carted`, then `order.placing` and `order.placed` once the confirm carries the code |
| `card_declined` | The confirm is refused at once with `402 card_declined`, and the order ends `failed` with `CARD_DECLINED`. Nothing is placed. | `order.building`, `order.carted`, `order.placing`, `order.failed` |
| `price_changed` | At the first confirm the total goes up $1.00, item and tax together, so that confirm is refused with `409 price_changed`. Build a fresh cart with the same request and a new `idempotencyKey`: it carries the new total. Show the person the new total and confirm the fresh cart with it. | `order.building`, `order.carted`, then for the fresh cart `order.building`, `order.carted`, `order.placing`, `order.placed` |
| `closed` | The cart fails with `STORE_CLOSED`. | `order.building`, `order.failed` |
| `pickup_later` | The order is placed with no pickup details, as a real order often is. The name (`Sandbox`), the order number (`SIMULATED`) and the instructions arrive with `order.updated`, then the pickup time (`Ready in about 15 minutes`) with a second `order.updated`, whose `event_id` ends `_updated_2`. | `order.building`, `order.carted`, `order.placing`, `order.placed`, `order.updated`, `order.updated` |

A question writes no order and sends nothing. A ready cart nobody confirms expires as it does in production. A second cart at a restaurant while the person's order there is confirmed and placing fails with `order_in_progress`.

## Timing

`latency` sets how long each step waits.

| Step | `realistic` (the default) | `instant` |
| --- | --- | --- |
| Building the cart | 30 seconds for one item and 10 more for each item after it, within 15% either way, at most 90 | None |
| Confirm to `placed` or `failed`, and a confirm with the code | 20 seconds | None |
| Confirm to `unconfirmed`, or a challenge clearing | 30 seconds | None |
| Placement to the `pickup_later` name, and to its pickup time | 15 seconds, and 30 seconds | None |

While a sandbox cart builds, `GET /v1/carts/:id` carries `build` with its stage and `etaSec` counting down this wait. With `instant`, the next read of the cart or the order has moved on. Reading the order, with `GET /v1/orders/:id` or `order_status` over MCP, moves it on the moment it is due; otherwise it moves on within a few seconds, and your webhooks follow. Use `realistic` to test your polling and `instant` in CI.

## The real engine

Send `"engine": "real"` to build on the restaurant's own site, as production does. It opens a browser, takes minutes, and its confirm is still simulated. It works at any restaurant Layout supports, held menu or not. `simulate` and `latency` do not apply to it.

## Limits

| Builds | A day, per developer account | `GET /v1/usage` |
| --- | --- | --- |
| Sandbox carts with no browser, at real restaurants and test restaurants | 10,000 | `simulated_builds` |
| Sandbox carts with `"engine": "real"` | 10 | `builds` |

Both count across all your applications, reset at midnight UTC, and are refused with `429 build_limit`, naming the number. To raise either, open [Usage](https://console.layout.link/usage) in the console and choose **Request a higher limit**, or email [developer@layout.link](mailto:developer@layout.link).

## Check your integration

[layout check](https://docs.layout.link/cli#check-your-integration) grades your integration from its last 24 hours of sandbox: a cart at a real restaurant, an answered question, a confirm with `totalMinor`, an order your application confirmed that reached each of `placed`, `failed` and `unconfirmed`, a confirm with a code, and a fresh cart confirmed after `price_changed`. With `--forward-to` it also sends your webhook handler four test events. Each check that does not pass says what to do. Run it until every check passes.

## Test restaurants

Test restaurants have a fixed menu, so a total is the same on every run, and each one scripts one ending. They ignore `simulate` and `latency`. Use them when a test needs exact numbers. Name one by its `placeId`, which starts `test_place_`. A production credential that names one gets the answer it would get for any place that does not exist.

A sandbox search that names them, such as `layout test`, lists every one of them. Add a word to narrow it: `layout test slow` finds `test_place_slow`. Search never lists `test_place_closed`; read it by its id. `GET /v1/places/test_place_carted` reads one, and `POST /v1/menus` with a test `placeId` returns the test menu. Over MCP, build with the `placeId`, a `query` naming the food, `restaurant` set to the store name in the table below, `near` (any place, such as `San Francisco`) and an `idempotencyKey`.

### The test restaurants

| placeId | Store | What it does |
| --- | --- | --- |
| `test_place_carted` | Layout Test Kitchen | Builds a ready cart priced from the test menu. Its confirm places the order at once, simulated: the order reads `placed` straight away. Over REST the confirm itself still answers `placing`. |
| `test_place_needs_choices` | Layout Test Kitchen Choices | The cart asks a `choices` decision: Size (required: Small, Medium, Large) and Extras (optional: Oat milk, Extra shot, Vanilla). It asks again until the answer names a size, then builds a ready cart for a latte. |
| `test_place_needs_location` | Layout Test Kitchen Two Branches | Matches two branches. `POST /v1/carts` answers `store_ambiguous` with both as `candidates`; over MCP the preview and build ask which branch. Build with the branch's `placeId` and its own name. |
| `test_place_branch_north`, `test_place_branch_south` | Layout Test Kitchen North, Layout Test Kitchen South | The two branches. Each builds a ready cart like `test_place_carted`. |
| `test_place_closed` | Layout Test Kitchen Closed | Is closed. The cart fails with failure code `STORE_CLOSED`. |
| `test_place_price_changed` | Layout Test Kitchen Price Change | Builds a ready cart. At the first confirm its total goes up $1.00, item and tax together, so that confirm is refused with `price_changed`. Build a fresh cart with the same request and a new `idempotencyKey`: it carries the new total. Confirm it with that total. |
| `test_place_placed` | Layout Test Kitchen Placing | Builds a ready cart. Its confirm answers `placing`, and the order is placed about 5 seconds later. |
| `test_place_failed` | Layout Test Kitchen Declined | Builds a ready cart. Its confirm answers `placing`, and the order fails about 5 seconds later with failure code `CARD_DECLINED`. |
| `test_place_unconfirmed` | Layout Test Kitchen Unconfirmed | Builds a ready cart. Its confirm answers `placing`, and about 5 seconds later the order ends `unconfirmed`. Handle it as you would in production: never build it again, and never tell the person it failed. Over MCP, `order_status` reports `status: "unconfirmed"` with `outcome: "unconfirmed"`, `failureCode: "UNCONFIRMED"` and an instruction not to retry it. Treat it as [An unconfirmed placement](https://docs.layout.link/mcp#an-unconfirmed-placement) says. |
| `test_place_challenge` | Layout Test Kitchen Challenge | Builds a ready cart. Its confirm answers `placing` and sends `order.challenge`. About 15 seconds later the challenge clears by itself and the order is placed. While it waits, `GET /v1/orders/:id` carries `challenge`, with `sms_sent: false`. |
| `test_place_code` | Layout Test Kitchen Code | The confirm answers `400 code_required` (`codeReason: "risk_check"`) until it carries `"code": "000000"`. Nothing is texted. Code limits apply as in production. With the code, the order is placed about 5 seconds later. |
| `test_place_pickup_later` | Layout Test Kitchen Pickup Later | Builds a ready cart. Its confirm answers `placing`, and about 5 seconds later the order is placed with no pickup details, as a real order often is: `ready_eta` and every `fulfillment` detail but `type` and `address` are null. About 5 seconds after placement the name (`Sandbox`), the order number (`SIMULATED`) and the instructions arrive with `order.updated`. About 10 seconds after placement the pickup time (`Ready in about 15 minutes`) arrives with a second `order.updated`, whose `event_id` ends `_updated_2`. Use it to test a late [pickup time](https://docs.layout.link/api#pickup-time) and [more than one order.updated](https://docs.layout.link/events#more-than-one-order-updated). |
| `test_place_delivery_far` | Layout Test Kitchen Far | Behaves like `test_place_placed`. |
| `test_place_card_declined_confirm` | Layout Test Kitchen Card Declined | Builds a ready cart. Its confirm is refused at once with `402 card_declined`, as a real card refused at confirm is, and the order ends `failed` with failure code `CARD_DECLINED`. Nothing is placed. |
| `test_place_slow` | Layout Test Kitchen Slow | The cart stays `building` for about 20 seconds, then becomes ready. |

Every store is at a numbered address on Sandbox Way, San Francisco, CA 94107. Any other id that starts `test_place_` is reserved and finds nothing. Over MCP, a name or `test_place_` id that no test restaurant carries answers `store_not_found`, with the test restaurants as candidates. A test restaurant ignores `simulate` and `latency`: it scripts its own ending and timing.

### The test menu

Every test restaurant shares one menu, so a total is the same every time you build it.

| Item | Price | Options |
| --- | --- | --- |
| Drip coffee | $3.50 | None |
| Latte | $5.25 | Size: Small, Medium (+$0.50), Large (+$1.00). Extras: Oat milk (+$0.75), Extra shot (+$1.00), Vanilla (+$0.50). |
| Blueberry muffin | $3.75 | None |
| Avocado toast | $9.00 | None |
| Breakfast burrito | $10.95 | None |

- Each `items` entry is one line, and a leading number is its quantity (`"2 lattes"`). An item matches by its whole name or its last word: `"a muffin"` is the Blueberry muffin.
- Options on the menu are priced. Anything else you say about an item is kept on its line at no charge. A latte with no size is Small, except at `test_place_needs_choices`, which asks.
- Unlisted items are $5.00, under the name you sent.
- Tax is 8.75%, and the service fee is the same as production. `totalMinor` includes both.

### Test restaurant events

| placeId | Events, in order |
| --- | --- |
| `test_place_carted` | `order.building`, `order.carted`, then `order.placed` at confirm |
| `test_place_closed` | `order.building`, `order.failed` |
| `test_place_placed` | `order.building`, `order.carted`, `order.placing`, `order.placed` |
| `test_place_failed` | `order.building`, `order.carted`, `order.placing`, `order.failed` |
| `test_place_unconfirmed` | `order.building`, `order.carted`, `order.placing`, `order.unconfirmed` |
| `test_place_challenge` | `order.building`, `order.carted`, `order.placing`, `order.challenge`, `order.placed` |
| `test_place_code` | `order.building`, `order.carted`, then `order.placing` and `order.placed` once the confirm carries the code |
| `test_place_pickup_later` | `order.building`, `order.carted`, `order.placing`, `order.placed`, `order.updated`, `order.updated` |

The times in the table above are approximate, and move on the same way as a sandbox cart's. Like every `order.updated`, the pickup details reach only an endpoint that subscribes to `order.updated`.

## A whole order in CI

Give every run its own idempotency keys, for example from your CI run id. A key reused from an earlier run replays that run's cart and confirm instead of building a new one. Send `"latency": "instant"` so nothing waits.

```bash
curl https://api.layout.link/v1/carts \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "placeId": "'"$PLACE_ID"'", "items": ["harvest bowl"], "simulate": "unconfirmed", "latency": "instant", "idempotencyKey": "ci-'"$RUN_ID"'-cart" }'
```

```
202 Accepted

{ "cart": { "id": "crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe", "status": "building" } }
```

Read the cart until it is `ready`, and confirm it with its `totalMinor`. The total depends on the restaurant's menu, so read it from the cart, never write it into the test. Then read the order or wait for `order.unconfirmed`.

```
TOTAL=$(curl -s https://api.layout.link/v1/carts/crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" | jq -r '.cart.totalMinor')
```

```bash
curl https://api.layout.link/v1/carts/crt_3f9a1c0b7dWq7nT2xKcZ0pLm4RvB8sYe/confirm \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "expectedTotalMinor": '"$TOTAL"', "idempotencyKey": "ci-'"$RUN_ID"'-confirm" }'
```

```
202 Accepted

{ "order": { "id": "ord_7c21", "status": "placing", "simulated": true } }
```

## Fire a test webhook

`POST /v1/sandbox/webhooks/fire` sends one signed test event to every enabled endpoint of your application that subscribes to it, so you can test a handler without building anything. It goes through the real delivery pipeline: the sandbox signing secret, the same headers, the delivery log and the retries. Call it with your `sk_test_` key.

```bash
curl https://api.layout.link/v1/sandbox/webhooks/fire \
  -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event": "order.unconfirmed", "orderId": "ord_7c21" }'
```

```
200 OK

{
  "event": "order.unconfirmed",
  "order": { "id": "ord_7c21", "state": "unconfirmed", "store": "Layout Test Kitchen Unconfirmed" },
  "deliveries": [
    { "id": "dl_9f3a2c", "url": "https://example.com/layout/webhooks", "outcome": "delivered" }
  ]
}
```

- `event` is any event type except `order.needs_approval`, which is reserved and never sent.
- `orderId` is optional. With one of your sandbox orders, the event carries that order's real id, store, item count, total and person. Without it, the event describes a test order whose ids match nothing: `ord_test_000000000000` at Layout test kitchen. `order` in the response shows which order the event described and the state it carried, as a real delivery of that event would: the state the event names, `placed` for `order.updated`, and for `order.challenge` the order's state at the time (`placing` for the test order). A fired `order.updated` carries `ready_eta` and `fulfillment`: the order's own with `orderId`, placeholders otherwise.
- Every fired event carries `"test": true`, like the console's **Send test event**.
- `outcome` is `delivered` when your endpoint answered 2xx, `retry` when it did not and the ordinary retry schedule takes over, and `dead` when retries are exhausted.
- A `sk_live_` key is refused with 403 `forbidden`. No endpoint subscribed to the event, or an `orderId` that is not one of your sandbox orders, is a 404 `not_found` that says which.
- While [layout listen](https://docs.layout.link/cli#forward-events-with-listen) runs for your application, an event no endpoint subscribes to is not a 404: it answers 200 with empty `deliveries`, and the event goes to `listen` and nowhere else.
- 1,000 a day for each developer account and 30 a minute for each key, refused with 429 `rate_limited`.
