> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laces.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> OAuth 2.0 client credentials for your server, and the publishable key for your web pages.

The Partner API accepts two credentials, on different routes:

| Credential | Header | From | Can do |
| - | - | - | - |
| **Bearer token** (from OAuth client credentials) | `Authorization: Bearer LCSTOK-…-X` | Your server | Everything your [scopes](/concepts/scopes) allow |
| **Publishable key** | `X-Laces-Publishable-Key: LCSPUBK-…-X` | A browser on one of your allowed domains | List and read events, create a checkout session |

Both are sent to the Partner API host, `https://connect.laces.run/v1`. A Laces
app sign-in (a Firebase ID token) is **not** a Partner API credential and is
rejected there with `401 invalid_token`. The reverse holds too: a partner
credential sent to any other Laces host (for example `api.laces.run`) is
refused with `401 partner_credential_not_accepted`.

<Info>
  Every key, token and id on this site is a made-up example value. Your real
  keys are issued to you in the Laces app, under **Settings → Developers**.
</Info>

## Server side: OAuth 2.0 client credentials

[`POST /oauth/token`](/api-reference/v1/oauth/token) implements the
OAuth 2.0 client credentials grant
([RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
You send your client id and secret, and get back a bearer token valid for one
hour.

<Warning>
  **The client secret (`LCSSECK-…-X`) is server-only.** It mints tokens that
  can create and pay orders. Keep it in your server's environment or secret
  manager, never in a browser, a mobile app or source control. The token
  endpoint refuses any request that carries an `Origin` header, so it cannot
  be called from a web page even by mistake.
</Warning>

### The request

Authenticate **either** with HTTP Basic **or** with fields in the body. Use
exactly one; sending both is `400 invalid_request`.

* **Basic**: `Authorization: Basic base64(client_id + ":" + client_secret)`.
  (Strictly, each part is URL-encoded first, but Laces ids and secrets contain
  only letters, digits, `-` and `_`, so encoding changes nothing.)
* **Body**: `client_id` and `client_secret` fields.

`grant_type=client_credentials` is always required in the body. The body can
be `application/x-www-form-urlencoded` (the RFC's format) or JSON.

`scope` is optional: a space-separated list to ask for fewer scopes than the
client holds. Leave it out to get all of them.

<CodeGroup>
  ```bash curl (Basic) theme={null}
  curl -s -X POST "$BASE/oauth/token" \
    -u "$LACES_CLIENT_ID:$LACES_CLIENT_SECRET" \
    -d "grant_type=client_credentials"
  ```

  ```bash curl (body) theme={null}
  curl -s -X POST "$BASE/oauth/token" \
    -d "grant_type=client_credentials" \
    -d "client_id=$LACES_CLIENT_ID" \
    -d "client_secret=$LACES_CLIENT_SECRET"
  ```

  ```js Node theme={null}
  // Node 18+ (global fetch). Server only.
  const basic = Buffer.from(
    `${process.env.LACES_CLIENT_ID}:${process.env.LACES_CLIENT_SECRET}`,
  ).toString("base64");

  const res = await fetch(`${process.env.LACES_BASE}/oauth/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ grant_type: "client_credentials" }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error}: ${body.error_description}`);
  const token = body.access_token;
  ```

  ```python Python theme={null}
  import os, requests

  res = requests.post(
      f"{os.environ['LACES_BASE']}/oauth/token",
      auth=(os.environ["LACES_CLIENT_ID"], os.environ["LACES_CLIENT_SECRET"]),
      data={"grant_type": "client_credentials"},
      timeout=30,
  )
  body = res.json()
  if not res.ok:
      raise RuntimeError(f"{body['error']}: {body.get('error_description')}")
  token = body["access_token"]
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init(getenv('LACES_BASE') . '/oauth/token');
  curl_setopt_array($ch, [
      CURLOPT_POST           => true,
      CURLOPT_USERPWD        => getenv('LACES_CLIENT_ID') . ':' . getenv('LACES_CLIENT_SECRET'),
      CURLOPT_POSTFIELDS     => http_build_query(['grant_type' => 'client_credentials']),
      CURLOPT_RETURNTRANSFER => true,
  ]);
  $body   = json_decode(curl_exec($ch), true);
  $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);

  if ($status !== 200) {
      throw new RuntimeException($body['error'] . ': ' . ($body['error_description'] ?? ''));
  }
  $token = $body['access_token'];
  ```
</CodeGroup>

### The response

`200`, in the bare OAuth shape (no Laces envelope), with
`Cache-Control: no-store`:

```json theme={null}
{
  "access_token": "LCSTOK-EXAMPLETKN23456789EXAMPLETKN23456789EXAMPLETKN234567-X",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "events:read orders:write checkout_sessions:write"
}
```

Send it on every other call:

```http theme={null}
Authorization: Bearer LCSTOK-EXAMPLETKN23456789EXAMPLETKN23456789EXAMPLETKN234567-X
```

### Caching the token

Tokens are opaque and valid for **3600 seconds**. Cache the token and fetch a
new one shortly before it expires, or when a call returns `401 invalid_token`.
**Do not mint a token per API call**: the token endpoint allows only about 10
requests a minute for each client id from one IP address
([rate limits](/concepts/rate-limits)).

```js theme={null}
let cached = { token: null, exp: 0 };

export async function getLacesToken() {
  // Refresh a minute early so a token never expires mid-request.
  if (cached.token && Date.now() < cached.exp - 60_000) return cached.token;
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const res = await fetch(`${BASE}/oauth/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: "grant_type=client_credentials",
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`token: ${body.error} ${body.error_description}`);
  cached = { token: body.access_token, exp: Date.now() + body.expires_in * 1000 };
  return cached.token;
}
```

If you run several server instances, share the cached token (for example in
Redis) rather than each instance minting its own.

### Server-to-server only

<Warning>
  Any request to the token endpoint that carries an **`Origin` header is
  refused** with `400 invalid_request`, and the endpoint sends no CORS headers.
  Browsers always add `Origin` to cross-origin POSTs, so the secret can't be
  used from a web page even by mistake.

  If your framework proxies requests and forwards the browser's headers, strip
  `Origin` before calling Laces.
</Warning>

### Token endpoint errors

Errors use the OAuth shape
([RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)),
not the Laces envelope:

```json theme={null}
{ "error": "invalid_client", "error_description": "Client authentication failed" }
```

| HTTP | `error` | Cause |
| - | - | - |
| 400 | `invalid_request` | `grant_type` missing; both Basic and body credentials sent; a body that can't be parsed or is over 10 KB; **the request carried an `Origin` header** |
| 400 | `unsupported_grant_type` | Anything other than `client_credentials` |
| 400 | `invalid_scope` | An unknown scope, or one the client doesn't hold |
| 401 | `invalid_client` | Unknown client, wrong secret, a secret more than 24 hours past a regenerate, revoked client, or a malformed or lowercased client id. Comes with `WWW-Authenticate: Basic`. |
| 429 | `temporarily_unavailable` | Rate limited. Wait the number of seconds in the `Retry-After` header, then retry. OAuth libraries treat this code as "back off and retry". |
| 500 | `server_error` | Unexpected failure. Retry with backoff. |

## Browser side: the publishable key

For code that runs in a visitor's browser (hosted and embedded checkout), send
the publishable key in a header:

```http theme={null}
X-Laces-Publishable-Key: LCSPUBK-ABCDEFGHJKLMNPQRSTUVWXYZ23456789-X
```

```js theme={null}
const res = await fetch("https://connect.laces.run/v1/events", {
  headers: { "X-Laces-Publishable-Key": LACES_PUBLISHABLE_KEY },
});
```

The publishable key is public: it's meant to sit in your page source. That's
safe because the server enforces three things:

1. **Origin must match an allowed domain.** The request's `Origin` header must
   exactly equal one of the key's [allowed domains](/api-keys#allowed-domains-vs-return-url-domains).
   A request with no `Origin` (curl, a server) is `403 origin_not_allowed`: a
   publishable key works only from a browser on your site. This guards against
   a copied key being used on someone else's site.
2. **It is capped to two scopes**, whatever the client holds: `events:read`
   and `checkout_sessions:write`. It can list and read events and create a
   checkout session. Nothing else, so it can never create, pay or read orders.
3. **CORS** is answered only for origins on an allowlist. `Content-Type`,
   `Accept`, `Authorization`, `X-Laces-Publishable-Key` and `Idempotency-Key`
   are the permitted request headers, and your script can read the
   `Retry-After` and `X-Request-Id` response headers. Cookies are not used.

If a request carries both a bearer token and a publishable key, only the bearer
token is considered.

## Authentication errors on API routes

Every route other than the token endpoint answers with the
[Laces envelope](/concepts/errors) and a stable `error_code`. A `401` also
carries `WWW-Authenticate: Bearer realm="laces-partner"`.

| HTTP | `error_code` | Meaning |
| - | - | - |
| 401 | `unauthorized` | No credential sent |
| 401 | `invalid_token` | Malformed, unknown, expired or revoked token, or a non-Laces bearer such as a Firebase token |
| 401 | `invalid_key` | Publishable key malformed, unknown, revoked, or replaced by **Regenerate keys** |
| 403 | `credential_not_permitted` | This route doesn't accept that credential type, for example a publishable key on an order route |
| 403 | `origin_not_allowed` | Publishable key sent with a missing or non-allowed `Origin` |
| 403 | `insufficient_scope` | The credential lacks a scope this route needs |
| 401 | `partner_credential_not_accepted` | You sent a partner credential to a Laces host other than `connect.laces.run`. Fix your base URL. |

```json theme={null}
{
  "code": 401,
  "message": "Unauthorized",
  "error": "Access token is invalid or expired",
  "error_code": "invalid_token"
}
```

On `invalid_token`, fetch a new token once and retry. If it fails again,
check that the key hasn't been revoked and that you are calling
`https://connect.laces.run/v1`.

Changes to a key apply immediately: a revoke cuts off the key and its tokens
on the next request, and a removed permission stops working on the next
request. After **Regenerate keys**, tokens already issued keep working until
they expire (at most 1 hour); revoke the key to cut them off at once. A newly
added allowed domain can take up to 5 minutes to pass browsers' CORS
preflight.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.