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

# Authentication

> How Intent API requests authenticate: personal sandbox keys, OAuth 2.0 client credentials, scopes, token lifetime and the signed webhook exception.

Every Intent API request carries a bearer token. This page covers the two credentials that produce one, the scope each route requires, how long a token lives, what the `401` and `403` responses look like, and how to get sandbox credentials. It is written for the engineer making the first call.

## How requests authenticate

Send the token in the `Authorization` header on every request:

```http theme={"system"}
Authorization: Bearer <token>
```

Two routes work differently:

* `POST /v1/oauth/token` is the token endpoint. It carries no bearer token; the client id and secret travel as HTTP Basic credentials, or as body fields.
* `POST /v1/webhooks/{platform}` is called by a destination platform, not by you. It carries no bearer token either. The delivery authenticates by its `X-Signature-HMAC-SHA256-{n}` header: a Base64-encoded HMAC-SHA256 of the raw request body, computed with one of the active signing keys. The numeric suffix carries no meaning; during key rotation a delivery may carry several such headers, and any one that matches an active key verifies it. Comparison is timing-safe, and a delivery that cannot be verified is rejected with `400`, not queued. composerID's own outbound webhooks use the same convention.

Destination credentials are never supplied by the caller. They are held per tenant and injected by the adapter.

## Credentials

<Tabs>
  <Tab title="Personal sandbox key">
    A personal key is a bearer token that carries every scope. It is the quickest route to a first request and is meant for exploring the API by hand. The reference sandbox prints one at startup (`demo_...`) unless `COMPOSER_API_KEYS` supplies your own as a comma-separated list. Keys are compared in constant time.

    <CodeGroup>
      ```bash curl theme={"system"}
      BASE=http://127.0.0.1:8787/v1
      KEY=demo_...        # printed when the sandbox starts

      curl -H "Authorization: Bearer $KEY" "$BASE/platforms"
      ```

      ```python Python theme={"system"}
      import json
      import os
      import urllib.request

      BASE = "http://127.0.0.1:8787/v1"
      KEY = os.environ["KEY"]  # printed when the sandbox starts

      req = urllib.request.Request(
          f"{BASE}/platforms",
          headers={"Authorization": f"Bearer {KEY}"},
      )
      with urllib.request.urlopen(req) as resp:
          print(json.load(resp))
      ```

      ```javascript JavaScript theme={"system"}
      const BASE = "http://127.0.0.1:8787/v1";
      const KEY = process.env.KEY; // printed when the sandbox starts

      const resp = await fetch(`${BASE}/platforms`, {
        headers: { Authorization: `Bearer ${KEY}` },
      });
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>

  <Tab title="OAuth 2.0 client credentials">
    Client credentials are the production model (RFC 6749 section 4.4). An organisation holds a `client_id` and `client_secret` with the scopes it has been granted and exchanges them at `POST /v1/oauth/token` for a short-lived bearer token. Secrets stay server to server, tokens expire, and a leaked token is bounded in time and in scope.

    The request sends `grant_type=client_credentials`, with the client id and secret as HTTP Basic credentials (preferred) or as `client_id` and `client_secret` fields, as `application/x-www-form-urlencoded` or `application/json`. An optional `scope` names a space-separated subset of the client's granted scopes; omit it to receive all of them. The response carries `access_token` (a signed payload, opaque to the client), `token_type` (`Bearer`), `expires_in` (`3600`) and `scope`, and is sent with `Cache-Control: no-store`.

    In the reference sandbox, `COMPOSER_CLIENTS` holds a comma-separated list of `id:secret[:scope scope]` entries; a client with no scope list is granted every scope. When it is unset the server generates a `demo-client` and prints its secret at startup.

    <CodeGroup>
      ```bash curl theme={"system"}
      BASE=http://127.0.0.1:8787/v1

      # 1. Exchange the client credentials for a token
      curl -u "$CLIENT_ID:$CLIENT_SECRET" \
        -d grant_type=client_credentials \
        -d "scope=intents:write publish" \
        "$BASE/oauth/token"
      # -> {"access_token":"...","token_type":"Bearer","expires_in":3600,"scope":"intents:write publish"}

      # 2. Use it
      curl -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"payload":{"channel":"contingent_hire"}}' \
        "$BASE/intent"
      ```

      ```python Python theme={"system"}
      import base64
      import json
      import os
      import urllib.parse
      import urllib.request

      BASE = "http://127.0.0.1:8787/v1"
      basic = base64.b64encode(
          f"{os.environ['CLIENT_ID']}:{os.environ['CLIENT_SECRET']}".encode()
      ).decode()

      # 1. Exchange the client credentials for a token
      form = urllib.parse.urlencode({
          "grant_type": "client_credentials",
          "scope": "intents:write publish",
      }).encode()
      req = urllib.request.Request(
          f"{BASE}/oauth/token",
          data=form,
          headers={"Authorization": f"Basic {basic}"},
      )
      with urllib.request.urlopen(req) as resp:
          token = json.load(resp)["access_token"]

      # 2. Use it
      req = urllib.request.Request(
          f"{BASE}/intent",
          data=json.dumps({"payload": {"channel": "contingent_hire"}}).encode(),
          headers={
              "Authorization": f"Bearer {token}",
              "Content-Type": "application/json",
          },
      )
      with urllib.request.urlopen(req) as resp:
          print(json.load(resp))
      ```

      ```javascript JavaScript theme={"system"}
      const BASE = "http://127.0.0.1:8787/v1";
      const basic = Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
      ).toString("base64");

      // 1. Exchange the client credentials for a token
      const tokenResp = await fetch(`${BASE}/oauth/token`, {
        method: "POST",
        headers: {
          Authorization: `Basic ${basic}`,
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "client_credentials",
          scope: "intents:write publish",
        }),
      });
      const { access_token } = await tokenResp.json();

      // 2. Use it
      const resp = await fetch(`${BASE}/intent`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${access_token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ payload: { channel: "contingent_hire" } }),
      });
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>
</Tabs>

## Scopes

Every route declares the scope it needs. A token without that scope receives `403 insufficient_scope`, never a silent success. A personal key carries all six scopes; a client credentials token carries the scopes the client was granted, narrowed further by the `scope` it asked for.

| Scope               | What it unlocks                                                                                                                                                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `intents:read`      | Read intent records and their timelines: `GET /v1/intent/{intent_id}` and `GET /v1/intent/{intent_id}/timeline`.                                                               |
| `intents:write`     | Create, patch, plan, preflight and enrich intents: `POST /v1/intent`, `PATCH /v1/intent/{intent_id}`, `POST /v1/plan`, `POST /v1/preflight` and `POST /v1/enrichment-request`. |
| `publish`           | Publish an intent into a destination system: `POST /v1/publish`.                                                                                                               |
| `reconcile`         | Run reconciliation against a destination: `POST /v1/reconcile`.                                                                                                                |
| `passthrough`       | Raw platform calls anchored to an Intent ID: `POST /v1/passthrough`.                                                                                                           |
| `destinations:read` | List platforms, destinations and capabilities: `GET /v1/platforms`, `GET /v1/destinations` and `GET /v1/destinations/{id}/capabilities`.                                       |

Four routes need no scope. The `GET /v1` index, which any authenticated principal may call. `POST /v1/oauth/token`, which authenticates the client itself. `POST /v1/webhooks/\{platform\}`, which authenticates by signature. And `POST /v1/sandbox/keys`, which carries no credential at all: it is how a caller gets their first one, so it is rate-limited per address and answers `201` with `Cache-Control: no-store`.

## Token lifetime and rotation

A token expires `3600` seconds after it is issued (`expires_in`). Request a new one when a call returns `401`; the token endpoint response is never cached. Tokens are self-contained rather than stored. Each one is a base64url JSON payload carrying the subject, its scopes, its tenant and an expiry, a dot, and an HMAC-SHA256 signature over that payload under the issuer's secret. Nothing is written down: a token is valid when its signature verifies and its expiry is in the future. That is what lets the hosted sandbox run on serverless instances that share no memory, and it is why an operator must set `COMPOSER_TOKEN_SECRET` to the same value everywhere. Without it each instance generates its own secret, and a token issued by one is rejected by the next.

Rotate a client by issuing it a new client secret, without a redeploy. Personal keys are issued per person, so rotation, revocation and the audit trail trace to a named individual rather than to a shared secret.

## Errors

| Status | Where                  | Meaning                                                                                                                                        |
| ------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Any protected route    | The bearer token is missing, unknown or expired.                                                                                               |
| `403`  | Any protected route    | The token is valid but lacks the scope the route needs (RFC 6750 section 3.1). The body and the `WWW-Authenticate` header both name the scope. |
| `401`  | `POST /v1/oauth/token` | `invalid_client`: the client id or secret is wrong.                                                                                            |
| `400`  | `POST /v1/oauth/token` | `unsupported_grant_type`, `invalid_scope` (an unknown scope, or one not granted to this client) or `invalid_request`.                          |

Token endpoint errors follow RFC 6749 section 5.2, with `error` and `error_description` fields.

<CodeGroup>
  ```http 401 missing token theme={"system"}
  HTTP/1.1 401 Unauthorized
  WWW-Authenticate: Bearer realm="composerID"
  Content-Type: application/json

  {"error": "missing or invalid bearer token"}
  ```

  ```http 403 insufficient scope theme={"system"}
  HTTP/1.1 403 Forbidden
  WWW-Authenticate: Bearer error="insufficient_scope", scope="intents:write"
  Content-Type: application/json

  {"error": "insufficient_scope", "scope": "intents:write"}
  ```

  ```http 401 invalid client theme={"system"}
  HTTP/1.1 401 Unauthorized
  WWW-Authenticate: Basic realm="composerID"
  Cache-Control: no-store
  Content-Type: application/json

  {"error": "invalid_client", "error_description": "client authentication failed"}
  ```

  ```http 400 invalid scope theme={"system"}
  HTTP/1.1 400 Bad Request
  Cache-Control: no-store
  Content-Type: application/json

  {"error": "invalid_scope", "error_description": "scope not granted to this client: reconcile"}
  ```
</CodeGroup>

## Getting sandbox credentials

There is no account to create and no sign-in. The documentation is open, and the sandbox issues its own credentials.

The hosted sandbox answers at `https://sandbox.composer.id/v1`. One unauthenticated call mints a tenant with everything needed to publish:

<CodeGroup>
  ```bash Mint credentials theme={"system"}
  curl -s -X POST https://sandbox.composer.id/v1/sandbox/keys \
    -H "Content-Type: application/json" \
    -d '{"label":"evaluating composerID"}'
  ```

  ```json Response theme={"system"}
  {
    "tenant": "tn_...",
    "api_key": "sk_sandbox_...",
    "client_id": "cid_...",
    "client_secret": "cs_...",
    "webhook_signing_key": "whsec_...",
    "scope": "destinations:read intents:read intents:write passthrough publish reconcile",
    "expires_at": "2026-10-22T00:00:00Z"
  }
  ```
</CodeGroup>

The secrets are shown once and stored only as hashes, and every credential in the set expires together at `expires_at`. Mint another set whenever you need one, at up to five sets an hour per address. Each tenant sees only the intents it minted, and an inbound webhook is routed to the tenant that minted the intent it names.

Running the sandbox locally is the other way in: `python3 -m service.server` prints a personal key, a demo client id and secret, and a webhook signing key at startup. See the [quickstart](/quickstart).

<Info>
  This is the reference sandbox: destinations are mock tenants, and its `GET /v1` index reports whether its state is durable. It answers at `https://sandbox.composer.id/v1` today; a dedicated `sandbox.composer.id` host is planned and not live yet. The production service behind `api.composer.id` is in build and will not expose `POST /sandbox/keys`.
</Info>

By using a key you agree to the [API Terms](/legal/api-terms) and the [Privacy Policy](/legal/privacy). Keys are personal, never shared demo credentials, so revocation and the audit trail trace to a person. composerID ships with Triage and is not sold separately; for product and pricing questions, [book a demo with Triage](https://www.addtriage.com/demo.html).

## Next steps

<Columns cols={3}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Start the sandbox and make the first call.
  </Card>

  <Card title="API reference" icon="code" href="/api-reference/overview">
    Every `/v1` route and the scope it requires.
  </Card>

  <Card title="Manuscript API" icon="file-text" href="/api-reference/manuscript/overview">
    A separate API with per-customer keys issued per programme.
  </Card>
</Columns>
