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.
/api/auth/oauth2/tokenThe 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.
AuthorizationheaderstringBasic base64(client_id:client_secret) — the client_secret_basic method.client_idbodystringclient_secret_post method: send client_id and client_secret as form fields instead of the header.client_secretbodystringBody — authorization code grant
grant_typebodystringrequiredauthorization_code.codebodystringrequiredredirect_uribodystringrequiredinvalid_request.code_verifierbodystringrequiredcode_challenge.resourcebodystringhttps://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_typebodystringrequiredrefresh_token.refresh_tokenbodystringrequiredscopebodystringinvalid_scope. Omit it to keep the same scopes.Example 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
{
"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
error | Typical error_description | Meaning |
|---|---|---|
invalid_request | redirect_uri mismatch | The redirect_uri differs from the one used at /authorize. |
invalid_request | PKCE is required for this client | You sent no code_verifier. |
invalid_request | code verification failed | The verifier does not hash to the challenge you sent. |
invalid_request | session no longer exists | The haunt.gg session that approved the code was signed out before you exchanged it. |
invalid_grant | invalid code | Unknown, already-used or expired code. |
invalid_grant | invalid refresh token | Expired, unknown, or already rotated. |
invalid_client | invalid client_id | The code or refresh token belongs to a different app. |
invalid_client | client secret must be provided | No client authentication was sent. |
invalid_client | client is disabled | The app was suspended by haunt.gg staff. |
invalid_scope | unable to issue scope … | The refresh request asked for a scope the token does not carry. |
unauthorized_client | client 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.