Authentication
The External S2S channel has two auth layers:
- S2S HMAC (every request) — proves the request really comes from your backend, via
X-Api-Key+ a signature - Member Bearer (member-scoped endpoints only) — proves which member session it is, via
Authorization: Bearer
Layer 1 — S2S HMAC
The 4 headers, every request
| Header | Value |
|---|---|
X-Api-Key | Your API key (starts with ak_) |
X-Timestamp | Signing time in unix milliseconds — at most ±300 seconds off the server clock |
X-Nonce | Unique per request (UUIDv4 recommended) — a repeat within 6 minutes is rejected |
X-Signature | hex( HMAC-SHA256(secret, canonical) ) per the formula below |
Additional headers
Not part of the canonical string — sign only per the formula below:
| Header | When to use |
|---|---|
X-Player-IP | The player's real IP — required on POST /games/:id/launch · recommended on other endpoints (recorded in login history) |
X-Player-UA | The player's User-Agent (optional — recorded in login history) |
X-Request-Id | Your correlation id — echoed back as request_uuid |
x-lang | th (default) or en — the language of message |
Canonical string
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
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
- Every retry is re-signed (new timestamp + nonce) — never replay the original request
X-Player-IP= the player's real IPX-Timestamp±300-second window
A mis-signed request returns HTTP 401:
| Code | Cause |
|---|---|
1019 | signature mismatch |
1020 | timestamp out of window (beyond ±300s) |
1021 | repeated nonce (replayed within 6 min) |
Request signing examples
Demo values used by every vector:
API_KEY = ak_3f9a1c5e7b2d4f6a8c0e1b3d5f7a9c2e4d6f8a0b1c3d5e7f
API_SECRET = 5b0e5f3c2a9d4e8f7c6b5a49382716053c2b1a0f9e8d7c6b5a4938271605f4e3Sign 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"}
bodyHash = 7398bfab1d434e577244f950baebd60ef61bf2c6e2c1f8ef71ec9513fdbdbe1d
X-Timestamp = 1783236600000
X-Nonce = 9f2b7d1e-4a83-4c1f-b2ce-6d5a0e8f3c21
X-Signature = 8e039b28037162d5b09f50f79a2af6ab24086661ddad29a997d2b0d864d762a0B — GET /profile/balance · no body (empty-string hash) + Authorization: Bearer <token>
bodyHash = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Timestamp = 1783236660000
X-Nonce = c4d8a2f6-1b3e-4f7a-9c0d-2e5b8a1f6d43
X-Signature = f0f002b52554c371d87edb0f9687c5c269ad2f7e0cbf6b58a225ad7096e6a96aC — POST /auth/verify-otp · body {"phone":"0812345678","otp_code":"123456"}
bodyHash = 37b0f11f251d34d64a8bbc84cef2a340971b983001cb8025fd43c9ae746b7f06
X-Timestamp = 1783236720000
X-Nonce = 7a1e9c3b-5d2f-4e8a-a6b4-0c9d8e7f2a15
X-Signature = 9ea09be21aefaa87339759b6c90a033572b41055d6f419830fcba386e3edc5b3D — GET /transactions?page=1&limit=20 · the query string is part of the canonical too
bodyHash = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Timestamp = 1783236780000
X-Nonce = e2f4a6c8-0b1d-4c3e-9f5a-7d8e9c0a1b2f
X-Signature = bd3430969ab63792c9a9ca3ea9faafb3525920db53606aab1fa1777807a5d27bLayer 2 — Member token
login/register/refresh return 2 JWTs per session:
| Token | Lifetime | How to use |
|---|---|---|
access_token | 15 minutes (expires_in = 900) | Send as Authorization: Bearer on every member-scoped endpoint |
refresh_token | 7 days · single-use | Get 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/logoutrevokes the session immediately — a not-yet-expired access token is cut off too (fast-revoke)
Handling token errors
There are three token-related codes:
| Code | Meaning |
|---|---|
1001 | unauthorized (missing / rejected token) |
1006 | token expired |
1007 | token 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
| Code | HTTP | Meaning |
|---|---|---|
1016 | 429 | account temporarily locked (too many failed sign-ins) |
Error Code
| code | Name | HTTP | Meaning |
|---|---|---|---|
1018 | API_KEY_INVALID | 401 | Key does not exist / disabled / revoked |
1019 | API_SIGNATURE_INVALID | 401 | Signature mismatch — walk the hard rules first |
1020 | API_TIMESTAMP_INVALID | 401 | Timestamp off by more than ±300s — check NTP |
1021 | API_NONCE_REPLAYED | 401 | Nonce reused — every request/retry must be freshly signed |
1022 | API_CHANNEL_FORBIDDEN | 403 | Credential cannot use this channel (External Site not enabled / not a TRANSFER agent / member token belongs to another agent) |
1023 | API_IP_NOT_ALLOWED | 403 | Your server's IP is not in the allowlist |