# Authentication

> Developer API keys: formats, the Bearer and x-api-key headers, scopes per route family, read-only keys, ceilings, rotation and hygiene.

Every API call is authenticated by a **developer API key**. A key belongs to one workspace, acts as the user who created it, and can be narrowed to the route families it needs, capped in questions per month and in requests per minute, made read-only, and given an expiry date. Narrowing is the point: the key you give to a client's application should be able to call the engine and nothing else.

## Key format

A key looks like `sk_live_` followed by 48 hexadecimal characters. Subsidia stores only a SHA-256 hash of it and a 16-character prefix (`sk_live_xxxxxxxx`) that the console shows to help you recognise it. The full key is returned **once**, at creation. There is no way to display it again; if it is lost, create a new key and revoke the old one.

## Sending the key

| Header | Used by | Notes |
| --- | --- | --- |
| `Authorization: Bearer sk_live_...` | OpenAI SDKs, curl, most HTTP clients | The value must start with `sk_live_` or `sk_test_`. Any other Bearer value is not treated as an API key. |
| `x-api-key: sk_live_...` | Anthropic SDKs, the Subsidia SDK | Takes precedence over `Authorization` when both are present. |

> **INFO: Precedence, precisely**
> If `x-api-key` is present it is the key, and an invalid value is a 401: the server does not fall back to the `Authorization` header. Without it, the `Authorization` header is used only if it starts with `Bearer sk_live_` or `Bearer sk_test_`. On the few routes that also accept an interactive session (Reflex and Light), any other Bearer value is read as a session token instead.

**Same key, both styles**

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/models -H "Authorization: Bearer $SUBSIDIA_API_KEY"
curl https://api.subsidia.protypa.fr/v1/models -H "x-api-key: $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/v1/models', {
  headers: { 'x-api-key': process.env.SUBSIDIA_API_KEY! },
})
if (res.status === 401) throw new Error('Key missing, revoked or expired')
```

_Python_

```python
import os, requests

res = requests.get(
    "https://api.subsidia.protypa.fr/v1/models",
    headers={"x-api-key": os.environ["SUBSIDIA_API_KEY"]},
)
res.raise_for_status()
```

## Creating and managing keys

The simplest way is the Gateway page of the console (`https://subsidia.protypa.fr/console/gateway`). Keys are also managed through `/developer/api-keys`, which is authenticated by a **signed-in user session** and not by an API key: a key can never create, list, change or revoke keys, including itself.

| Action | Request | Notes |
|---|---|---|
| Create | `POST /developer/api-keys` | Body: `name`, `scopes`, `monthlyQuestionLimit`, `ratePerMinute`, `expiresAt` (ISO date). Returns `201` with the full `key` once. |
| List | `GET /developer/api-keys` | Your active keys: `id`, `name`, `prefix`, `scopes`, ceilings, `usageCount`, `lastUsedAt`, `createdAt`, `expiresAt`. Never the secret. |
| Change | `PATCH /developer/api-keys/:id` | Any of `name`, `scopes`, `monthlyQuestionLimit`, `ratePerMinute`. `null` removes a limit. The secret and the expiry cannot be changed. |
| Revoke | `DELETE /developer/api-keys/:id` | `204`. The key stops working immediately and cannot be reactivated. |
| Usage | `GET /developer/usage` | Request count and last use per key. |

Limits must be positive whole numbers up to 10 000 000. A bad `scopes` list is a `400` that names the accepted values.

> **DANGER: Always pass scopes when you create a key**
> If `scopes` is omitted, the key receives the six legacy module scopes and behaves as an **unrestricted legacy key**: it can reach every route family. A key is only restricted once it holds at least one capability scope from the table below. Never give an unrestricted key to a third party.

**Create a restricted key**

_curl_

```bash
# $SESSION_TOKEN is the signed-in user's session token, not an API key
curl https://api.subsidia.protypa.fr/developer/api-keys \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "x-workspace-id: $WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Client app - production",
    "scopes": ["engine", "knowledge"],
    "monthlyQuestionLimit": 2000,
    "ratePerMinute": 60,
    "expiresAt": "2027-06-30T00:00:00Z"
  }'
```

_Response_

```json
{
  "key": "sk_live_3f9c0b7e5a1d4c28b6e0f1a29d7c4e5830ab12cd45ef6789",
  "prefix": "sk_live_3f9c0b7e",
  "name": "Client app - production",
  "scopes": ["engine", "knowledge"],
  "monthlyQuestionLimit": 2000,
  "ratePerMinute": 60,
  "note": "Store this key now — it will not be shown again."
}
```

## Scopes

A scope grants a family of routes. Combine as many as the key needs. A restricted key that calls a route outside its families gets `403 scope_denied`; a route that maps to no scope at all is refused for restricted keys too.

| Scope | Route families | Typical holder |
| --- | --- | --- |
| `engine` | `/v1/chat/completions`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/models`, `/v1/ai/*`, `/v1/proofs/*`, `/v1/gateway/*` | Any application that calls a model. See [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md), [Completions](https://dev.subsidia.protypa.fr/docs/completions.md), [Proof certificates](https://dev.subsidia.protypa.fr/docs/proofs.md). |
| `knowledge` | `/v1/knowledge-sources*`, `/v1/knowledge/*` | Anything that uploads or queries documents. See [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). |
| `proof` | `/v1/verify*`, `/v1/registre*` | A checker or an audit tool. See [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md) and [Le Registre](https://dev.subsidia.protypa.fr/docs/registre.md). Exempt from the monthly question ceiling. |
| `agents` | `/v1/agents*`, `/v1/tools*` | Applications that manage or chat with agents. See [Agents](https://dev.subsidia.protypa.fr/docs/agents.md). |
| `conversations` | `/v1/conversations*` | Multi-agent debates. See [Conversations](https://dev.subsidia.protypa.fr/docs/conversations.md). |
| `webhooks` | `/v1/webhooks*` | Event subscriptions. See [Webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md). |
| `reflex` | `/engine/*` | Automations. See [Reflex](https://dev.subsidia.protypa.fr/docs/reflex.md). |
| `light` | `/light/*` | The Light assistant. See [Light](https://dev.subsidia.protypa.fr/docs/light.md). |
| `partner` | `/partner/*` | Integrators managing client workspaces. See [Integrators](https://dev.subsidia.protypa.fr/docs/integrators.md). |
| `readonly` | not a route family | A restriction, described below. |

> **WARNING: Routes with no scope**
> Routes such as `POST /v1/postes/:key/run` are not attached to a capability scope. A restricted key cannot call them (`403 scope_denied`); use an unrestricted key for those until a scope is assigned. The `/devices/*` routes used by the desktop app are exempt from scope checks.

## Read-only keys

Add `readonly` to the scopes and the key may only read. Any `POST`, `PUT`, `PATCH` or `DELETE` is refused with `403 read_only_key`, with one deliberate exception: a few `POST` routes only ask a question and change nothing, so they stay open. These are `/v1/chat/completions`, `/v1/messages`, `/v1/ai/complete`, `/v1/knowledge/query`, `/v1/verify`, `/light/ask` and `/light/web`.

`readonly` is combined with capability scopes: `["engine", "readonly"]` is a key that can ask the Gateway but cannot create anything. Inside the app a read-only key acts with the `viewer` role; every other key acts as the workspace `owner`.

## Ceilings, limits and expiry

| Setting | Effect | Failure |
| --- | --- | --- |
| `monthlyQuestionLimit` | Caps the questions this key may consume per calendar month (UTC). The count can lag a burst by a few seconds, so treat it as a guard rail against a runaway integration, not as a meter. An email alert is sent as the key approaches its ceiling. | `402 API_KEY_BUDGET_EXCEEDED` |
| `ratePerMinute` | Caps requests per minute for this key, counted in the database so it holds across servers. Keys created without it have no per-key limit, but route-level limits still apply, see [Rate limits](https://dev.subsidia.protypa.fr/docs/rate-limits.md). | `429 rate_limited` with `retry-after` |
| `expiresAt` | The key stops working at that instant. Set one for any key handed to a project with an end date. | `401 API key expired` |

## Rotating a key

1. **Create the replacement**

   Create a new key with the same scopes and limits. Both keys are valid at the same time, so nothing breaks.

2. **Deploy it**

   Roll the new value out to every place that uses the old one, through your secret manager.

3. **Check the old key is idle**

   In the console or with `GET /developer/usage`, watch the old key's `lastUsedAt` stop moving.

4. **Revoke the old key**

   Delete it. The next call made with it returns `401 Invalid or revoked API key`.

## Key hygiene

- One key per application and per environment, named after both, so a leak points at its source.
- Give each key the fewest scopes that work, a monthly ceiling, and an expiry when the project has an end.
- Keep keys in server-side code only. A key shipped to a browser or a mobile app is public; put your own backend in front of the Gateway instead.
- Never log the `Authorization` or `x-api-key` header. Log the key prefix if you need to tell keys apart.
- Rotate on staff changes and after any suspected exposure; revoke first, investigate after.
- The proof certificates and the usage journal record which workspace acted, so a revoked key leaves a clear trail.

## Authentication errors

| Status | Code or message | Meaning | What to do |
| --- | --- | --- | --- |
| 401 | `API key required. Pass x-api-key header or Authorization: Bearer sk_live_xxx` | No usable key was sent. | Check the header name and that the value starts with `sk_live_`. |
| 401 | `Invalid or revoked API key` | The key is unknown or was revoked. | Create a new key. A typo and a revoked key look the same on purpose. |
| 401 | `API key expired` | `expiresAt` has passed. | Create a replacement key. |
| 403 | `scope_denied` | The key lacks the scope for this route, or the route has no scope. | Add the scope named in the message, or use another key. |
| 403 | `read_only_key` | A read-only key tried to change something. | Use a key without `readonly` for writes. |
| 403 | `client_suspended` | The workspace was suspended by its integrator. | Contact your provider. Keys are kept and work again when access is restored. |
| 403 | `provider_unpaid` | The integrator that hosts this client workspace has an overdue invoice. | Contact your provider. Nothing is deleted. |
| 402 | `API_KEY_BUDGET_EXCEEDED` | The key reached its monthly question ceiling. | Raise the ceiling with `PATCH /developer/api-keys/:id`, or wait for the next month. |
| 429 | `rate_limited` | More requests per minute than the key allows. | Wait for `retry-after` seconds. |

The full catalogue, including the OpenAI and Anthropic error envelopes, is in [Errors](https://dev.subsidia.protypa.fr/docs/errors.md).
