# Authentication

Every request to the REST API and to the MCP server carries an API key as a bearer token.

```bash
curl https://reel.money/api/v1/account \
  -H "Authorization: Bearer rm_your_key"
```

A key is created in ReelMoney under Settings, then API keys, at `https://reel.money/brands/<brand_id>/api_keys`. The token is shown once, at creation. If it is lost, revoke it and make another one.

Facts worth knowing before you build:

- A key belongs to **one brand** and acts on that brand by default. Only a key carrying the `brands:all` scope can reach the account's other brands, per request, with `brand_id`.
- Plan limits, quota and credits are the **brand owner's**. `GET /api/v1/account` reports them.
- A brand can hold up to ten keys, so give each agent its own and revoke the one that misbehaves.
- Keys can carry an expiry date. An expired key answers `key_expired`, a revoked key answers `key_revoked`.
- No CORS headers are sent, on purpose. Call the API from a server or an agent runtime, never from a browser page where the key would be readable.

## Scopes

A key carries one or more scopes, ticked when it is created. A scope it does not carry is a wall, not a warning.

| Scope | What it allows |
| --- | --- |
| `brand:read` | Read your brand profile, plan, quota, videos and slideshows. |
| `media:read` | Browse your media library, media packs and stock media. |
| `media:write` | Upload files and add them to your media library. |
| `content:write` | Create and edit slideshows and videos, and start renders. |
| `posts:read` | Read your posts and scheduled posts, and run post checks. |
| `posts:write` | Publish and schedule posts, and cancel scheduled posts. |
| `analytics:read` | Read post metrics and account analytics. |
| `brands:all` | Work across every brand this account owns, choosing the brand per request. |

Publishing is deliberately separate. A key with `content:write` but no `posts:write` can research, generate and render all day and still cannot put anything in front of an audience. That is the setting to start an agent on.

Each endpoint names the scope it needs in [/openapi.json](/openapi.json) as `x-required-scope`, and the [endpoint reference](/api-docs/endpoints) repeats it per group.

### One key, many brands

`brands:all` is the opt in exception to the one key, one brand rule. A key that carries it may act on any brand the same account owns: send the optional `brand_id` query parameter (or the `X-Brand-Id` header, the parameter winning) on any brand facing endpoint, with an id from `GET /api/v1/brands`. Leave `brand_id` out and the key works on its own brand, exactly as every key always has, so nothing changes for an existing integration. Without the scope, a `brand_id` naming a different brand answers 403 `missing_scope` with `brands:all` in `details.required_scope`, and an id the account does not own answers 404 `not_found`, whatever the scopes. Quota, credits and rate limits stay the key owner's in every case.

### Keys made before this vocabulary

Older keys carry `read`, `write` or `admin`. They keep working exactly as they did and are read like this:

| Old scope | Reads as |
| --- | --- |
| `read` | `brand:read`, `media:read`, `posts:read`, `analytics:read` |
| `write` | `content:write`, `media:write`, `posts:write` |
| `admin` | `brand:read`, `media:read`, `media:write`, `content:write`, `posts:read`, `posts:write`, `analytics:read`, `brands:all` |

A key with no scopes at all is treated as `read`. It can never write. Edit such a key in the dashboard to tick the permissions you want instead.

## What a failure looks like

A missing or bad token answers 401 with an RFC 6750 challenge header, which is what an OAuth capable client looks for:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="ReelMoney", error="invalid_token"
```

```json
{
  "success": false,
  "errors": [
    { "code": "invalid_token", "message": "The API key is not valid.", "retryable": false }
  ]
}
```

A valid key without the right scope answers 403 and names the scope it wanted, so an agent can tell a person exactly which box to tick:

```json
{
  "success": false,
  "errors": [
    {
      "code": "missing_scope",
      "message": "The API key does not have the permission this endpoint requires.",
      "retryable": false,
      "details": {
        "required_scope": "posts:write",
        "key_scopes": ["brand:read", "content:write"]
      }
    }
  ]
}
```

Branch on `code`, never on the message text. Messages are written for people and may be reworded. Codes are a contract. See the [error reference](/api-docs/errors).

## Response shape

Every success looks like this:

```json
{ "success": true, "data": { }, "meta": { } }
```

Every failure looks like this:

```json
{ "success": false, "errors": [{ "code": "quota_exceeded", "message": "...", "retryable": false }] }
```

List endpoints take `page` and `per_page` (1 to 50, 25 by default) and report `meta.pagination` with `page`, `per_page`, `total_pages` and `total_count`.

## Rate limits

Ceilings are counted per key, not per account, so one noisy agent cannot starve another.

| Bucket | Ceiling per key |
| --- | --- |
| Every endpoint | 300 requests per 5 minutes |
| Generation (create slideshow, render slideshow, create video) | 20 requests per 1 hour |
| Posting (publish or schedule) | 30 requests per 1 hour |

Every response carries the current bucket in headers:

```http
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Window: 300
```

Over a ceiling the API answers 429 `rate_limited` with a `Retry-After` header and `details.retry_after_seconds`. Wait that long, then continue. Do not spin.

Social platforms enforce their own separate caps. Those surface as `platform_rate_limited`, and `POST /api/v1/posts/validate` reports the headroom left per account before you spend it.

## Idempotency keys

The create endpoints (`POST /api/v1/slideshows`, `POST /api/v1/slideshows/{id}/render`, `POST /api/v1/videos`, `POST /api/v1/posts`) accept an `Idempotency-Key` header. Send a fresh unique value, such as a UUID, per logical action.

```bash
curl -X POST https://reel.money/api/v1/slideshows \
  -H "Authorization: Bearer rm_your_key" \
  -H "Idempotency-Key: 5a1f9b2c-7f1e-4c73-9c6d-1b2f3a4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{ "context": "..." }'
```

- Same key, same body: the stored response is replayed with `meta.idempotent_replay` set to true. Nothing runs twice.
- Same key, different body: 409 `idempotency_conflict`. One key belongs to one request forever.
- Same key while the first request is still running: 409 `idempotency_conflict` with `details.in_flight`.
- No header: nothing is stored and the request behaves normally.

Keys are scoped to the API key and the exact path, and they are kept for 24 hours. This is what makes a timeout safe to retry: send the same key again and you get the first answer instead of a second slideshow.

## Housekeeping

- Rotate a key by creating the new one, moving the agent over, then revoking the old one.
- Revoking is instant. `last_used_at` and the request count on each key show what an agent has actually been doing.
- If a key leaks, revoke it first and ask questions after. There is no way to unpublish a post that has already gone out.
