Sandbox test restaurants
Each test restaurant scripts one outcome, the way a payment processor's test cards do, so you can test every path your integration handles in CI. They never open a browser, cost nothing, and never count toward your sandbox build allowance.
Who can use them
Sandbox credentials only: a sandbox build grant, or a connected person's token where the person is a sandbox test user. Name a test restaurant by its placeId, which always starts test_place_.
Over MCP that means a session started from a sandbox build grant. An OAuth connector session never reaches them, even when the person it signs in is a test user.
A production credential that names a test restaurant gets exactly the answer it would get for any place that does not exist: store_not_found from POST /v1/carts, a 404 from the place and menu reads, and an unknown store from the MCP order tool. Nothing is built.
Find them
In sandbox, a search that names them lists them, up to limit (5 by default, at most 10) like any search. Add a word to narrow it: layout test slow finds test_place_slow. None of these reads draws your place or menu allowance.
curl "https://api.layout.link/v1/places?query=layout%20test" \ -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN"
GET /v1/places/test_place_carted reads one, and POST /v1/menus with a test placeId returns the test menu. Over MCP, pass the placeId straight to the order tool's build action; the places and menu tools do not list test restaurants.
The test restaurants
| placeId | What it does |
|---|---|
test_place_carted | Builds a ready cart priced from the test menu. Its confirm places the order at once, simulated. |
test_place_needs_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 | Matches two branches. POST /v1/carts answers store_ambiguous with both as candidates; over MCP the build asks which one. |
test_place_branch_north, test_place_branch_south | The two branches. Each builds a ready cart like test_place_carted. |
test_place_closed | Is closed. The cart fails with failure code STORE_CLOSED. |
test_place_price_changed | Builds a ready cart. At the first confirm its total goes up $1.00, so that confirm is refused with price_changed. Read the cart again and confirm its new total. Over MCP, where the refusal asks for a fresh cart, build test_place_price_changed again within 15 minutes, while the moved cart is still unconfirmed: the fresh cart starts at the new total and its confirm places it. |
test_place_placed | Builds a ready cart. Its confirm answers placing, and the order is placed about 5 seconds later. |
test_place_failed | Builds a ready cart. Its confirm answers placing, and the order fails about 5 seconds later with failure code CARD_DECLINED. |
test_place_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 it as failed with outcome: "unconfirmed" and an instruction not to retry it. Treat that as unconfirmed, as An unconfirmed placement says. |
test_place_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. |
test_place_slow | The cart stays building for about 20 seconds, then becomes ready. |
Any other id that starts test_place_ is reserved and finds nothing.
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 entry of
items, or each comma inrequest, is one line. A leading number is the quantity:"2 lattes". - An option named anywhere in the request applies to the items that offer it. A latte with no size named is Small, except at
test_place_needs_choices, which asks. - Anything the menu does not list is priced at $5.00 under the name you sent.
- Tax is 8.75% of the subtotal. The service fee is the same as in production, and
totalMinorincludes it.
What they write
A test restaurant writes the same order a real build does, marked simulated: true. GET /v1/carts/:id, confirm, GET /v1/orders/:id, the event feed and your webhooks all treat it as an order, and every event it sends carries "simulated": true. No card is shown and no code is asked for.
| 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 |
A question (test_place_needs_choices before it is answered, test_place_needs_location) writes no order and sends nothing. A ready cart nobody confirms expires as it would in production.
The times are approximate. An order moves on within a few seconds of being due. Reading the order, with GET /v1/orders/:id or order_status over MCP, moves it on the moment it is due. Reading a cart moves on only a slow build.
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.
curl https://api.layout.link/v1/carts \
-H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "placeId": "test_place_placed", "items": ["latte", "blueberry muffin"], "idempotencyKey": "ci-'"$RUN_ID"'-cart" }'
202 Accepted
{ "cart": { "id": "crt_3f9a1c0b7dY2ktcnVuLTAwMDE", "status": "building" } }
Read the cart until it is ready, confirm it with its totalMinor, then read the order or wait for order.placed.
curl https://api.layout.link/v1/carts/crt_3f9a1c0b7dY2ktcnVuLTAwMDE/confirm \
-H "Authorization: Bearer $LAYOUT_PERSON_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "expectedTotalMinor": 1019, "idempotencyKey": "ci-'"$RUN_ID"'-confirm" }'
202 Accepted
{ "order": { "id": "ord_7c21", "status": "placing", "simulated": true } }
Limits
Test restaurant builds have their own allowance: 10,000 a day for each developer account, across all its applications, inside a pool shared by every developer. They never draw your sandbox build allowance, and they do not appear in Usage. A refusal is build_limit and names the number. Both reset at midnight UTC.
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" \
-H "Content-Type: application/json" \
-d '{ "event": "order.unconfirmed", "orderId": "ord_7c21" }'
200 OK
{
"event": "order.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 describes that order. Without it, the event describes a test order whose ids match nothing.- 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. - 1,000 a day for each developer account and 30 a minute for each key, refused with 429
rate_limited.