Token exchange reference

Last updated 2 November 2026

View .md

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

FieldInTypeRequiredDefault and limitsDescription
AuthorizationheaderBearer and the keynoA 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.
audiencebodyAudiencenoThe 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_keybodystringnoThe 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.

FieldTypeDescription
access_tokenstringThe 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.
audienceAudienceThe audience the request named.
expires_atstring (date-time)When the token expires.
expires_inintegerSeconds until the token expires: 600, or less when the key expires sooner. Exchange again before then.
client_idstring (uuid)The key's id, as tonbo api-key list shows it.
substringWhom 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_idstring (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

FieldInTypeRequiredDefault and limitsDescription
grant_typebody"urn:ietf:params:oauth:grant-type:token-exchange"yesThe RFC 8693 token exchange.
subject_tokenbodystringyesWhat 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_typebodyone of "urn:tonbo:params:oauth:token-type:api_key" or "urn:ietf:params:oauth:token-type:access_token"yesurn:tonbo:params:oauth:token-type:api_key for a key, urn:ietf:params:oauth:token-type:access_token for a Streams token.
requested_token_typebody"urn:ietf:params:oauth:token-type:access_token"noOptional; the only type a down-scope issues.
audiencebody"https://streams.tonbo.dev"noOptional; a down-scoped token is always a Streams token.
streamsbodyarray of StreamNamenoAt most 20 items.Streams the token reaches by name. Each must be one the subject reaches. Sorted and repeated names collapse.
stream_prefixesbodyarray of StreamPrefixnoAt 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.
databasesbodyarray of DatabaseNamenoAt 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_prefixesbodyarray of DatabasePrefixnoAt 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_onlybodybooleannoDefault 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_inbodyintegernoDefault 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.

FieldTypeDescription
access_tokenstringThe 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.
scopestringWhat 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_atstring (date-time)When the token expires.
expires_inintegerSeconds until the token expires, at most the requested expires_in.
client_idstring (uuid)The id of the key every token in the chain came from.
substringWhom the token acts as, the subject's sub.
account_idstring (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:

FieldTypeDescription
errorstringWhat went wrong, for a person to read. It may change; never match on it.
codestringThe refusal, where the status names one. Match on it with the status.
StatusCodeWhen, and what to do
400noneThe 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.
400invalid_requestA 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.
400unsupported_token_typesubject_token_type or requested_token_type is not one of its values. Use the values a down-scope lists.
400invalid_targetA down-scope's audience is not https://streams.tonbo.dev. Leave it out.
400invalid_narrowingA 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.
400invalid_lifetimeexpires_in is not a whole number from 60 to 600.
400subject_token_expiringThe subject has less than 60 seconds left. Exchange the key again and down-scope the new token.
401noneNo 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.
401invalid_subject_tokenA 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.
403noneThe subject of a down-scope cannot mint the token it asks for.
403subject_not_down_scopableThe 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.
403narrowing_exceeds_subjectThe request names a stream, database or prefix the subject does not reach. Nothing is trimmed to fit: ask only for what the subject reaches.
403read_only_subjectread_only is false and the subject is read-only. Leave read_only out, or use a writable subject.
415noneA down-scope sent as application/x-www-form-urlencoded, as RFC 8693 clients often send it. Send the same fields as JSON.
429nonePast 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.
429rate_limitedWait Retry-After seconds, then send the request again.
500noneThe 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 perLimitWhat counts
key600 in 60 secondsTokens 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.
address3000 in 60 secondsRequests 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}$.