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 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). 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.

simulateWhat happensEvents, in order
placedThe confirm answers placing, and the order is placed.order.building, order.carted, order.placing, order.placed
failedThe confirm answers placing, and the order fails with CARD_DECLINED.order.building, order.carted, order.placing, order.failed
unconfirmedThe 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
challengeThe 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
codeThe 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_declinedThe 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_changedAt 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
closedThe cart fails with STORE_CLOSED.order.building, order.failed
pickup_laterThe 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.

Steprealistic (the default)instant
Building the cart30 seconds for one item and 10 more for each item after it, within 15% either way, at most 90None
Confirm to placed or failed, and a confirm with the code20 secondsNone
Confirm to unconfirmed, or a challenge clearing30 secondsNone
Placement to the pickup_later name, and to its pickup time15 seconds, and 30 secondsNone

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

BuildsA day, per developer accountGET /v1/usage
Sandbox carts with no browser, at real restaurants and test restaurants10,000simulated_builds
Sandbox carts with "engine": "real"10builds

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

placeIdStoreWhat it does
test_place_cartedLayout Test KitchenBuilds 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_choicesLayout Test Kitchen ChoicesThe 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_locationLayout Test Kitchen Two BranchesMatches 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_southLayout Test Kitchen North, Layout Test Kitchen SouthThe two branches. Each builds a ready cart like test_place_carted.
test_place_closedLayout Test Kitchen ClosedIs closed. The cart fails with failure code STORE_CLOSED.
test_place_price_changedLayout Test Kitchen Price ChangeBuilds 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_placedLayout Test Kitchen PlacingBuilds a ready cart. Its confirm answers placing, and the order is placed about 5 seconds later.
test_place_failedLayout Test Kitchen DeclinedBuilds a ready cart. Its confirm answers placing, and the order fails about 5 seconds later with failure code CARD_DECLINED.
test_place_unconfirmedLayout Test Kitchen UnconfirmedBuilds 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_challengeLayout Test Kitchen ChallengeBuilds 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_codeLayout Test Kitchen CodeThe 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_laterLayout Test Kitchen Pickup LaterBuilds 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_farLayout Test Kitchen FarBehaves like test_place_placed.
test_place_card_declined_confirmLayout Test Kitchen Card DeclinedBuilds 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_slowLayout Test Kitchen SlowThe 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.

ItemPriceOptions
Drip coffee$3.50None
Latte$5.25Size: Small, Medium (+$0.50), Large (+$1.00). Extras: Oat milk (+$0.75), Extra shot (+$1.00), Vanilla (+$0.50).
Blueberry muffin$3.75None
Avocado toast$9.00None
Breakfast burrito$10.95None
  • 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

placeIdEvents, in order
test_place_cartedorder.building, order.carted, then order.placed at confirm
test_place_closedorder.building, order.failed
test_place_placedorder.building, order.carted, order.placing, order.placed
test_place_failedorder.building, order.carted, order.placing, order.failed
test_place_unconfirmedorder.building, order.carted, order.placing, order.unconfirmed
test_place_challengeorder.building, order.carted, order.placing, order.challenge, order.placed
test_place_codeorder.building, order.carted, then order.placing and order.placed once the confirm carries the code
test_place_pickup_laterorder.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" }
  ]
}
  • 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 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.