Week of October 19
wrong_credential
A real credential of the wrong kind now answers 403 wrong_credential, with a message that names what to send. That is your secret key on an endpoint that acts for a person (carts, places, menus, images, the profile), or a build grant on an endpoint that takes your secret key (/v1/users, /v1/events, /v1/usage) or on GET /v1/users/me/profile. Your secret on a person endpoint used to be 401 unauthorized. A credential Layout does not recognise is still 401 unauthorized. See Every error code.
grant_expired
A build grant past its expiry is now 401 with error.code grant_expired, where it was 401 unauthorized, and WWW-Authenticate carries error_description="grant_expired". Mint a new grant with POST /v1/users/:id/grant and send it. A grant that was revoked, or is refused for any other reason, still answers 401 unauthorized. mcp.layout.link answers the same way. See Refusals.
Expired carts
A ready cart nobody confirmed before expiresAt now ends on its own. The cart reads expired with failure.code CART_EXPIRED, where failure was null, and its order reads failed with failure_code CART_EXPIRED, where it was OUR_TIMEOUT. Its confirm is 409 cart_closed, and nothing is charged. The cart is released for you, so you need not cancel it; a cancel answers 200 with status: "expired". Build again with a new key.
order.failed for an expired cart, or for a ready cart you canceled, is sent at once when you read the cart or its order, and within a few minutes otherwise. See GET /v1/carts/:id and Failure codes.
order_in_progress names the order
A cart that fails with order_in_progress because the person has an order at the same store that is confirmed and paying now carries failure.orderId when your application placed that order. Read it until it is placed or failed, then build with a new key. When another cart is still building for the person, the message says so and there is no orderId. A sandbox test order no longer blocks a build at a real store, and a second cart at a test restaurant while the person’s order there is placing now fails with order_in_progress, so you can test it.
Store names
- On
POST /v1/cartsand/answer, every word ofrestaurantNamemust appear in the store's name or address, allowing small differences in how a name is written. A word the store does not carry, such as another branch, is409 store_mismatch. - A
restaurantNamethat matches no store by that name is409 store_not_found, notstore_ambiguous.candidates, when present, are other stores the search found; send one only if the person says it is the one they meant.store_ambiguouslists only stores whose names match. - After
store_ambiguous, you may send theplaceIdthe person picked with therestaurantNameyou first sent: aplaceIdLayout offered for that name in the last 30 minutes is neverstore_mismatch.
See POST /v1/carts.
Places search
GET /v1/placesanswers an empty search where Layout looked and holds no open place matching it withemptyBecause: "no_match". Treat a value you do not know as an empty list.- A common drink in
query, such aslatte,cold breworboba, finds the coffee and tea shops that serve it. localityMatchedis also false when some places come from a place matching fewer of the words innearthan the best match Layout holds.
See GET /v1/places.
Answered carts
Once a cart's question is answered, that cart reads closed with replacedBy, the id of the cart the answer started. Only the first idempotencyKey to answer starts a cart. An answer under another key is 409 answer_not_needed with error.replacedBy, and starts nothing. Confirming the answered cart is 409 cart_closed with error.replacedBy, the cart to confirm instead; it was cart_not_ready. See POST /v1/carts/:id/answer.
A real wait on every short 429
Every per-minute or per-hour 429 now carries Retry-After and the same wait as the new error.retryInSeconds, including GET /v1/events, GET /v1/whoami and the per-number verification limits. RateLimit-Reset is the seconds until RateLimit-Remaining next rises, and on a 429 it equals Retry-After. A 429 from verify/start for a code sent too recently carries RateLimit-* too. A confirm refused for too many wrong codes, 429 code_attempts_exceeded, carries the real wait, and its message says why.
A cart that failed with failure.code: "retry_blocked", because several cancels in a row or many builds in an hour paused new builds, carries failure.retryInSeconds: how long until a build can start. Test restaurants never count toward it. See Handling a 429.
Cancels
- A cancel that releases a cart, ready or still building, is never refused for rate. Only a cancel that finds nothing left to stop counts toward 6 per 10 minutes per person, and past that its
429carriesRetry-After,RateLimit-*anderror.retryInSeconds. - A cancel of a cart that already ended is answered from the cart and never counts toward that limit.
- A cancel of a cart whose order already failed answers
409 cart_closedwitherror.order, that order with statusfailed.
See POST /v1/carts/:id/cancel.
provisioned_to_another_app
POST /v1/users for a number another of your applications already holds, in the same environment, now answers 409 provisioned_to_another_app, where it was 409 invalid_request. Only a call that provisions a new person counts toward Users provisioned: a repeat for a number already provisioned, a refused body and this 409 give their count back. See Users, sessions and texts.
POST /v1/users checks its body
These are now 400 invalid_request, refused before any limit counts them: a body that is not valid JSON or not a JSON object, a firstName, lastName, phone, label or email that is not a string, a phone with letters or other characters, and a connectDelivery other than "developer". Fields the endpoint does not name are still ignored. See Fields.
GET /v1/events checks its query
A cursor this feed never wrote is now 400 invalid_request, and so is a limit of 0 or anything that is not a whole number. A larger limit is still capped at 100. With a sandbox key, a parameter the endpoint does not take, such as orderId for order_id, is 400 invalid_request naming the ones it does. See GET /v1/events.
challenge on GET /v1/orders/:id
While an order is placing and its payment waits on the person approving Layout's texted link, GET /v1/orders/:id carries "challenge": { "sms_sent": true }. It is false at test_place_challenge, which texts nobody, and absent otherwise. See GET /v1/orders/:id.
Pickup details on order.updated
order.updated now carries order.ready_eta and order.fulfillment, the same values GET /v1/orders/:id returns when the event is written. No other event carries them, and both are absent when Layout could not read the order at that moment. A test order.updated from POST /v1/sandbox/webhooks/fire carries them too: the order's own when you name one, placeholders otherwise. See Example payload.
fee_not_included
A confirm whose expectedTotalMinor left out Layout's service fee now carries error.reason: "fee_not_included", on the 400 invalid_request and on a 409 price_changed for the same cause. Show the person the cart's totalMinor and confirm with it.
The standard 404
A request no endpoint handles now answers 404 in the standard shape, { "error": { "code": "not_found", "message": "No route answers GET /v1/…" } }, with X-Request-Id and Layout-Version. It was a flat body with error as a string. See Error shape.
Limits counted once
The per-minute and per-hour limits, and the per-credential count in RateLimit-*, now hold at the numbers Rate limits lists. Sandbox now applies production's code limits too: verify/start meets the per-number spacing and hourly cap, resend-code at test_place_code meets the cart's four codes, the person's hour and codes in a row, and after 8 wrong codes in 15 minutes every code there, 000000 too, is 429 code_attempts_exceeded. Nothing is texted. See the test restaurants.
Smaller changes
GET /v1/whoamilistsconfirmin a build grant'sscopes,["build", "confirm"], andorder:confirmin an application's.- After
price_changed, a read of the cart carriesfees.subtotalMinor,fees.taxAndFeesMinorand each item’spriceMinorwhen the restaurant stated them, where they werenull. - The sandbox test menu reads a request the way a person says it:
"an iced latte, large, with oat milk"is one latte with its size and options,"a muffin"is the Blueberry muffin, and number words such as"two lattes"or"a dozen muffins"set the quantity, up to 999. An option named after several items goes on the nearest item before it that offers it, and anything else said about an item stays on its line at no charge.test_place_price_changedputs its $1.00 rise on an item and its tax. See The test menu.
Breaking changes
- Code that treats a
401from a person endpoint as "the secret was sent there" now gets403 wrong_credential. - Code that matches
unauthorizedfor an expired build grant now getsgrant_expired. The status is still401. - A cart past
expiresAtcan no longer be confirmed. Code that readsfailureasnullon an expired cart now gets{ "code": "CART_EXPIRED", "message": "…" }, and code that matchesOUR_TIMEOUTon its order now getsCART_EXPIRED. - A
restaurantNamewith a word the store does not carry, sent with aplaceId, is nowstore_mismatch. - Code that waits on
cart_not_readyafter confirming an answered cart now getscart_closed. - Code that matches
409 invalid_requestfromPOST /v1/usersfor a number another application holds now getsprovisioned_to_another_app. - A
POST /v1/usersbody, or aGET /v1/eventsquery, that Layout used to read loosely, such as a number forfirstName,limit=0or an unreadablecursor, is now400. - Code that reads
erroras a string on a404for an unknown path now gets the standard object. - Sandbox tests that resend codes or call
verify/startin quick succession can now get429 rate_limited, as production does. - A sandbox request without
itemscan now build different lines, quantities and totals than before.
Everything else here is a new field, header or value, or a request that used to be refused, and code that ignores what it does not know keeps working.