Skip to content

Signing & authentication

There's no client SDK for either direction of the operator integration — you sign your own calls to the platform, and you verify the platform's calls back to you, both server-side, in whatever stack you're running. Both directions share the same HMAC-SHA256 construction; no proprietary tooling required.

Request headers

Every signed request — yours to the platform, or the platform's to you — carries four headers:

HeaderValue
X-Tenant-IDYour operator tenant ID
X-TimestampUnix timestamp (seconds) at signing time
X-NonceA fresh random value, unique per request (e.g. 16 random bytes, hex-encoded)
X-SignatureHMAC-SHA256(secret, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body), hex-encoded

method is the HTTP method (POST/GET), and body is the exact request body bytes (empty string for GET). The nonce is folded into the signature itself, not just sent as a bare header — that's what makes it effective against replay: the verifier tracks nonces it's already seen and rejects repeats, and because the nonce is cryptographically bound to the signature, an attacker can't swap in a fresh nonce on a captured request to bypass that check.

Two more requirements the verifier enforces: X-Timestamp must be close to real time — see Clock sync for the exact tolerance — and each X-Nonce may only be used once. See Errors & retry for exactly how each kind of verification failure comes back.

path — one exception by direction

What goes into path differs by who's signing:

  • You signing calls to the platform (Operator API) — path is the URL path only, no scheme/host/query string, and must match exactly what the server receives.
  • The platform signing calls to you (wallet callback API) — path is the full request URI, path and query string. That's what covers GET /v1/wallet/balance's playerRef query parameter, so it can't be swapped in flight without invalidating the signature.

Every operator API call is also rate-limited per tenant — see Errors & retry.

Signing your requests (calling the platform)

Below is a self-contained example in Node.js, using only the built-in crypto module — the same construction works in any language with an HMAC-SHA256 primitive. Use this to sign calls to every endpoint in the Operator API reference — see Launching games for a worked example that calls it.

ts
import { createHmac, randomBytes } from 'node:crypto'

function signRequest(method, path, secret, body, tenantId) {
  const timestamp = Math.floor(Date.now() / 1000).toString()
  const nonce = randomBytes(16).toString('hex')

  const mac = createHmac('sha256', secret)
  mac.update(method)
  mac.update('\n')
  mac.update(path)
  mac.update('\n')
  mac.update(timestamp)
  mac.update('\n')
  mac.update(nonce)
  mac.update('\n')
  mac.update(body)

  return {
    'X-Tenant-ID': tenantId,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': mac.digest('hex'),
  }
}

Verifying platform signatures (wallet callbacks)

The wallet callback API is the reverse direction — the platform calls you, signing with the construction above over the full URI. You're the verifier here, not the signer — reject the request if the signature doesn't check out, the timestamp is outside your allowed clock skew, or a required header is missing. There's no SDK for this side of the exchange; your wallet backend is your own implementation, so you verify the signature yourself. It's the same handful of lines as the signing example above, just checking a signature instead of producing one.

ts
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyPlatformSignature(method, pathWithQuery, secret, body, headers, maxSkewSeconds = 300) {
  const timestamp = headers['x-timestamp']
  const nonce = headers['x-nonce']
  const providedSignature = headers['x-signature']
  if (!timestamp || !nonce || !providedSignature) return false

  const skewSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(skewSeconds) || skewSeconds > maxSkewSeconds) return false

  const mac = createHmac('sha256', secret)
  mac.update(method)
  mac.update('\n')
  mac.update(pathWithQuery) // e.g. `${req.path}?${queryString}` — must include the query string
  mac.update('\n')
  mac.update(timestamp)
  mac.update('\n')
  mac.update(nonce)
  mac.update('\n')
  mac.update(body) // exact bytes received, before JSON parsing; '' for GET
  const expected = Buffer.from(mac.digest('hex'), 'hex')
  const provided = Buffer.from(providedSignature, 'hex')

  return expected.length === provided.length && timingSafeEqual(expected, provided)
}

Two details worth getting right: read the raw request body before any JSON-parsing middleware runs (the signature covers the exact bytes sent, not a re-serialized version of the parsed object), and compare signatures with a constant-time comparison (timingSafeEqual above) rather than ===, so a timing side-channel can't leak how much of a guessed signature matched.

Getting your shared secret

The same secret signs both directions — the one you use to sign calls to the Operator API, and the one the platform uses to sign its wallet callbacks to you. One secret, used in both directions.

It's issued by the platform's admin team and delivered out-of-band when your operator tenant is created. It is not self-service to view or generate — there's no API that returns it to you. You can self-service rotate it and change where the platform sends your wallet callbacks through your tenant's portal — see Registering your wallet callback URL. A rotation keeps your old secret valid for a grace period, so both sides have time to switch over together rather than a hard cutover.