Authentication

The External S2S channel has two auth layers:

  1. S2S HMAC (every request) — proves the request really comes from your backend, via X-Api-Key + a signature
  2. Member Bearer (member-scoped endpoints only) — proves which member session it is, via Authorization: Bearer

Layer 1 — S2S HMAC

The 4 headers, every request

HeaderValue
X-Api-KeyYour API key (starts with ak_)
X-TimestampSigning time in unix milliseconds — at most ±300 seconds off the server clock
X-NonceUnique per request (UUIDv4 recommended) — a repeat within 6 minutes is rejected
X-Signaturehex( HMAC-SHA256(secret, canonical) ) per the formula below

Additional headers

Not part of the canonical string — sign only per the formula below:

HeaderWhen to use
X-Player-IPThe player's real IP — required on POST /games/:id/launch · recommended on other endpoints (recorded in login history)
X-Player-UAThe player's User-Agent (optional — recorded in login history)
X-Request-IdYour correlation id — echoed back as request_uuid
x-langth (default) or en — the language of message

Canonical string

text
canonical = METHOD (uppercase) + "\n"
          + PATH_WITH_QUERY    + "\n"   ← starts "/api/v1/..." incl. "?query" exactly as sent
          + X-Timestamp        + "\n"
          + X-Nonce            + "\n"
          + hex( SHA-256(raw body bytes) )   ← no body → hash of the empty string:
                                               e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  • The 5 lines are joined with \n (a single LF, not \r\n) — sign the raw bytes exactly as sent.

Node.js example

typescript
import { createHash, createHmac, randomUUID } from "node:crypto";

export function signedHeaders(method: string, pathWithQuery: string, bodyStr = "") {
  const timestamp = Date.now().toString();
  const nonce = randomUUID();
  const bodyHash = createHash("sha256").update(bodyStr, "utf8").digest("hex");
  const canonical = [method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash].join("\n");
  return {
    "X-Api-Key": process.env.API_KEY!,
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "X-Signature": createHmac("sha256", process.env.API_SECRET!).update(canonical).digest("hex"),
  };
}

Hard rules

  1. Every retry is re-signed (new timestamp + nonce) — never replay the original request
  2. X-Player-IP = the player's real IP
  3. X-Timestamp ±300-second window

A mis-signed request returns HTTP 401:

CodeCause
1019signature mismatch
1020timestamp out of window (beyond ±300s)
1021repeated nonce (replayed within 6 min)

Request signing examples

Demo values used by every vector:

text
API_KEY    = ak_3f9a1c5e7b2d4f6a8c0e1b3d5f7a9c2e4d6f8a0b1c3d5e7f
API_SECRET = 5b0e5f3c2a9d4e8f7c6b5a49382716053c2b1a0f9e8d7c6b5a4938271605f4e3

Sign each example and your X-Signature must match exactly. The path in the canonical is the full request path — e.g. /api/v1/auth/login, not the shortened /auth/login used in the example headings below. If it does not match, compare bodyHash first (a wrong hash means your body bytes differ), then walk the canonical line by line

A — POST /auth/login · body {"phone":"0812345678","password":"P@ssw0rd123"}

text
bodyHash    = 7398bfab1d434e577244f950baebd60ef61bf2c6e2c1f8ef71ec9513fdbdbe1d
X-Timestamp = 1783236600000
X-Nonce     = 9f2b7d1e-4a83-4c1f-b2ce-6d5a0e8f3c21
X-Signature = 8e039b28037162d5b09f50f79a2af6ab24086661ddad29a997d2b0d864d762a0

B — GET /profile/balance · no body (empty-string hash) + Authorization: Bearer <token>

text
bodyHash    = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Timestamp = 1783236660000
X-Nonce     = c4d8a2f6-1b3e-4f7a-9c0d-2e5b8a1f6d43
X-Signature = f0f002b52554c371d87edb0f9687c5c269ad2f7e0cbf6b58a225ad7096e6a96a

C — POST /auth/verify-otp · body {"phone":"0812345678","otp_code":"123456"}

text
bodyHash    = 37b0f11f251d34d64a8bbc84cef2a340971b983001cb8025fd43c9ae746b7f06
X-Timestamp = 1783236720000
X-Nonce     = 7a1e9c3b-5d2f-4e8a-a6b4-0c9d8e7f2a15
X-Signature = 9ea09be21aefaa87339759b6c90a033572b41055d6f419830fcba386e3edc5b3

D — GET /transactions?page=1&limit=20 · the query string is part of the canonical too

text
bodyHash    = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Timestamp = 1783236780000
X-Nonce     = e2f4a6c8-0b1d-4c3e-9f5a-7d8e9c0a1b2f
X-Signature = bd3430969ab63792c9a9ca3ea9faafb3525920db53606aab1fa1777807a5d27b

Layer 2 — Member token

login/register/refresh return 2 JWTs per session:

TokenLifetimeHow to use
access_token15 minutes (expires_in = 900)Send as Authorization: Bearer on every member-scoped endpoint
refresh_token7 days · single-useGet a new pair via POST /auth/refresh

Session rules

  • single-use rotation — once you request a new refresh token, the old one stops working
  • 1 member = 1 session — a login from a new place replaces the old session; the old refresh tokens stop working
  • POST /auth/logout revokes the session immediately — a not-yet-expired access token is cut off too (fast-revoke)

Handling token errors

There are three token-related codes:

CodeMeaning
1001unauthorized (missing / rejected token)
1006token expired
1007token invalid

On any of these → refresh once and retry the original request; if the refresh fails, send the player back to login

Account lockout

5 failed logins within 15 minutes → the account is temporarily locked · one successful login resets the counter

CodeHTTPMeaning
1016429account temporarily locked (too many failed sign-ins)

Error Code

codeNameHTTPMeaning
1018API_KEY_INVALID401Key does not exist / disabled / revoked
1019API_SIGNATURE_INVALID401Signature mismatch — walk the hard rules first
1020API_TIMESTAMP_INVALID401Timestamp off by more than ±300s — check NTP
1021API_NONCE_REPLAYED401Nonce reused — every request/retry must be freshly signed
1022API_CHANNEL_FORBIDDEN403Credential cannot use this channel (External Site not enabled / not a TRANSFER agent / member token belongs to another agent)
1023API_IP_NOT_ALLOWED403Your server's IP is not in the allowlist