Tokens
Exchange the code you got from the DJ's consent for an access token, refresh it before
it expires, and revoke it when you need to. Every bot API call starts with a token from here.
These endpoints follow RFC 6749 (OAuth 2.0) and RFC 7009 (revocation).
Shared rules
POST $SPOON_BASE_URL/v1/oauth/token
POST $SPOON_BASE_URL/v1/oauth/revoke
| Name | In | Required | Description |
|---|---|---|---|
Content-Type | header | ✔ | application/x-www-form-urlencoded. Not JSON. |
Client authentication
There are two ways, and the Basic header wins.
Authorization: Basic base64(client_id:client_secret)
Without the header, the client_id and client_secret form fields are used instead.
With neither, you get 401 invalid_client. The client_id must be a UUID; anything
else is also 401 invalid_client.
A 401 response carries a WWW-Authenticate: Basic realm="oauth" header.
These calls use your Client Secret, so make them from your server. Calling them from a browser or a shipped mobile app exposes the secret.
Issue a token
Exchange the code from consent for a token pair.
POST /v1/oauth/token
| Form field | Required | Description |
|---|---|---|
grant_type | ✔ | authorization_code |
code | ✔ | The single-use value from consent. Valid for 60 seconds. |
redirect_uri | ✔ | Must match the one used in the consent request. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=authorization_code" \
-d "code={the code you received}" \
-d "redirect_uri={login redirect URL}"
// refresh.ts — node --experimental-strip-types refresh.ts
import { writeFile } from 'node:fs/promises';
interface Token {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface OauthError {
error: string;
error_description: string;
}
const basic = Buffer.from(
`${process.env.SPOON_CLIENT_ID}:${process.env.SPOON_CLIENT_SECRET}`,
).toString('base64');
const res = await fetch(`${process.env.SPOON_BASE_URL}/v1/oauth/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: process.env.SPOON_REFRESH_TOKEN ?? '',
}),
});
if (!res.ok) {
const { error, error_description } = (await res.json()) as OauthError;
throw new Error(`Refresh failed ${error}: ${error_description}`);
}
const token = (await res.json()) as Token;
await writeFile('token.json', JSON.stringify(token, null, 2)); // always store the new refresh_token
console.log(`scope: ${token.scope}`); // check it on every refresh
export SPOON_CLIENT_ID="{Client ID}"
export SPOON_CLIENT_SECRET="{Client Secret}"
export SPOON_REFRESH_TOKEN="{the refresh_token you received}"
node --experimental-strip-types refresh.ts
code twice revokes every token for that integrationRequesting twice with the same code is treated as a theft signal, and every token
issued for that integration is revoked. The response is the same invalid_grant as a
plain failure, so it looks identical from the outside while the consequence is far heavier.
If a network error loses the response, do not retry with the same code. Discard it
and start again from consent.
Refresh a token
Get a new pair before the access token expires.
POST /v1/oauth/token
| Form field | Required | Description |
|---|---|---|
grant_type | ✔ | refresh_token |
refresh_token | ✔ | The most recent refresh token you received. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=refresh_token" \
-d "refresh_token={the refresh_token you received}"
Refreshing revokes the old refresh token immediately and returns a new access and
refresh pair. If you do not store the new refresh_token from the response, your next
refresh fails with 400 invalid_grant and the bot dies. The refresh that succeeded
looks fine, and only the one after it breaks, which makes the cause hard to find.
The old access token is not revoked alongside it — it simply expires. So for a short window after a refresh, both access tokens work.
Check scope on every refresh. The scope in the response reflects the DJ's consent at that moment. If the DJ
narrows their consent, the next refresh picks that up automatically. Assuming you still
hold the scopes you were first issued means calling an API with a permission that is
already gone and getting a 403.
Refresh with a generous margin
Refreshing at the last second is too late if your worker paused and came back. Leave a wide margin — an hour before expiry, for example — and persist the result so it survives a restart. If you keep it only in memory, the DJ has to grant consent again every time your process restarts.
Response (issue and refresh)
Field names are snake_case.
{
"access_token": "at_3f9c1e7b8d2a4056...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_7b2e5a9c4f10d386...",
"scope": "events.chat chat.send"
}
| Field | Type | Description |
|---|---|---|
access_token | string | The value you use to call bot APIs. |
token_type | string | Always Bearer. |
expires_in | number | The access token's remaining lifetime, in seconds. 1 hour (3600) by default. |
refresh_token | string | The value for your next refresh. 30 days by default. |
scope | string | The permissions the DJ granted, separated by spaces. |
From then on, every bot API call looks like this.
Authorization: Bearer {access_token}
Revoke a token
Invalidate a token you were issued.
POST /v1/oauth/revoke
| Form field | Required | Description |
|---|---|---|
token | ✔ | Either an access token or a refresh token. |
token_type_hint | Accepted but ignored — the token string itself is inspected. |
curl -X POST "$SPOON_BASE_URL/v1/oauth/revoke" \
-u "{Client ID}:{Client Secret}" \
-d "token={the token to revoke}"
On success you get a 200 with no body.
- An unknown token, or someone else's token, also returns
200. This keeps the existence of a token from leaking (RFC 7009), so a200does not mean the token existed. - Revoking a refresh token also revokes the access token for the same integration.
Revoking is not the same as disconnecting. The DJ's consent (grant) stays in place. Only the tokens become invalid, so you can get new ones by going through the consent screen without asking for consent again.
A DJ disconnecting the integration is a different thing — then the tokens are invalid and consent is needed again.
Errors
The format differs from the other bot APIs. The bot runtime APIs return {"status", "detailCode", "message"}, while these OAuth
endpoints use the RFC 6749 §5.2 shape. The detailCode values in
Error codes do not appear here.
{
"error": "invalid_grant",
"error_description": "invalid, expired or already used code"
}
error | HTTP | When it happens | What to do |
|---|---|---|---|
invalid_request | 400 | A required form field is missing (grant_type, code, redirect_uri, refresh_token, token). | Fix the request. |
invalid_client | 401 | Client authentication failed, client_id is malformed, or the app is suspended. | Check your credentials and the app's status. |
invalid_grant | 400 | The code expired or was already used, was issued to another app, redirect_uri does not match, or the refresh token is invalid or expired. | For a code, start again from consent. If the refresh token is invalid, ask the DJ to authorize again. |
unsupported_grant_type | 400 | A value other than authorization_code or refresh_token. | Use one of the two. |
Do not branch on error_description. It is a human-readable explanation and the
wording can change. Branch on error.
invalid_scope comes from the
consent step, not from here.
Related documents
- Endpoint list — the endpoints a token lets you call.
- Get user consent — the step that gives you a
code. - Build a bot — a minimal end-to-end example.
- Choosing permissions — the values that appear in
scope.