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#

HeaderValue
X-Merchant-IdMerchant code.
X-TimestampTime of sending in milliseconds since 1970 (epoch), as text.
X-NonceRandom value, unique per request (e.g. 16 bytes in hex).
X-SignatureSignature in lowercase hex (64 characters).
Content-Typeapplication/json when there is a body.

Signed string#

{timestamp}.{nonce}.{METHOD}.{path with query}.{raw body}
  • METHOD in uppercase (GET, POST).
  • path with query exactly as in the URL, including the version prefix: /v1/game/launch, /v1/games?page=2&limit=20.
  • raw body is the exact text sent in the body. In GET (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:

CodeWhen
MISSING_AUTH_HEADERSOne of the four headers is missing.
REQUEST_EXPIREDX-Timestamp outside the 5-minute window (ahead or behind) — keep the server clock in sync (NTP).
INVALID_MERCHANTUnknown merchant code.
MERCHANT_BLOCKEDMerchant is blocked.
INVALID_SIGNATUREThe signature does not match.
IP_NOT_ALLOWEDThe merchant has an IP list and the request came from another IP.
NONCE_REUSEDThe same X-Nonce was already used by this merchant (generate a new one for every request, retries included).
AUTH_UNAVAILABLETemporary 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')

Test vector#

Use it to check your implementation:

InputValue
secretsegredo-de-teste
timestamp1767225600000
nonce0f1e2d3c4b5a69788796a5b4c3d2e1f0
method / pathPOST /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):

174c4db0cfb46cc1fa1747b0c822a956469048eec42586be0cba3d25b570e755

Validating 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:

  1. Read the raw body (text) before any JSON parsing.
  2. Check that X-Merchant-Id is one of your merchants and use its secret.
  3. Reject an X-Timestamp outside a 5-minute window.
  4. Recompute the signature with the method (POST), the path + query of your walletUrl and the raw body, and compare in constant time.
  5. Recommended: keep the received nonces for at least 10 minutes and reject repeated ones.
  6. 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)
}

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.