# Authentication (/api/authentication)



Everything you need to make an authenticated call to the REST API.

```text title="The two lines you need"
Base URL   https://app.riffads.com/api/v1
Auth       Authorization: Bearer sk_live_...
```

* One version: `v1`. No version header.
* `riffads.com` is the marketing site. It does not serve `/api/v1`.
* MCP clients use OAuth, not keys. See [MCP connect](/mcp/connect).

<Callout type="warn" title="Server side only">
  No CORS headers, no `OPTIONS` handler. The key is a secret. Call from a backend, task runner, CI job or terminal. Never a browser.
</Callout>

## Send the key [#send-the-key]

<Tabs items="['cURL','TypeScript']">
  <Tab value="cURL">
    ```bash title="Terminal"
    export RIFFADS_API_KEY="sk_live_..."

    curl -s https://app.riffads.com/api/v1/capabilities \
      -H "Authorization: Bearer $RIFFADS_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const res = await fetch("https://app.riffads.com/api/v1/capabilities", {
        headers: { Authorization: `Bearer ${process.env.RIFFADS_API_KEY}` },
    });

    const body = await res.json();
    if (!res.ok) {
        // Every failure is { error: { ok, code, message, retryable, credits_charged } }
        throw new Error(`${body.error.code}: ${body.error.message}`);
    }
    ```
  </Tab>
</Tabs>

### Header rules [#header-rules]

`x-api-key: sk_live_...` works anywhere `Authorization` does.

| Case                                            | Result                                                                                                       |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Both headers sent                               | A usable `Authorization: Bearer` wins. A non-Bearer `Authorization` counts as absent, so `x-api-key` is read |
| Scheme casing                                   | Case-insensitive: `bearer`, `Bearer`, `BEARER`                                                               |
| Extra whitespace                                | Allowed between scheme and key                                                                               |
| Non-Bearer scheme (`Basic abc`), no `x-api-key` | Counts as no header. Plain 401                                                                               |
| Key in a query param                            | Not supported. 401                                                                                           |

Missing header, malformed header and unknown key all get the same 401, byte for byte.

## Get a key [#get-a-key]

Owners and admins create keys at [app.riffads.com/api-keys](https://app.riffads.com/api-keys). No API endpoint creates, rotates or lists keys.

* The workspace plan must include the API. Every paid plan does.
* Prefix is `sk_live_` everywhere. No `sk_test_`, no sandbox. Every call is real.
* Shown once. Stored as a hash. Lost it? Create a new one.
* A key belongs to the **workspace**, not the person who made it. It only reaches its own workspace.
* Keys **never expire**. They work until revoked.
* Name is required, up to 60 characters. Name it after where it lives (`CI pipeline, staging`).
* A key can carry an optional 24 hour spend cap, set in the app.

## Scopes [#scopes]

Pick scopes when you create the key. &#x2A;*They can never be widened.** Need another scope? Create a new key.

| Scope     | Opens                                                                                                                    |
| --------- | ------------------------------------------------------------------------------------------------------------------------ |
| Read      | On every key. Capabilities, actors, voices, balance, estimates, all generation, batch and workflow reads. Spends nothing |
| Generate  | Read, plus uploads and `POST /generations`                                                                               |
| Workflows | Read, plus both workflow invoke routes                                                                                   |

A Generate key can't invoke a workflow. A Workflows key can't submit a generation. Need both? Tick both.

| Endpoint                                              | Scope         |
| ----------------------------------------------------- | ------------- |
| `GET /capabilities`, `GET /capabilities/{id}`         | Read          |
| `POST /estimates`                                     | Read          |
| `GET /actors`, `GET /voices`                          | Read          |
| `GET /generations/{id}`, `GET /generations/{id}/wait` | Read          |
| `GET /batches/{id}`                                   | Read          |
| `GET /workflows/templates`, `GET /workflow-runs/{id}` | Read          |
| `POST /generations`                                   | **Generate**  |
| `POST /uploads`, `POST /uploads/{assetId}/finalize`   | **Generate**  |
| `POST /workflows/{id}/invoke`                         | **Workflows** |
| `POST /workflows/templates/{key}/invoke`              | **Workflows** |

A read-only key can't spend. Safe for dashboards and reports.

## Revoke and rotate [#revoke-and-rotate]

* Revoke in the app. It takes effect on the next call.
* A revoked key answers `401 not_authorized`.
* Work already running finishes.
* To rotate: create the new key, deploy it (both work meanwhile), then revoke the old one.
* No cap on keys per workspace. One key per integration keeps limits and revocation separate.
* Committed a key to a repo? Revoke it now.

## Auth errors [#auth-errors]

Every failure is wrapped in `error`. Every 401 sends `WWW-Authenticate: Bearer realm="RiffAds"`.

```json title="Response: 401"
{
  "error": {
    "ok": false,
    "code": "not_authorized",
    "message": "This RiffAds API key is not valid. Check the Authorization header, or create a new key at riffads.com.",
    "retryable": false,
    "credits_charged": 0
  }
}
```

| Cause                                              | `code`                  | HTTP    | Retry  |
| -------------------------------------------------- | ----------------------- | ------- | ------ |
| No header, non-Bearer scheme, blank or unknown key | `not_authorized`        | 401     | no     |
| Key revoked                                        | `not_authorized`        | 401     | no     |
| Key expired (only very old keys)                   | `not_authorized`        | 401     | no     |
| Key's request allowance used up                    | `quota_exceeded`        | 429     | **no** |
| Too many requests                                  | `rate_limited`          | 429     | yes    |
| Key lacks the scope this endpoint needs            | `insufficient_scope`    | 403     | no     |
| Workspace deleted                                  | `workspace_unavailable` | **410** | no     |
| Plan does not include the API                      | `required_plan`         | 403     | no     |
| Read-only connection tried to spend                | `read_only_connection`  | 403     | no     |
| Key check failed on our side                       | `internal_error`        | 500     | yes    |

When several things are wrong, you get them in this order: header, key (revoked, allowance, rate limit, scope), workspace, plan.

<Callout type="warn" title="Messages say riffads.com">
  Error messages say "create a new key at riffads.com". Keys are made at [app.riffads.com/api-keys](https://app.riffads.com/api-keys).
</Callout>

<Accordions type="single">
  <Accordion title="401 on a brand new key">
    Usually the header. Check for a trailing newline, stray quotes, a non-Bearer scheme, or the key in a query param.
  </Accordion>

  <Accordion title="403 insufficient_scope on POST /generations">
    The key lacks Generate. The message names the missing scope. Create a key with Generate, then revoke the old one.
  </Accordion>

  <Accordion title="404 on a path I am sure exists">
    Any unknown `/api/v1/...` path answers `404 not_found` without checking the key, so a typo never looks like a bad key. A wrong method on a real path is a plain `405`.
  </Accordion>

  <Accordion title="not_found on an id I know is real">
    Ids from another workspace answer `not_found`, same as ids that never existed. Check the id came from this key's workspace.
  </Accordion>
</Accordions>

## Rate limits [#rate-limits]

Two brakes. Either can return `429 rate_limited`.

**Per key:** 120 requests a minute. The number is copied onto the key when it is created.

**Per action:** fixed one minute windows, counted per key. Two keys never share a bucket.

| Bucket        | Per minute | Endpoints                                                                                         |
| ------------- | ---------- | ------------------------------------------------------------------------------------------------- |
| Reads         | 120        | Capabilities, actors, voices, balance, generation and batch reads, wait, templates, workflow runs |
| Estimates     | 60         | `POST /estimates`                                                                                 |
| Submits       | 20         | `POST /generations`                                                                               |
| Uploads       | 30         | `POST /uploads` and finalize                                                                      |
| Workflow runs | 20         | Both invoke routes                                                                                |

* One `wait` call costs 1 read, however long it blocks.
* No `X-RateLimit-*` headers.
* A 429 sends `Retry-After` only when the body has `retry_after_seconds`. Same number.
* `quota_exceeded` is a 429 you can't retry and sends no `Retry-After`.

All numbers: [limits](/reference/limits). Retry rules: [conventions](/api/conventions).
