The flow at a glance
Why PKCE?
Without PKCE, anyone who intercepts the authorization code (browser history, server logs, a malicious app handling your custom URL scheme) can exchange it for tokens. PKCE binds the code to a secret your client generated before the flow started.- Your client generates
code_verifier— a random 43-128 character string. - Your client sends
code_challenge = BASE64URL(SHA256(code_verifier))to/authorize. - Flexslot stores the challenge against the issued code.
- When your client redeems the code at
/token, it sends the originalcode_verifier. - Flexslot recomputes
SHA256(code_verifier)and compares to the stored challenge.
invalid_grant and the code is invalidated. See RFC 7636 for the spec.
Generating PKCE values
Step 1 — Authorization request
Send the user’s browser to the consent screen on the Flexslot web app:This is
flexslot.gg, not api.flexslot.gg. The authorization endpoint is a web page the user’s browser opens — pointing the browser at the API host returns 401 FLEXSLOT_AUTHENTICATION_REQUIRED. Only the token, introspection, and revocation endpoints live on api.flexslot.gg.Required query parameters
| Parameter | Value | Description |
|---|---|---|
response_type | code | The only supported value. |
client_id | flx_client_… | The client ID from the partner admin. |
redirect_uri | Full URL | Must exactly match a URI registered for this client. |
scope | Space-separated | E.g. decks:read sideboards:write. See Scopes. |
state | Random string | CSRF token. You’ll validate it on the callback. |
code_challenge | Base64url SHA-256 | From the PKCE generator above. |
code_challenge_method | S256 | Required. plain is forbidden. |
Example
All query parameter values must be percent-encoded. Use your language’s URL builder — don’t hand-roll the string.
Step 2 — User consents
Flexslot renders the consent screen, showing the user:- Your application’s name and logo (from the partner admin)
- The scopes you requested, in human-readable form
- A clear Allow and Deny option
code. If they click Deny, you get redirected with error=access_denied.
Step 3 — Callback to your redirect_uri
What you must verify
state matches
Compare to the value you stored when starting the flow. If it doesn’t match, abort — this is a CSRF attempt.
iss equals https://api.flexslot.gg
RFC 9207 mix-up defense. If a different
iss shows up, you’ve been redirected to the wrong AS.No error parameter
If
error=... is present, the flow failed. See errors.Step 4 — Token exchange
Body parameters
| Parameter | Required? | Description |
|---|---|---|
grant_type | Yes | authorization_code |
code | Yes | The code from the callback |
redirect_uri | Yes | Same URI you used in step 1 |
code_verifier | Yes | The PKCE verifier you generated in step 1 |
client_id | If public client | The client ID (confidential clients send it via Basic auth) |
Authentication
| Client type | How to authenticate |
|---|---|
| Confidential (has client_secret) | Authorization: Basic <base64(client_id:client_secret)> |
| Public (no secret) | Include client_id in the body |
Example request
Success response
200 OK, Content-Type: application/json, Cache-Control: no-store:
| Field | Description |
|---|---|
access_token | Send as Authorization: Bearer <token> on API requests. |
token_type | Always Bearer (or DPoP if you opted in — see DPoP). |
expires_in | Seconds until the access token expires. Currently 3600. |
refresh_token | Use to mint new access tokens. Rotates on every use. |
scope | The scopes actually granted. May be narrower than what you requested. |
Error response
400 Bad Request (or 401 for invalid_client), Content-Type: application/json:
Step 5 — Call the API
The resource server is game-scoped: deck, guide, and card endpoints live under/api/public/v1/games/{game}/..., where {game} is the game’s exact slug (for Magic, magic-the-gathering). There is no bare /api/public/v1/decks/ — it returns 404. The slug is the game field on any deck or guide you read back.
{ "error", "message", "details" }) on auth/scope failures — it does not emit an RFC 6750 WWW-Authenticate: Bearer challenge.
A token lacking the required scope returns 403 Forbidden:
401 Unauthorized with the matching error code:
WWW-Authenticate header is the DPoP lane (Authorization: DPoP …), which on proof failure returns 401 with WWW-Authenticate: DPoP error="invalid_dpop_proof".
Step 6 — Refresh
When the access token expires (or proactively, ~5 minutes before it does), exchange the refresh token for a new pair:Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
invalid_grant on first exchange | Code already used, or expired (10 min) | Restart the flow |
invalid_grant with “code_verifier mismatch” | Verifier doesn’t match the challenge | Make sure you stored the verifier from the same session |
invalid_grant with “redirect_uri does not match” | Different URI in step 1 vs step 4 | They must be byte-for-byte identical |
invalid_client (401) | Wrong client_id, wrong secret, or wrong auth method | Check the partner admin |
unsupported_grant_type | You sent something other than authorization_code or refresh_token | Those are the only two grants supported |
Browser stuck on /authorize | redirect_uri isn’t registered for this client | Add it in the partner admin |
What to read next
Code samples
Full Express, Flask, and curl implementations
Security
PKCE, state, redirect URIs in depth
Errors
Every error code explained
DPoP
Sender-constrained tokens