# 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](https://developer.layout.link/reference/how-it-works#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.

```bash
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

| Credential | Shape | Notes |
| --- | --- | --- |
| Client id | `lp_test_…` / `lp_live_…` | Identifies the application. Not a secret, but not useful on its own. |
| Client secret | `sk_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 secret | On the Webhooks page | Different secret, different job. See [Webhooks](https://developer.layout.link/reference/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](https://developer.layout.link/console/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](https://developer.layout.link/console/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.

| What | Value |
| --- | --- |
| Discovery | `https://api.layout.link/.well-known/oauth-authorization-server` |
| Authorize | `https://api.layout.link/v1/oauth/authorize` |
| Token | `https://api.layout.link/v1/oauth/token` |
| Revoke | `https://api.layout.link/v1/oauth/revoke` (see [Revoking a connection](#revoking-a-connection)) |
| Resource | `https://mcp.layout.link` for MCP, `https://api.layout.link` for the REST API, or both in one authorization (see [One authorization for both](#one-authorization-for-both)) |
| Resource metadata | `https://api.layout.link/.well-known/oauth-protected-resource` for the REST API, `https://mcp.layout.link/.well-known/oauth-protected-resource` for MCP |
| Scope | `order` |

To put this behind a button in your app, see [Sign in with Layout](https://developer.layout.link/reference/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](https://developer.layout.link/reference/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](#sign-in-test-accounts), 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](#sign-in-test-accounts).

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](https://developer.layout.link/reference/card-links) 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](https://developer.layout.link/console/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](https://developer.layout.link/reference/card-links#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](https://developer.layout.link/console/oauth-clients) page. Layout gives the account a reserved phone number and a six-digit sign-in code.

| What | Value |
| --- | --- |
| Phone number | Picked 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 code | Six 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. |
| Environment | Sandbox, 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 to | Only 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](https://developer.layout.link/reference/provisioning#connecting-an-existing-account) from any application in your developer account, for a user provisioned with its number. |
| Approval | Not needed. A sandbox person connects to an application that is still under review. |
| Limits | 10 at a time for each developer account, across its applications, and 30 created a day. |
| Deleting | It 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](https://developer.layout.link/reference/cli) signs in with a code a person approves on the console's [CLI](https://developer.layout.link/console/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](https://developer.layout.link/reference/cli#production)).

| A CLI token can | A CLI token cannot |
| --- | --- |
| Read the account, its applications, usage and recent sandbox deliveries | Create, reveal or rotate a live key |
| Create applications | Read a live signing secret |
| Create, read and revoke sandbox keys | Add, change or delete a webhook endpoint, or change any production setting |
| List webhook endpoints and read the sandbox signing secret | Approve anything |
| Fire sandbox test events and read the sandbox event feed | Place a live order or move money |
| Ask for production review | Use 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 endpoints | Write 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](https://developer.layout.link/reference/cli#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](https://developer.layout.link/reference/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](#check-who-a-token-acts-for)). Neither person credential can add a card. See [The credentials](https://developer.layout.link/reference/how-it-works#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](#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`:

```bash
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](#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

```bash
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](https://developer.layout.link/reference/how-it-works#what-each-door-can-do).

### Tokens and refresh

| What | Lifetime |
| --- | --- |
| Authorization code | Ten minutes, and it works once. |
| Access token | Ten minutes. It also stops at once when the person disconnects your app, you revoke it, or you delete the client. |
| Refresh token | Thirty 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.

```bash
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`:

```bash
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

| Status | Code | What to do |
| --- | --- | --- |
| `401` | `unauthorized` | The 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`. |
| `401` | `grant_expired` | The build grant expired. Mint a new one. |
| `403` | `unauthorized` | The 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. |
| `403` | `wrong_credential` | A 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](https://developer.layout.link/reference/limits#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](https://developer.layout.link/console/credentials) page, lists both. Every code is on [Errors](https://developer.layout.link/reference/errors).
