Docs Releases Log in

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.