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, and when to mint a narrower token in A token for a web page.
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 | 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 | 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 lists.
Example
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 | 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 | 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 | 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 | 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 lists.
Example
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. 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.devhttps://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}$.