Authentication
Every call to the operator API and every wallet callback is signed with HMAC-SHA256 using the merchant secret. The scheme is the same in both directions.
Headers#
| Header | Value |
|---|---|
X-Merchant-Id | Merchant code. |
X-Timestamp | Time of sending in milliseconds since 1970 (epoch), as text. |
X-Nonce | Random value, unique per request (e.g. 16 bytes in hex). |
X-Signature | Signature in lowercase hex (64 characters). |
Content-Type | application/json when there is a body. |
Signed string#
{timestamp}.{nonce}.{METHOD}.{path with query}.{raw body}METHODin uppercase (GET,POST).path with queryexactly as in the URL, including the version prefix:/v1/game/launch,/v1/games?page=2&limit=20.raw bodyis the exact text sent in the body. InGET(no body) it is empty — the string ends with..
X-Signature = hex( HMAC_SHA256( secret, signed_string ) )Sign the text you send
Serialize the JSON once, sign that text and send the same text in the body. Re-serializing after signing (different field order, spaces, escapes) invalidates the signature.
Validation rules#
TALOS rejects the request (HTTP 401, or 503 for AUTH_UNAVAILABLE) when:
| Code | When |
|---|---|
MISSING_AUTH_HEADERS | One of the four headers is missing. |
REQUEST_EXPIRED | X-Timestamp outside the 5-minute window (ahead or behind) — keep the server clock in sync (NTP). |
INVALID_MERCHANT | Unknown merchant code. |
MERCHANT_BLOCKED | Merchant is blocked. |
INVALID_SIGNATURE | The signature does not match. |
IP_NOT_ALLOWED | The merchant has an IP list and the request came from another IP. |
NONCE_REUSED | The same X-Nonce was already used by this merchant (generate a new one for every request, retries included). |
AUTH_UNAVAILABLE | Temporary failure while recording the nonce; retry with a new nonce. |
Authentication happens before body validation: an unauthenticated request never gets a 422.
Examples#
A minimal client that signs and sends. The examples on the reference pages use the talos function below.
// talos.ts — Node 18+ / Bun
import { createHmac, randomBytes } from 'node:crypto'
const HOST = process.env.TALOS_API_HOST! // e.g. https://<api-host> (without /v1)
const MERCHANT = process.env.TALOS_MERCHANT!
const SECRET = process.env.TALOS_SECRET!
export async function talos(method: 'GET' | 'POST', path: string, payload?: unknown) {
const body = payload === undefined ? '' : JSON.stringify(payload)
const timestamp = Date.now().toString()
const nonce = randomBytes(16).toString('hex')
const signature = createHmac('sha256', SECRET)
.update(`${timestamp}.${nonce}.${method}.${path}.${body}`, 'utf8')
.digest('hex')
const res = await fetch(`${HOST}${path}`, {
method,
headers: {
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
'X-Merchant-Id': MERCHANT,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature
},
body: payload === undefined ? undefined : body
})
return res.json()
}
// usage
const me = await talos('GET', '/v1/merchant')<?php
// talos.php — PHP 8+
function talos(string $method, string $path, ?array $payload = null): array
{
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) (int) round(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', "$timestamp.$nonce." . strtoupper($method) . ".$path.$body", getenv('TALOS_SECRET'));
$headers = [
'X-Merchant-Id: ' . getenv('TALOS_MERCHANT'),
"X-Timestamp: $timestamp",
"X-Nonce: $nonce",
"X-Signature: $signature",
];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
}
$ch = curl_init(getenv('TALOS_API_HOST') . $path); // host without /v1
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
$me = talos('GET', '/v1/merchant');# talos.py — Python 3.9+
import hashlib, hmac, json, os, secrets, time
import urllib.error, urllib.request
def talos(method: str, path: str, payload=None) -> dict:
body = '' if payload is None else json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
timestamp = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
message = f'{timestamp}.{nonce}.{method.upper()}.{path}.{body}'
signature = hmac.new(os.environ['TALOS_SECRET'].encode(), message.encode('utf-8'), hashlib.sha256).hexdigest()
headers = {
'X-Merchant-Id': os.environ['TALOS_MERCHANT'],
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
}
if payload is not None:
headers['Content-Type'] = 'application/json'
request = urllib.request.Request(
os.environ['TALOS_API_HOST'] + path, # host without /v1
data=body.encode('utf-8') if payload is not None else None,
headers=headers,
method=method.upper(),
)
try:
with urllib.request.urlopen(request) as response:
return json.load(response)
except urllib.error.HTTPError as error: # 4xx/5xx also carry the JSON envelope
return json.load(error)
me = talos('GET', '/v1/merchant')Test vector#
Use it to check your implementation:
| Input | Value |
|---|---|
| secret | segredo-de-teste |
| timestamp | 1767225600000 |
| nonce | 0f1e2d3c4b5a69788796a5b4c3d2e1f0 |
| method / path | POST /v1/game/launch |
| body | {"player_id":"user-123","game":"sportsbook"} |
1767225600000.0f1e2d3c4b5a69788796a5b4c3d2e1f0.POST./v1/game/launch.{"player_id":"user-123","game":"sportsbook"}Expected signature (computed with the same function TALOS uses):
174c4db0cfb46cc1fa1747b0c822a956469048eec42586be0cba3d25b570e755Validating calls from TALOS#
Wallet callbacks arrive with the same four headers, signed with the secret of the merchant given in X-Merchant-Id. Before touching the balance:
- Read the raw body (text) before any JSON parsing.
- Check that
X-Merchant-Idis one of your merchants and use its secret. - Reject an
X-Timestampoutside a 5-minute window. - Recompute the signature with the method (
POST), the path + query of yourwalletUrland the raw body, and compare in constant time. - Recommended: keep the received nonces for at least 10 minutes and reject repeated ones.
- Only then parse the JSON.
Reply 401 with { "ok": false, "error": "INVALID_SIGNATURE" } when validation fails.
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRETS: Record<string, string> = { [process.env.TALOS_MERCHANT!]: process.env.TALOS_SECRET! }
const WINDOW_MS = 5 * 60_000
export function verifyTalos(request: Request, rawBody: string): boolean {
const merchant = request.headers.get('x-merchant-id')
const timestamp = request.headers.get('x-timestamp')
const nonce = request.headers.get('x-nonce')
const signature = request.headers.get('x-signature')
const secret = merchant ? SECRETS[merchant] : undefined
if (!secret || !timestamp || !nonce || !signature) return false
if (Math.abs(Date.now() - Number(timestamp)) > WINDOW_MS) return false
const url = new URL(request.url)
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${nonce}.${request.method}.${url.pathname}${url.search}.${rawBody}`, 'utf8')
.digest()
const given = Buffer.from(signature, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}<?php
function verify_talos(string $rawBody, string $method, string $pathWithQuery): bool
{
$secrets = [getenv('TALOS_MERCHANT') => getenv('TALOS_SECRET')];
$merchant = $_SERVER['HTTP_X_MERCHANT_ID'] ?? '';
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_NONCE'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret = $secrets[$merchant] ?? null;
if (!$secret || $timestamp === '' || $nonce === '' || $signature === '') {
return false;
}
if (abs(microtime(true) * 1000 - (float) $timestamp) > 5 * 60 * 1000) {
return false;
}
$expected = hash_hmac('sha256', "$timestamp.$nonce." . strtoupper($method) . ".$pathWithQuery.$rawBody", $secret);
return hash_equals($expected, strtolower($signature));
}
$raw = file_get_contents('php://input');
if (!verify_talos($raw, $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'INVALID_SIGNATURE']);
exit;
}import hashlib, hmac, os, time
SECRETS = {os.environ['TALOS_MERCHANT']: os.environ['TALOS_SECRET']}
def verify_talos(headers, method: str, path_with_query: str, raw_body: str) -> bool:
secret = SECRETS.get(headers.get('X-Merchant-Id', ''))
timestamp = headers.get('X-Timestamp')
nonce = headers.get('X-Nonce')
signature = headers.get('X-Signature')
if not secret or not timestamp or not nonce or not signature:
return False
if abs(time.time() * 1000 - float(timestamp)) > 5 * 60 * 1000:
return False
message = f'{timestamp}.{nonce}.{method.upper()}.{path_with_query}.{raw_body}'
expected = hmac.new(secret.encode(), message.encode('utf-8'), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.lower())Behind a proxy
The signed path is the one of the walletUrl configured at TALOS. If a proxy rewrites the path before it reaches your application, use the original walletUrl path in the calculation.
SDK for Node / Bun#
The @sportsbook/sdk/server package already ships the signed client (createTalosClient: launch, listGames, listProviders, merchant) and the callback validator (readWalletCallback). It is what the demo site uses.