Token

Exchange an authorization code for tokens, and refresh them later.

The back-channel half of the flow. Call it from your server with your client secret — never from a browser. It serves two grants: authorization_code to turn a fresh code into tokens, and refresh_token to get new ones without sending the user back.

POST/api/auth/oauth2/token

The body must be application/x-www-form-urlencoded. Sending JSON answers 415 Unsupported Media Type — this is the single most common integration mistake.

Authorizations

Authenticate the app with either method. If both are present, the header wins.

Authorizationheaderstring
Basic base64(client_id:client_secret) — the client_secret_basic method.
client_idbodystring
The client_secret_post method: send client_id and client_secret as form fields instead of the header.
client_secretbodystring
Your app's secret. Required for every grant — haunt.gg has no public clients.

Body — authorization code grant

grant_typebodystringrequired
authorization_code.
codebodystringrequired
The single-use code from your redirect URL. Valid for 10 minutes.
redirect_uribodystringrequired
The exact same value you sent to the authorization endpoint. A mismatch answers invalid_request.
code_verifierbodystringrequired
The PKCE verifier whose SHA-256 hash you sent as code_challenge.
resourcebodystring
Optional. Switches the access token from an opaque string to a signed JWT for that audience. Only https://haunt.gg/api/auth and https://haunt.gg/api/auth/oauth2/userinfo are accepted; anything else answers invalid_request. Most integrations should leave this out.

Body — refresh token grant

grant_typebodystringrequired
refresh_token.
refresh_tokenbodystringrequired
The refresh token from a previous response. Every use rotates it — store the new one.
scopebodystring
Optional down-scoping. Every entry must already be carried by the refresh token; asking for more answers invalid_scope. Omit it to keep the same scopes.

Example Request

Request
curl -X POST "https://haunt.gg/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "code=1a2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P" \
  -d "redirect_uri=https://example.com/auth/callback" \
  -d "code_verifier=YOUR_PKCE_VERIFIER"

Response

Response
Tokens issued, with Cache-Control: no-store. `refresh_token` is only present when the granted scopes include offline_access, and `id_token` only when they include openid. `expires_at` is a non-standard convenience field (absolute unix seconds) — `expires_in` is the one to rely on.
{
  "access_token": "aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeF",
  "token_type": "Bearer",
  "expires_in": 3600,
  "expires_at": 1767225600,
  "refresh_token": "gHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkL",
  "scope": "openid identify offline_access",
  "id_token": "eyJhbGciOiJFZERTQSIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL2hhdW50LmdnIn0.…"
}

Errors

errorTypical error_descriptionMeaning
invalid_requestredirect_uri mismatchThe redirect_uri differs from the one used at /authorize.
invalid_requestPKCE is required for this clientYou sent no code_verifier.
invalid_requestcode verification failedThe verifier does not hash to the challenge you sent.
invalid_requestsession no longer existsThe haunt.gg session that approved the code was signed out before you exchanged it.
invalid_grantinvalid codeUnknown, already-used or expired code.
invalid_grantinvalid refresh tokenExpired, unknown, or already rotated.
invalid_clientinvalid client_idThe code or refresh token belongs to a different app.
invalid_clientclient secret must be providedNo client authentication was sent.
invalid_clientclient is disabledThe app was suspended by haunt.gg staff.
invalid_scopeunable to issue scope …The refresh request asked for a scope the token does not carry.
unauthorized_clientclient is not authorized to use grant type …Only authorization_code and refresh_token are enabled for haunt.gg apps.

Refresh tokens rotate

Every successful refresh invalidates the token you presented and issues a new one, and resets the 30-day window. Always store the refresh token from the latest response.

Presenting a refresh token that was already rotated is treated as a stolen token: haunt.gg answers invalid_grant and deletes every refresh token your app holds for that user, along with the access tokens issued from them. The user has to go through the authorization flow again.

This is standard reuse detection, and the usual trigger is not an attacker but two workers refreshing the same token at the same time. Refresh in one place and share the result.

Rate limit

100 requests per minute per IP address, counted for this endpoint alone.