Platform setup
The standing contract between your platform and the engine: how server-to-server calls authenticate, the one response envelope every endpoint uses, and the two endpoints you must host.
Service auth (server ↔ server)
# All REST calls are server-to-server. Base path: /v1
# Two-layer auth, both directions:
# 1. IP whitelist (private network / VPC)
# 2. Header X-Service-Auth: <secret>
# End-user socket clients do NOT use this — they use a session token from
# POST /v1/sessions, passed in the WebSocket subprotocol (next chapter).The IP whitelist authenticates the network; the static secret authenticates the caller — cloud egress IPs can change under autoscaling, so both layers exist. The same pair protects both directions: your calls into /v1, and the engine's calls to your webhook and user-data endpoint.
The response envelope
Every JSON response from the engine uses the same envelope — { success, response, responseCode, data, error } — so your success and failure paths parse identically. All examples in these docs show the complete envelope.
# EVERY JSON response from the engine is wrapped in one envelope, so success
# and failure parse the same way. responseCode mirrors the HTTP status.
# success — the endpoint's payload is in "data"
{
"success": true,
"response": "success",
"responseCode": 200,
"data": { /* the per-endpoint object */ }
}
# error — "data" is null and "error" carries the detail
{
"success": false,
"response": "error",
"responseCode": 409,
"data": null,
"error": { "errorCode": "#409", "errorMessage": "user is already in an active game" }
}
# Actions with no payload return 200 with "data": null.The two endpoints you host
The engine calls you in exactly two places. Everything else in the integration is you calling the engine.
POST your webhook — callbacks in
One endpoint receives all six event types. The engine retries with backoff until you acknowledge; Callbacks & reliability covers each event in depth.
// The webhook — reply 2xx once processed, 409 for a duplicate eventId
// (the engine treats 409 exactly like 2xx: delivered, stop retrying).
app.post("/callbacks", (req, res) => {
const ev = req.body;
if (seen(ev.eventId)) return res.sendStatus(200); // idempotent — already processed
switch (ev.type) {
case "match_found": notifyPlayers(ev); break; // notification — the engine already pushed the game
case "match_aborted": requeueOrForfeit(ev); break;
case "game_result": applyRatings(ev); break; // persist + clamp to floor
case "tournament_complete": saveStandings(ev); break;
case "fairplay_flag": reviewSignal(ev); break;
}
markSeen(ev.eventId);
res.sendStatus(200);
});GET <platform>/users/{userId} — ratings out
Called by the engine at enqueue (matchmaking), server-to-server. This is how a rating reaches the engine without the client ever sending one — a client that could state its own rating could farm weak pairings.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
data.user_id | string | yes | — | the id echoed back (opaque to the engine) |
data.username | string | — | omitted | display name — the engine puts it on each match_found / game_state seat so the client can render the opponent by name; no canonical copy is kept |
data.avatar | string | — | omitted | avatar URL passthrough |
data.title | string | — | omitted | chess title passthrough (FM · IM · GM …) |
data.ratings | object | yes | — | map keyed "<variant>:<speed>" → a rating block (fields below). Return EVERY category; omit ones never played |
Each value under ratings is a Glicko-2 triple plus optional idle/floor hints:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
r | number | yes | — | current rating, any positive number (e.g. 1512.3) |
rd | number | yes | — | rating deviation (uncertainty) |
vol | number | yes | — | Glicko-2 volatility |
lastGameAt | number | — | omitted | unix ms of the last game — drives idle RD inflation; omit if unknown |
floor | number | — | omitted | peak-linked lower bound; the engine clamps the resulting rating up to it |
GET <platform>/users/user_123
X-Service-Auth: <secret> // engine → platform, server-to-server
// The engine sends NO query parameters — return EVERY rating category the
// player has and the engine selects the one for the pool being joined.
// You host this endpoint, so it uses YOUR response envelope: the payload sits
// under "data" (snake_case), and the engine unwraps it.
→ 200
{
"success": true,
"response_code": 200,
"message": "User found.",
"data": {
"user_id": "user_123",
"username": "magnus_fan", // optional — display only
"title": "FM", // optional — passthrough (FM · IM · GM …)
"ratings": {
"standard:bullet": { "r": 1489.2, "rd": 92.0, "vol": 0.0601, "lastGameAt": 1719820000000, "floor": 1420 },
"standard:blitz": { "r": 1512.3, "rd": 84.1, "vol": 0.0598, "lastGameAt": 1719828000000, "floor": 1400 },
"standard:rapid": { "r": 1533.9, "rd": 70.4, "vol": 0.0591 }
}
},
"errors": null
}
// The engine reads "data" and ignores success/response_code/message/errors.
// The identity key is "user_id" (snake_case). Keys are "<variant>:<speed>".
// Omit a category the player has never played — the engine seeds it
// provisionally rather than trusting an invented 1500.
// If this endpoint is unreachable within ~2–3s the enqueue is REJECTED
// (error code platform_unavailable) — there is deliberately no client fallback.⚠ Rated matchmaking hard-depends on this endpoint. The engine caches the pulled rating per user on the session (one call covers queue/cancel loops and category switches) and recomputes Glicko-2 itself after each game, with a short TTL to pick up manual adjustments. Tournament join does not use it — the rating rides in the join request body instead, because that call is already server-to-server.
Checklist
- Host one webhook endpoint (e.g. POST /callbacks) reachable by the engine; reply 2xx once processed.
- Deduplicate every callback by eventId (idempotent) — already-processed events still get a 2xx (or 409).
- Mint a session token at login (POST /v1/sessions) and hand it to that user's client — one socket per login session.
- Expose GET /users/{userId} returning user detail + EVERY rating category, keyed "<variant>:<speed>".
- Persist the new rating from game_result and clamp to the floor (track the peak per category) — the callback states its own variant / timeControl / speed.
- Run a periodic reconciliation job: GET /v1/matches/results?since=<cursor>.
- Provide an IP whitelist + shared secret; send the X-Service-Auth header on every REST call.
What can go wrong
| Symptom | Cause & fix |
|---|---|
| 401 / 403 from the engine | Missing or wrong X-Service-Auth header, or your server's IP is not whitelisted. POST /v1/sessions additionally fails closed: it refuses to serve at all while the engine's secret is unconfigured. |
| Rated enqueue rejected with platform_unavailable | Your /users/{userId} endpoint was unreachable or slower than ~2–3s. The engine never falls back to a client-supplied rating — fix availability, don't work around it. |
| Engine retries the same callback forever | Your webhook is not replying 2xx (or replies 409 only for real duplicates). Reply 2xx after processing — even for events you choose to ignore. |
| A player's blitz rating changed after a rapid game | You filed the rating into the wrong category. Always write to ratings[variant + ":" + speed] using the fields ON the game_result payload — never infer the category. |