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. 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. 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 withITEM_UNAVAILABLE. - An option with a price reads like
Oat Milk (+$0.75), orNo 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.
moreOptionscounts 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"), orx2,2xorQty 2after it. - One
itemsentry that names two items is two lines. no,without,hold,remove,skiporminusbefore an option says no to it. An option the menu namesNo Picklesis 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
choicesdecision 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. Afterextra,double,addormore, it is the group for extras. - The answer to a
choicesdecision goes to every item still owing a choice, and each item takes what fits its own groups. On REST, send it withPOST /v1/carts/:id/answer, and each answer adds to the ones before. Over MCP, build again withchoicesset 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 thatplaceIdand 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
totalMinorincludes both. Each line’spriceMinoris 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). 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 in the console and choose Request a higher limit, or email developer@layout.link.
Check your integration
layout check 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 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 and 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
itemsentry 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.
totalMinorincludes 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.
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')
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.
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" }
]
}
eventis any event type exceptorder.needs_approval, which is reserved and never sent.orderIdis 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_000000000000at Layout test kitchen.orderin 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,placedfororder.updated, and fororder.challengethe order's state at the time (placingfor the test order). A firedorder.updatedcarriesready_etaandfulfillment: the order’s own withorderId, placeholders otherwise.- Every fired event carries
"test": true, like the console's Send test event. outcomeisdeliveredwhen your endpoint answered 2xx,retrywhen it did not and the ordinary retry schedule takes over, anddeadwhen retries are exhausted.- A
sk_live_key is refused with 403forbidden. No endpoint subscribed to the event, or anorderIdthat is not one of your sandbox orders, is a 404not_foundthat says which. - While
layout listenruns for your application, an event no endpoint subscribes to is not a 404: it answers 200 with emptydeliveries, and the event goes tolistenand nowhere else. - 1,000 a day for each developer account and 30 a minute for each key, refused with 429
rate_limited.