Skip to main content

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
NameInRequiredDescription
Content-Typeheader✔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 fieldRequiredDescription
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
Using a code twice revokes every token for that integration

Requesting 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 fieldRequiredDescription
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}"
Refresh tokens rotate — you must store the new one

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"
}
FieldTypeDescription
access_tokenstringThe value you use to call bot APIs.
token_typestringAlways Bearer.
expires_innumberThe access token's remaining lifetime, in seconds. 1 hour (3600) by default.
refresh_tokenstringThe value for your next refresh. 30 days by default.
scopestringThe 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 fieldRequiredDescription
token✔Either an access token or a refresh token.
token_type_hintAccepted 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 a 200 does 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"
}
errorHTTPWhen it happensWhat to do
invalid_request400A required form field is missing (grant_type, code, redirect_uri, refresh_token, token).Fix the request.
invalid_client401Client authentication failed, client_id is malformed, or the app is suspended.Check your credentials and the app's status.
invalid_grant400The 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_type400A 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.