Skip to main content
The Authorization Code Grant with PKCE is the only user-facing grant Flexslot supports. This page is the complete reference: every parameter, every header, every status code.

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 original code_verifier.
  • Flexslot recomputes SHA256(code_verifier) and compares to the stored challenge.
If the values don’t match, you get invalid_grant and the code is invalidated. See RFC 7636 for the spec.
code_challenge_method=plain is forbidden at Flexslot. Use S256 only. RFC 9700 deprecates plain because it provides no actual protection.

Generating PKCE values

import crypto from 'node:crypto'

function base64url(buffer) {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

export function generatePkce() {
  const verifier = base64url(crypto.randomBytes(32))
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())
  return { verifier, challenge, method: 'S256' }
}
import base64
import hashlib
import secrets

def generate_pkce() -> dict[str, str]:
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode('ascii')
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode('ascii')).digest()
    ).rstrip(b'=').decode('ascii')
    return {"verifier": verifier, "challenge": challenge, "method": "S256"}
# verifier: 43 chars base64url, no padding
verifier=$(openssl rand 32 | base64 | tr '+/' '-_' | tr -d '=\n')

# challenge = BASE64URL(SHA256(verifier))
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary \
  | base64 | tr '+/' '-_' | tr -d '=\n')

echo "verifier=$verifier"
echo "challenge=$challenge"

Step 1 — Authorization request

Send the user’s browser to the consent screen on the Flexslot web app:
GET https://flexslot.gg/oauth/authorize
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

ParameterValueDescription
response_typecodeThe only supported value.
client_idflx_client_…The client ID from the partner admin.
redirect_uriFull URLMust exactly match a URI registered for this client.
scopeSpace-separatedE.g. decks:read sideboards:write. See Scopes.
stateRandom stringCSRF token. You’ll validate it on the callback.
code_challengeBase64url SHA-256From the PKCE generator above.
code_challenge_methodS256Required. plain is forbidden.

Example

https://flexslot.gg/oauth/authorize?
  response_type=code&
  client_id=flx_client_01HX2K…&
  redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback&
  scope=decks%3Aread%20sideboards%3Aread&
  state=4f8a9c1e2b3d…&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256
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
If the user clicks Allow, you get redirected with a code. If they click Deny, you get redirected with error=access_denied.

Step 3 — Callback to your redirect_uri

GET https://your.app/oauth/callback?
  code=01HX2K6F8N9P…&
  state=4f8a9c1e2b3d…&
  iss=https://api.flexslot.gg

What you must verify

1

state matches

Compare to the value you stored when starting the flow. If it doesn’t match, abort — this is a CSRF attempt.
2

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.
3

No error parameter

If error=... is present, the flow failed. See errors.

Step 4 — Token exchange

POST https://api.flexslot.gg/api/public/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

Body parameters

ParameterRequired?Description
grant_typeYesauthorization_code
codeYesThe code from the callback
redirect_uriYesSame URI you used in step 1
code_verifierYesThe PKCE verifier you generated in step 1
client_idIf public clientThe client ID (confidential clients send it via Basic auth)

Authentication

Client typeHow to authenticate
Confidential (has client_secret)Authorization: Basic <base64(client_id:client_secret)>
Public (no secret)Include client_id in the body

Example request

POST /api/public/v1/oauth/token HTTP/1.1
Host: api.flexslot.gg
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y2xpXzAxSFgyS…OnNlY3JldA==

grant_type=authorization_code
&code=01HX2K6F8N9P…
&redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Success response

200 OK, Content-Type: application/json, Cache-Control: no-store:
{
  "access_token": "flx_at_01HX2K6F8N9PRSTUVWXYZA…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "flx_rt_01HX2K6F8N9PRSTUVWXYZB…",
  "scope": "decks:read sideboards:read"
}
FieldDescription
access_tokenSend as Authorization: Bearer <token> on API requests.
token_typeAlways Bearer (or DPoP if you opted in — see DPoP).
expires_inSeconds until the access token expires. Currently 3600.
refresh_tokenUse to mint new access tokens. Rotates on every use.
scopeThe scopes actually granted. May be narrower than what you requested.

Error response

400 Bad Request (or 401 for invalid_client), Content-Type: application/json:
{
  "error": "invalid_grant",
  "error_description": "The authorization code is expired or has already been used."
}
See Errors for the full catalog.

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.
GET /api/public/v1/games/magic-the-gathering/decks HTTP/1.1
Host: api.flexslot.gg
Authorization: Bearer flx_at_01HX2K6F8N9PRSTUVWXYZA…
The resource server returns the canonical Flexslot JSON envelope ({ "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:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
  "error": "FLEXSLOT_INSUFFICIENT_SCOPE",
  "message": "Caller does not have the required scope(s).",
  "details": { "required": ["decks:write"], "granted": ["decks:read"] }
}
An expired, revoked, or unknown token returns 401 Unauthorized with the matching error code:
{ "error": "FLEXSLOT_OAUTH2_TOKEN_EXPIRED", "message": "OAuth2 access token has expired.", "details": {} }
{ "error": "FLEXSLOT_OAUTH2_TOKEN_REVOKED", "message": "OAuth2 access token has been revoked.", "details": {} }
{ "error": "FLEXSLOT_AUTHENTICATION_FAILED", "message": "Authentication failed.", "details": {} }
The only resource-server response carrying a 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:
POST /api/public/v1/oauth/token HTTP/1.1
Host: api.flexslot.gg
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y2xpXzAxSFgyS…OnNlY3JldA==

grant_type=refresh_token
&refresh_token=flx_rt_01HX2K6F8N9PRSTUVWXYZB…
The response shape is identical to the initial exchange — and includes a new refresh token. Store the new one. The old one is now invalid.
Refresh tokens rotate. If you replay a refresh token after it’s been used, Flexslot revokes the entire grant. The user will have to re-consent. See Security → Refresh token rotation.

Common gotchas

SymptomCauseFix
invalid_grant on first exchangeCode already used, or expired (10 min)Restart the flow
invalid_grant with “code_verifier mismatch”Verifier doesn’t match the challengeMake sure you stored the verifier from the same session
invalid_grant with “redirect_uri does not match”Different URI in step 1 vs step 4They must be byte-for-byte identical
invalid_client (401)Wrong client_id, wrong secret, or wrong auth methodCheck the partner admin
unsupported_grant_typeYou sent something other than authorization_code or refresh_tokenThose are the only two grants supported
Browser stuck on /authorizeredirect_uri isn’t registered for this clientAdd it in the partner admin

Code samples

Full Express, Flask, and curl implementations

Security

PKCE, state, redirect URIs in depth

Errors

Every error code explained

DPoP

Sender-constrained tokens