API
Authentication
An API key is never sent as it is. A client proves it holds the key by signing a one-time challenge, and gets a short-lived bearer token back. The token goes on every other call.
The handshake
1. Ask for a challenge
POST /api/auth/jwt/challenge/
X-API-Key: <key prefix>
{
"request_id": "Jm2x_Qe7-Rk9TzLb4VnWc8YpHs3dFa6G",
"nonce": "b7Kd-P2qWz9_XmRt4LcVn8YsHe3JfGa6Uo1ZiBkE5wN",
"expires_at": "2026-10-18T12:01:00+00:00",
"algorithm": "hmac-sha256",
"message_format": "{request_id}.{nonce}.{unix_ts}.{permissions}",
"permissions_format": "requested permissions sorted and joined with '|', or empty for all"
}
2. Sign the message
Build the message from the challenge, the current Unix time in seconds, and the permissions you want the token to carry:
<request_id>.<nonce>.<unix_ts>.<permissions>
<permissions> is the permission strings you are asking for, sorted and
joined with |. Leave it empty to ask for everything the key holds; most
clients do.
Sign it according to algorithm:
hmac-sha256: HMAC-SHA256 of the message, keyed with the key's secret (both as UTF-8).ed25519: an Ed25519 signature of the message with your private key.
Encode the result as URL-safe base64 without = padding.
3. Redeem it
POST /api/auth/jwt/redeem/
X-API-Key: <key prefix>
Content-Type: application/json
{"request_id": "...", "challenge": "<signature>", "unix_ts": 1790000000}
Add "permissions": [...] with the same strings you signed if you asked for
some. The answer is the token:
{
"token": "eyJhbGciOi...",
"expires_at": 1790003600,
"permissions": ["usr:4:gm:aX9k2Qp:game_client"],
"declined": []
}
declined lists anything you asked for that the key does not hold. The token
carries the rest.
An example in Python
import base64, hashlib, hmac, time, requests
BASE = "https://scoreboat.example.com"
PREFIX, SECRET = "your-key-prefix", "your-key-secret"
challenge = requests.post(f"{BASE}/api/auth/jwt/challenge/", headers={"X-API-Key": PREFIX}).json()
unix_ts = int(time.time())
message = f"{challenge['request_id']}.{challenge['nonce']}.{unix_ts}."
signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).digest()
proof = base64.urlsafe_b64encode(signature).decode().rstrip("=")
token = requests.post(
f"{BASE}/api/auth/jwt/redeem/",
headers={"X-API-Key": PREFIX},
json={"request_id": challenge["request_id"], "challenge": proof, "unix_ts": unix_ts},
).json()["token"]
scores = requests.get(f"{BASE}/api/lb/<leaderboard_id>/score/",
headers={"Authorization": f"Bearer {token}"}).json()
If you are writing a client library, the test vectors on Releases hold fixed inputs and the exact messages and signatures they must produce, for both kinds of key.
Lifetimes
| What | Lasts |
|---|---|
| A challenge, from asking to redeeming it | 1 minute |
| How far the signed timestamp may be from the server clock | 60 seconds |
| A bearer token, with or without a player | 1 hour |
| A player's session (how long a refresh token works) | 30 days |
| A sign-in ticket from your server | 2 minutes |
| A code mailed to a player | 15 minutes |
A challenge can be redeemed once. Keep your clock right: a timestamp too far from the server's is refused.
When a token is close to expiring, run the handshake again. The GameMaker library does this by itself.
Signing in a player
The token above speaks for your key. For calls made as a player, such as
submitting their score, sign them in with one of the
player endpoints: as a guest, with a password,
or with a ticket from your own server. Each answers with a new token: the
same key and permissions, with the player added. Use it in place of the old one.
A player's sign-in also answers with a refresh_token. When the player's
token runs out, trade it at POST /api/gm/<game_id>/player/refresh/ for a new
one, without asking the player again. Keep the refresh token somewhere safe on
the device; it works until the session ends or the player is signed out.