> Fetch the index of every Tonbo docs page as Markdown at https://tonbo.dev/llms.txt

# Token exchange reference

Last updated 2 November 2026

`POST https://tonbo.dev/api/iam/token` exchanges a Tonbo API key for a short-lived access token, and mints from a key or a Streams token a narrower Streams token for one client, such as a web page.

This page is generated from the endpoint’s OpenAPI document. When to exchange a key is in [The token exchange](https://tonbo.dev/docs/platform/api-keys.md#exchange), and when to mint a narrower token in [A token for a web page](https://tonbo.dev/docs/streams/access.md#browser).

- [Endpoint](#endpoint)
- [Exchange a key](#exchange)
- [Down-scope a token](#down-scope)
- [Refusals](#refusals)
- [Rate limits](#limits)
- [Types](#types)

## Endpoint

`POST https://tonbo.dev/api/iam/token`, with `Content-Type: application/json`.

The body decides what the request does. A body without `grant_type` exchanges the key in `Authorization` (or in `api_key`) for an access token of the `audience` it names. A body whose `grant_type` is `urn:ietf:params:oauth:grant-type:token-exchange` and that names what the new token reaches is a down-scope (RFC 8693): it mints a Streams token narrower than its subject.

Every request is stateless and mints a new token: nothing is stored, no `Idempotency-Key` is read, and sending a request again is safe and mints another token. Every answer, a token or a refusal, carries `Cache-Control: no-store`. A refusal is `{"error": …}`, with a `code` where the refusal names one; match on the status and the code, never on the message.

Answers may gain members; clients ignore members they do not know.

## Exchange a key

Exchanges a key for an access token of one audience that acts as the key's member, you or an Agent, with the member's current role and the key's narrowing, in the key's account only. Use it for a key that acts as an Agent, which Streams and the Management API never take directly, or when the credential your program holds should expire on its own. A key that acts as you is also accepted directly. Revoking the key stops its exchanges at once; Streams refuses its tokens shortly after, and every token expires within 600 seconds anyway.

### Request

| Field | In | Type | Required | Default and limits | Description |
| --- | --- | --- | --- | --- | --- |
| `Authorization` | header | `Bearer` and the key | no |  | A Tonbo API key (`tbo_…`), personal or an Agent's, as `Authorization: Bearer`. Only a key exchange reads it; a down-scope carries its subject in the body and reads no header. |
| `audience` | body | [Audience](#schema-audience) | no |  | The API the token is for. Always send it: without it the token is for another Tonbo API, which Streams and the Management API refuse. |
| `api_key` | body | string | no |  | The key, in place of the `Authorization` header. The header wins when both are sent. |

### Answer

`200`, `application/json`, `Cache-Control: no-store`. An exchange's token and what it acts as. It may carry other members kept for older clients, such as `api_key_id`; ignore them.

| Field | Type | Description |
| --- | --- | --- |
| `access_token` | string | The token, a JWT. Send it as `Authorization: Bearer` to the audience's API, and read its lifetime from `expires_in`, not from the token. |
| `token_type` | `"Bearer"` | `Bearer`. |
| `audience` | [Audience](#schema-audience) | The audience the request named. |
| `expires_at` | string (date-time) | When the token expires. |
| `expires_in` | integer | Seconds until the token expires: 600, or less when the key expires sooner. Exchange again before then. |
| `client_id` | string (uuid) | The key's id, as `tonbo api-key list` shows it. |
| `sub` | string | Whom the token acts as: your user id for a key that acts as you, or `agent.` and the Agent's id for a key that acts as an Agent. |
| `account_id` | string (uuid) | The key's account, the one account the token reaches. It is the segment before the name in each of the account's database URLs. |

A refusal answers as [Refusals](#refusals) lists.

### Example

```shell
curl -sS -X POST "https://tonbo.dev/api/iam/token" \
  -H "Authorization: Bearer $TONBO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"audience":"https://streams.tonbo.dev"}'
```

## Down-scope a token

Mints, from a key or a Streams token, a Streams token that reaches only the streams and databases it names, read-only unless it asks otherwise, for one client such as a web page. The new token acts as its subject's member, in the subject's account, and is never wider than its subject: a request for anything the subject does not reach is refused, never trimmed. A down-scoped token may be down-scoped again, each step only narrowing. No name needs to exist yet; Streams answers 404 for a missing one within reach. Revoking the key the chain came from stops every token in it. A request is checked in this order: its fields, then the subject, then the names, then the lifetime.

### Request

| Field | In | Type | Required | Default and limits | Description |
| --- | --- | --- | --- | --- | --- |
| `grant_type` | body | `"urn:ietf:params:oauth:grant-type:token-exchange"` | yes |  | The RFC 8693 token exchange. |
| `subject_token` | body | string | yes |  | What the token is minted from, as it is: a Tonbo API key (`tbo_…`), personal or an Agent's, or a Streams token that an exchange or a down-scope returned. A key or token narrowed in a way that names cannot narrow is refused. |
| `subject_token_type` | body | one of `"urn:tonbo:params:oauth:token-type:api_key"` or `"urn:ietf:params:oauth:token-type:access_token"` | yes |  | `urn:tonbo:params:oauth:token-type:api_key` for a key, `urn:ietf:params:oauth:token-type:access_token` for a Streams token. |
| `requested_token_type` | body | `"urn:ietf:params:oauth:token-type:access_token"` | no |  | Optional; the only type a down-scope issues. |
| `audience` | body | `"https://streams.tonbo.dev"` | no |  | Optional; a down-scoped token is always a Streams token. |
| `streams` | body | array of [StreamName](#schema-stream-name) | no | At most 20 items. | Streams the token reaches by name. Each must be one the subject reaches. Sorted and repeated names collapse. |
| `stream_prefixes` | body | array of [StreamPrefix](#schema-stream-prefix) | no | At most 20 items. | Streams the token reaches by the start of their name. Each must start with one of the subject's stream prefixes, or the subject must reach every stream. |
| `databases` | body | array of [DatabaseName](#schema-database-name) | no | At most 20 items. | Databases the token reaches by name. Each must be one the subject reaches. A stream name never reaches a database, nor a database name a stream. |
| `database_prefixes` | body | array of [DatabasePrefix](#schema-database-prefix) | no | At most 20 items. | Databases the token reaches by the start of their name. Each must start with one of the subject's database prefixes, or the subject must reach every database. At least one of the four name fields is not empty. |
| `read_only` | body | boolean | no | Default `true`. | `true` lets the token read, tail and read snapshots only. `false` gives it the subject's writes on what it reaches, and is refused when the subject is read-only. |
| `expires_in` | body | integer | no | Default `600`. 60 to 600. | The token's lifetime in whole seconds. It is cut to what the subject has left, so the token never outlives its subject; a subject with less than 60 seconds left is refused. |

### Answer

`200`, `application/json`, `Cache-Control: no-store`. The narrower Streams token and what it reaches.

| Field | Type | Description |
| --- | --- | --- |
| `access_token` | string | The token, a JWT. Hand it to the one client it is for, which sends it as `Authorization: Bearer` to the stream or database. |
| `issued_token_type` | `"urn:ietf:params:oauth:token-type:access_token"` | `urn:ietf:params:oauth:token-type:access_token`. |
| `token_type` | `"Bearer"` | `Bearer`. |
| `audience` | `"https://streams.tonbo.dev"` | `https://streams.tonbo.dev`. |
| `scope` | string | What the token reaches, space-delimited: `streams.narrow.read-only` when it is read-only, then `streams.narrow.database:<name>`, `streams.narrow.database-prefix:<prefix>`, `streams.narrow.stream:<name>` and `streams.narrow.stream-prefix:<prefix>`, each sorted. |
| `expires_at` | string (date-time) | When the token expires. |
| `expires_in` | integer | Seconds until the token expires, at most the requested `expires_in`. |
| `client_id` | string (uuid) | The id of the key every token in the chain came from. |
| `sub` | string | Whom the token acts as, the subject's `sub`. |
| `account_id` | string (uuid) | The one account the token reaches, the subject's. |

A refusal answers as [Refusals](#refusals) lists.

### Example

```shell
PAGE_TOKEN=$(jq -n --arg key "$TONBO_API_KEY" '{
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: $key,
    subject_token_type: "urn:tonbo:params:oauth:token-type:api_key",
    streams: ["events"],
    read_only: true,
    expires_in: 300
  }' | curl -sS -X POST "https://tonbo.dev/api/iam/token" \
  -H "Content-Type: application/json" -d @- | jq -r .access_token)
```

## Refusals

A refusal is JSON with `Cache-Control: no-store`:

| Field | Type | Description |
| --- | --- | --- |
| `error` | string | What went wrong, for a person to read. It may change; never match on it. |
| `code` | string | The refusal, where the status names one. Match on it with the status. |

| Status | Code | When, and what to do |
| --- | --- | --- |
| `400` | none | The request is malformed. An exchange answers it with no code when its `audience` is not an API this endpoint issues tokens for: send `https://streams.tonbo.dev` or `https://api.tonbo.dev`. A down-scope answers it with a code. |
| `400` | `invalid_request` | A down-scope with a field it does not name, without a required field, or with a field of the wrong type. Send only the fields a down-scope lists, each of its type. |
| `400` | `unsupported_token_type` | `subject_token_type` or `requested_token_type` is not one of its values. Use the values a down-scope lists. |
| `400` | `invalid_target` | A down-scope's `audience` is not `https://streams.tonbo.dev`. Leave it out. |
| `400` | `invalid_narrowing` | A name or prefix breaks its rule, a list holds more than 20, or the request names nothing. Fix the name, split the token, or name at least one stream or database. |
| `400` | `invalid_lifetime` | `expires_in` is not a whole number from 60 to 600. |
| `400` | `subject_token_expiring` | The subject has less than 60 seconds left. Exchange the key again and down-scope the new token. |
| `401` | none | No usable key or subject token. An exchange answers it with no code when the request carries no key (`Missing API key.`) or one that is unknown, malformed, revoked or expired (`Invalid API key.`): sending it again does not help, so use a live key. A down-scope answers it with a code. |
| `401` | `invalid_subject_token` | A down-scope's key is unknown, revoked or expired, or its token does not verify for Streams or has expired. Use a live key, or a fresh token from it. |
| `403` | none | The subject of a down-scope cannot mint the token it asks for. |
| `403` | `subject_not_down_scopable` | The subject is neither a key nor a member's Streams token, or it is narrowed in a way that names cannot narrow. Down-scope from a key, or from a token exchanged from one. |
| `403` | `narrowing_exceeds_subject` | The request names a stream, database or prefix the subject does not reach. Nothing is trimmed to fit: ask only for what the subject reaches. |
| `403` | `read_only_subject` | `read_only` is `false` and the subject is read-only. Leave `read_only` out, or use a writable subject. |
| `415` | none | A down-scope sent as `application/x-www-form-urlencoded`, as RFC 8693 clients often send it. Send the same fields as JSON. |
| `429` | none | Past one of the endpoint's rate limits (`x-rate-limits`). It guards against abuse and is no quota: wait `Retry-After` seconds and send the request again. A program that sends a key that acts as you directly makes no exchange at all. It carries `Retry-After: 60`. |
| `429` | `rate_limited` | Wait `Retry-After` seconds, then send the request again. |
| `500` | none | The token could not be issued (`Could not issue access token.`). Send the request again after a short backoff. |

## Rate limits

Past either limit, the endpoint answers [`429` `rate_limited`](#refusal-rate-limited). The counts are kept at each of Tonbo’s edge locations, so they are approximate.

| Counted per | Limit | What counts |
| --- | --- | --- |
| key | 600 in 60 seconds | Tokens for each key in a minute: its exchanges and the down-scopes whose subject is the key or a token exchanged from it, together. Counted once the key or token is verified. |
| address | 3000 in 60 seconds | Requests from each client address in a minute, counted before the key or token is read, so several keys behind one address are not cut off by it first. |

## Types

### Audience

The API a token is for, by its name, which is not the origin a request goes to. `https://streams.tonbo.dev` is Tonbo Streams: its token works at the Streams origin and the database origin of every region. `https://api.tonbo.dev` is the Management API at `https://api.tonbo.dev/v1`.

- `https://streams.tonbo.dev`
- `https://api.tonbo.dev`

### StreamName

A stream by the name `tonbo stream list` shows, without `s.`: 1 to 83 lowercase letters, digits, dots, underscores and hyphens, starting and ending with a letter or digit, without `..`, not starting with `m.`, `vol.`, `s.`, `session.` or `proc.`, and not `streams` or `new`.

A string. 1 to 83 characters. Matches `^(?!m\.)(?!vol\.)(?!s\.)(?!session\.)(?!proc\.)(?!(?:streams|new)$)(?!.*\.\.)[a-z0-9](?:[a-z0-9._-]{0,81}[a-z0-9])?$`.

### StreamPrefix

The start of stream names: 1 to 83 lowercase letters, digits, dots, underscores and hyphens, starting with a letter or digit, not starting with `m.`, `vol.`, `s.`, `session.` or `proc.`. It reaches every stream whose name starts with it.

A string. 1 to 83 characters. Matches `^(?!m\.)(?!vol\.)(?!s\.)(?!session\.)(?!proc\.)[a-z0-9][a-z0-9._-]{0,84}$`.

### DatabaseName

A database by the name `tonbo database list` shows: 1 to 85 lowercase letters, digits, dots, underscores and hyphens, starting and ending with a letter or digit, without `..`, not starting with `m.`, `vol.`, `s.`, `session.` or `proc.`, and not `streams` or `new`.

A string. 1 to 85 characters. Matches `^(?!m\.)(?!vol\.)(?!s\.)(?!session\.)(?!proc\.)(?!(?:streams|new)$)(?!.*\.\.)[a-z0-9](?:[a-z0-9._-]{0,83}[a-z0-9])?$`.

### DatabasePrefix

The start of database names: 1 to 85 lowercase letters, digits, dots, underscores and hyphens, starting with a letter or digit, not starting with `m.`, `vol.`, `s.`, `session.` or `proc.`. It reaches every database whose name starts with it.

A string. 1 to 85 characters. Matches `^(?!m\.)(?!vol\.)(?!s\.)(?!session\.)(?!proc\.)[a-z0-9][a-z0-9._-]{0,84}$`.
