Week of October 19

Updated APIBreaking

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/carts and /answer, every word of restaurantName must 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, is 409 store_mismatch.
  • A restaurantName that matches no store by that name is 409 store_not_found, not store_ambiguous. candidates, when present, are other stores the search found; send one only if the person says it is the one they meant. store_ambiguous lists only stores whose names match.
  • After store_ambiguous, you may send the placeId the person picked with the restaurantName you first sent: a placeId Layout offered for that name in the last 30 minutes is never store_mismatch.

See POST /v1/carts.

  • GET /v1/places answers an empty search where Layout looked and holds no open place matching it with emptyBecause: "no_match". Treat a value you do not know as an empty list.
  • A common drink in query, such as latte, cold brew or boba, finds the coffee and tea shops that serve it.
  • localityMatched is also false when some places come from a place matching fewer of the words in near than 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 429 carries Retry-After, RateLimit-* and error.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_closed with error.order, that order with status failed.

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/whoami lists confirm in a build grant's scopes, ["build", "confirm"], and order:confirm in an application's.
  • After price_changed, a read of the cart carries fees.subtotalMinor, fees.taxAndFeesMinor and each item’s priceMinor when the restaurant stated them, where they were null.
  • 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_changed puts its $1.00 rise on an item and its tax. See The test menu.

Breaking changes

  • Code that treats a 401 from a person endpoint as "the secret was sent there" now gets 403 wrong_credential.
  • Code that matches unauthorized for an expired build grant now gets grant_expired. The status is still 401.
  • A cart past expiresAt can no longer be confirmed. Code that reads failure as null on an expired cart now gets { "code": "CART_EXPIRED", "message": "…" }, and code that matches OUR_TIMEOUT on its order now gets CART_EXPIRED.
  • A restaurantName with a word the store does not carry, sent with a placeId, is now store_mismatch.
  • Code that waits on cart_not_ready after confirming an answered cart now gets cart_closed.
  • Code that matches 409 invalid_request from POST /v1/users for a number another application holds now gets provisioned_to_another_app.
  • A POST /v1/users body, or a GET /v1/events query, that Layout used to read loosely, such as a number for firstName, limit=0 or an unreadable cursor, is now 400.
  • Code that reads error as a string on a 404 for an unknown path now gets the standard object.
  • Sandbox tests that resend codes or call verify/start in quick succession can now get 429 rate_limited, as production does.
  • A sandbox request without items can 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.

All changes