Authentication


Every request carries one bearer token, and there are four kinds: your application secret, a build grant, a person’s OAuth access token and a CLI token. Which one you send decides who the call acts for. What each one opens is in The credentials.

Authorize every request

Send your secret as a bearer token. We read the Authorization header and nothing else, so a secret in a query string or a cookie gets ignored rather than quietly working.

curl https://api.layout.link/v1/users \
  -H "Authorization: Bearer $LAYOUT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "firstName": "Dana", "lastName": "Whitfield", "phone": "+15005550142" }'

What the console gives you

CredentialShapeNotes
Client idlp_test_… / lp_live_…Identifies the application. Not a secret, but not useful on its own.
Client secretsk_test_… / sk_live_…The credential. Reveal it again on the Credentials page. A production secret can be revealed or rotated only once Layout approves the application, and asks you to sign in again first if your last sign-in was more than fifteen minutes ago. Reveals have an hourly and a daily limit.
Webhook signing secretOn the Webhooks pageDifferent secret, different job. See Webhooks.

The prefix picks the environment. An sk_test_ secret reaches sandbox and only sandbox, no matter what the rest of the request claims.

Keep the secret server-side. It provisions users and mints build grants, so anything holding it can act as your application. Never ship it in a browser bundle or an app binary.

Rotating a secret

Rotate on the application's Credentials page. The client id stays the same and the old secret keeps working for 24 hours, so you can deploy the new secret to your fleet without a gap. The page shows when the old secret stops. Tick Revoke the previous secret now when the old one leaked: it then stops working the moment the new one exists. Revoking an environment, or the whole application, also ends any overlap at once. layout keys create --replace does the same for a sandbox key and prints when the old secret stops.

Over the console API, POST /v1/console/apps/:appId/credentials/rotate takes an optional revokePrevious: true and answers clientId (unchanged), secret and previousSecretExpiresAt, which is null when the old secret was revoked. Anything but a boolean is 400 invalid_input.

Once the overlap ends, a rotated secret gets 401, with the same body as every other refusal, so a rotation that outran your deploy looks exactly like a bad key. Refused requests, on the Credentials page, is how you tell them apart: a stale key shows up as The key was rotated or revoked, with a count of how often it was sent.

OAuth clients

When people sign in with their own Layout account, from an assistant or an app of yours, create an OAuth client on the console's OAuth clients page with your redirect URIs. You get a client_id that starts mcp_. It is a public client: authorization code with PKCE (S256), no client secret.

  • Redirect URIs are absolute https URLs, or http on localhost, 127.0.0.1 or [::1]. Up to 10 for each client. The authorize request must match one exactly.
  • Up to 10 clients for each application.
  • The consent screen names the host of the redirect URI the request uses, such as yourapp.example, not the client's name, unless Layout has verified the client.
WhatValue
Discoveryhttps://api.layout.link/.well-known/oauth-authorization-server
Authorizehttps://api.layout.link/v1/oauth/authorize
Tokenhttps://api.layout.link/v1/oauth/token
Revokehttps://api.layout.link/v1/oauth/revoke (see Revoking a connection)
Resourcehttps://mcp.layout.link for MCP, https://api.layout.link for the REST API, or both in one authorization (see One authorization for both)
Resource metadatahttps://api.layout.link/.well-known/oauth-protected-resource for the REST API, https://mcp.layout.link/.well-known/oauth-protected-resource for MCP
Scopeorder

To put this behind a button in your app, see Sign in with Layout, which also covers returning people (prompt) and prefilling the phone number (login_hint).

The person approves your app on Layout's consent screen. Orders they place through the client reach your webhooks while they stay connected. GET /v1/orders/:id and the console's Orders page keep showing those orders after they disconnect.

The environment follows the person, not the client. An order is sandbox only when the person is a sign-in test account, which you create on the same console page. The test accounts on the console's Sandbox test accounts page are for users you provision and cannot sign in through OAuth. Every other person is production, and production starts once Layout approves your application.

Your own Layout account is a production account. Until Layout approves your application, it cannot connect to your client: approving sends you back with error=unauthorized_client and no code. Once the application is approved, confirming with it charges your card and sends a real order to the restaurant. Test with a sign-in test account.

A client you register yourself through dynamic registration still connects, but it belongs to no application, so its orders never reach you. There is no way to claim one: create the client in the console. Deleting a client closes it to new connections and ends every connection it holds: refresh tokens stop working, and an access token already issued is refused on its next request, on the API and on MCP. Every order the client placed stays readable.

Return URLs

A card link sends the person back to your app only through a return URL you registered. Add one under Return URLs on the console's App details page with Add return URL. They are not OAuth redirect URIs, and an OAuth client's redirect URIs do not count as return URLs.

  • https with a host, your app's own custom scheme such as com.acme.assistant:/layout/card, or http on localhost for sandbox only.
  • Up to 10 for each application, shared by your sandbox and production keys. A link must name one exactly.

See Return URLs on the card links page for what comes back and how to check it.

Sign-in test accounts

To test an OAuth client end to end with no real order, choose New test account under Sign-in test accounts on the console's OAuth clients page. Layout gives the account a reserved phone number and a six-digit sign-in code.

WhatValue
Phone numberPicked by Layout from +1 500 555 2000 to +1 500 555 9999. Nobody holds these numbers, and nothing is ever texted to one.
Sign-in codeSix digits, shown once, when you create the account. Layout keeps only a hash. If you lose it, delete the account and create another. 000000 does not sign one in.
EnvironmentSandbox, for the life of the account. Every confirm is simulated and carries "simulated": true: no restaurant receives it, no card is used, nothing is charged. Over REST the confirm answers "status": "placing", as every REST confirm does; read the order for the outcome.
Connects toOnly the OAuth clients of the application that created it. Any other client, including an assistant no application owns and your other applications' clients, is refused: invalid_target when the authorization includes the REST API, unauthorized_client for MCP alone. Other developers cannot see, use or delete it. It can also approve a sandbox connect link from any application in your developer account, for a user provisioned with its number.
ApprovalNot needed. A sandbox person connects to an application that is still under review.
Limits10 at a time for each developer account, across its applications, and 30 created a day.
DeletingIt can no longer sign in, and every connection it holds ends at once. Orders it placed stay readable. The number may later go to a different account.

To sign in, start the authorization as usual. On Layout's consent screen, sign in with the account's phone number, enter its code where Layout asks for the texted code, and approve. Its orders reach your webhooks with your sandbox signing secret.

CLI sessions

The Layout CLI signs in with a code a person approves on the console's CLI page, and gets an lcli_ token for that developer account. It works in sandbox. With layout login --live it can also read an approved application's production for 12 hours, and never changes it. A live read for an application that is not approved is refused with 403 not_approved (see Production).

A CLI token canA CLI token cannot
Read the account, its applications, usage and recent sandbox deliveriesCreate, reveal or rotate a live key
Create applicationsRead a live signing secret
Create, read and revoke sandbox keysAdd, change or delete a webhook endpoint, or change any production setting
List webhook endpoints and read the sandbox signing secretApprove anything
Fire sandbox test events and read the sandbox event feedPlace a live order or move money
Ask for production reviewUse any API route outside /v1/cli. Every other route refuses a CLI token, and /v1/cli refuses a client secret or a build grant.
With production access, for 12 hours, on an approved application: read live events, deliveries, key details (client id and last four) and endpointsWrite anything in production

Layout stores the token hashed. A session from layout login lasts 30 days; a token made on the console's CLI page lasts 30 days, 90 days or a year (see CI tokens). Every action it takes is recorded in your account's audit trail like a console action. Revoke a session from the console's CLI page or with layout logout. See CLI security.

Calling as a person

Two credentials act for one person: a build grant, for a person you provisioned, and an OAuth access token, for a person who approved your application. Places, menus, images and carts take one of them, never your secret: your secret there is 403 wrong_credential. GET /v1/whoami takes any of the three (see Who a token acts for). Neither person credential can add a card. See The credentials.

Get an API token

Run the same authorization code flow with PKCE as for MCP, with resource=https://api.layout.link on the authorize request. The resource parameter is required. Only an OAuth client your application created in the console can ask for this resource; a client registered through dynamic registration is refused with invalid_target, and a suspended application with unauthorized_client.

https://api.layout.link/v1/oauth/authorize
  ?response_type=code
  &client_id=$CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
  &code_challenge=$CODE_CHALLENGE
  &code_challenge_method=S256
  &scope=order
  &resource=https%3A%2F%2Fapi.layout.link
  &state=$STATE

An access token is for one resource. A token for https://mcp.layout.link is refused by the API, and a token for the API is refused by MCP. Refreshing keeps the resources the person approved: asking a refresh token for one it was not granted is invalid_target. See Tokens and refresh for lifetimes.

The environment follows the person. A Layout sandbox account is sandbox; anyone else is production, and production needs your application approved. A production person who approves an application that is not approved yet is sent back to your redirect URI with error=unauthorized_client and no code.

One authorization for both

A server that calls the REST API and drives MCP for the same person can ask for both in one authorization, so the person sees one consent screen. Send resource twice on the authorize request:

https://api.layout.link/v1/oauth/authorize
  ?response_type=code
  &client_id=$CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
  &code_challenge=$CODE_CHALLENGE
  &code_challenge_method=S256
  &scope=order
  &resource=https%3A%2F%2Fapi.layout.link
  &resource=https%3A%2F%2Fmcp.layout.link
  &state=$STATE

The same rules apply as for the API alone: only an OAuth client your application created in the console can ask, and a production person needs your application approved. One refresh token covers both resources. Every access token is still for exactly one, so every token request names the one you want with a single resource:

curl https://api.layout.link/v1/oauth/token \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d redirect_uri=https://yourapp.example/callback \
  -d code_verifier=$CODE_VERIFIER \
  -d client_id=$CLIENT_ID \
  -d resource=https://api.layout.link

For the other resource, refresh with the refresh token that came back and resource=https://mcp.layout.link. There is one refresh token for the person, never one per resource, and the rules in Tokens and refresh apply to it whichever resource you ask for. A token request for a two-resource grant with no resource, or with two different ones, is invalid_target; an authorization code refused that way is not spent, so send it again with a resource.

Who a token acts for

curl https://api.layout.link/v1/whoami \
  -H "Authorization: Bearer $LAYOUT_PERSON_TOKEN"
200 OK

{
  "kind": "user",
  "app": { "id": "lyt_app_3f9c2a7e1b4d8f6a0c5e9b27" },
  "environment": "live",
  "user": { "id": "usr_4b8e9a01c2d3" },
  "scopes": ["order"]
}

With a build grant, kind is "grant", scopes is ["build", "confirm"] (it confirms with the person's code whenever Layout asks for one), and the response adds the grant's expiresAt. A person's OAuth token answers kind: "user" with the scopes its authorization granted, which today is always ["order"]. user.id is the id this application knows the person by, the same one its webhooks carry. A person who signed in through OAuth and a person you provisioned get ids from different series: if the same person does both, your application knows them by two different ids.

With your secret, whoami describes your application instead: kind is "app", with app.id, environment, approvalStatus and scopes, and no user. That scopes is the same fixed list for every application, ["order:preview", "order:build", "order:confirm", "order:status", "places"]. It is not a scope any token carries, and it does not list everything a build grant can do, such as reading menus: see What each door can do.

Tokens and refresh

WhatLifetime
Authorization codeTen minutes, and it works once.
Access tokenTen minutes. It also stops at once when the person disconnects your app, you revoke it, or you delete the client.
Refresh tokenThirty days from when it was issued.

Every refresh rotates. The refresh token you send stops working and the response carries a new one, with a fresh thirty days, so a connection you refresh at least once a month never expires. Store the new refresh token before you use the new access token.

curl https://api.layout.link/v1/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=$REFRESH_TOKEN \
  -d client_id=$CLIENT_ID \
  -d resource=https://api.layout.link

client_id is required. resource must be a resource the grant covers: required when it covers both, otherwise optional. Every token response carries access_token, token_type (Bearer), expires_in (600), refresh_token and scope.

Keep only the newest refresh token, and send one refresh at a time per person. A retry within 20 seconds, after a lost response, gets a new pair. Reusing an old refresh token more than 20 seconds after it was replaced ends the connection, and the person approves again. A revoked refresh token, or a connection that no longer holds, is invalid_grant. 503 temporarily_unavailable means Layout could not check, and the refresh token still works.

Revoking a connection

When a person signs out of your product or deletes their account with you, revoke their connection (RFC 7009). Send either their refresh token or an access token, with your client_id:

curl https://api.layout.link/v1/oauth/revoke \
  -d token=$REFRESH_TOKEN \
  -d token_type_hint=refresh_token \
  -d client_id=$CLIENT_ID

200 OK

Either token ends the whole connection for that person: every refresh token your client holds for them stops working, and every access token is refused on its next request, on the API and on MCP. It is the same as the person disconnecting your app in Layout. token_type_hint is optional. An access token still revokes after it has expired, for up to thirty days, so revoking at sign-out with the access token you have cached works; the refresh token works too.

200 means revoked, or nothing to revoke. Retry 429 (after Retry-After) and 503 until you get a 200. Anything else is a request error: a 400 names it.

Errors from the authorize, token and revoke endpoints use the OAuth shape, { "error": "…", "error_description": "…" }, not the API's error envelope.

Refusals

StatusCodeWhat to do
401unauthorizedThe token is not recognised, expired, for another resource, or its connection ended (a disconnect, a sign-out everywhere, a deleted account or a deleted OAuth client). Refresh, or send the person through authorization again. A revoked build grant, or one whose application is suspended, is this too: mint a new grant once the application can act again. WWW-Authenticate points at https://api.layout.link/.well-known/oauth-protected-resource.
401grant_expiredThe build grant expired. Mint a new one.
403unauthorizedThe token is real, but your application may not use it right now: it or your organization is suspended, or this is a production person and the application is not approved yet.
403wrong_credentialA real credential of the wrong kind, such as your secret on a person route, or a build grant on a route only a person's token can call. The message names what to send.
  • 429 rate_limited: more than 3,000 requests a minute with one credential of an approved application in production, or 600 in sandbox or before approval. A credential Layout does not recognise counts against the calling address instead, at 300 a minute. A credential Layout issued and refused counts against itself, at 600 a minute. Back off and retry. See Requests per credential.
  • 503 unavailable: we could not check the credential. Nothing about it is wrong. Retry shortly.

MCP refusals

MCP refuses with the same envelope: 401 unauthorized with WWW-Authenticate pointing at the resource metadata, and 401 grant_expired for an expired build grant. 503 unavailable means retry. A secret key is never an MCP credential. Tool results inside the protocol keep their own shape.

401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.layout.link/.well-known/oauth-protected-resource", scope="order"

{ "error": { "code": "unauthorized", "message": "Missing, invalid or disconnected access token. Authorize with Layout again." } }

When a call is refused

A refused secret is 401 when it was not recognised or was rotated, and 403 when it is real but the application may not do that, almost always production before approval. A 401 does not say which; a 403 carries error.reason (live_not_approved, app_suspended or partner_suspended), and Refused requests, on the Credentials page, lists both. Every code is on Errors.