Signing (v1)
Every call between you and ZooZ is authenticated with HMAC-SHA256, in both directions and for both requests and responses. The scheme is the same everywhere. Only the key family changes:
| Key family | Signs | Verifies |
|---|---|---|
| Operator keys | Your requests to ZooZ (/api/v1/operator/*) and ZooZ's responses to them |
ZooZ verifies your requests. You verify ZooZ's responses |
| Wallet keys | ZooZ's wallet calls to you and your responses to them | You verify ZooZ's requests. ZooZ verifies your responses |
Each key is a key id plus a secret of at least 32 random bytes, exchanged as base64. Always decode the base64 and use the raw bytes as the HMAC key, never the base64 text.
#Request signature
#Headers
| Header | Value |
|---|---|
X-ZooZ-Operator |
Your operator id |
X-ZooZ-Key-Id |
Id of the key that signed this request |
X-ZooZ-Timestamp |
Unix time in seconds, as a decimal integer (for example 1790000000) |
X-ZooZ-Nonce |
A new random UUIDv4 for every request, retries included. At most 64 characters |
X-ZooZ-Signature |
v1= followed by the lowercase hex HMAC-SHA256 of the canonical string |
#Canonical string
canonical = "v1" + "\n"
+ METHOD + "\n"
+ path + "\n"
+ query + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ hex(SHA256(rawBody))
X-ZooZ-Signature = "v1=" + hex(HMAC_SHA256(secret, UTF8(canonical)))| Part | Exact rule |
|---|---|
"v1" |
The literal signature version. It is part of the signed data, so a signature cannot be reinterpreted under a later version |
METHOD |
Upper case: POST, GET |
path |
The URL path without scheme, host or port, exactly as sent, for example /api/v1/operator/sessions or /zooz/wallet/debit. If your wallet URL has a base path, it is included. Use only unreserved URL characters in your wallet path so that encoding questions never come up |
query |
The query string without the leading ?, exactly as sent. An empty string if there is none (currently always empty) |
timestamp |
The same string as the X-ZooZ-Timestamp header |
nonce |
The same string as the X-ZooZ-Nonce header |
hex(SHA256(rawBody)) |
Lowercase hex SHA-256 of the raw body bytes as they travel on the wire. For an empty body, this is the hash of zero bytes (e3b0c442…b855) |
\n |
A single line feed (0x0A). There is no trailing newline |
The raw body rule matters most. Hash the bytes you send, or the bytes you received, before any JSON
parsing. Never re-serialise the JSON to verify it: key order, whitespace, number formatting (1.00 compared with
1) and escaping all change the hash. Serialise once, sign those bytes, and send the same bytes.
#Verification order
The receiver (ZooZ for your calls, you for wallet calls) checks the request in this order, before any session or player lookup:
- Key. Find the key using
X-ZooZ-OperatorandX-ZooZ-Key-Id. Unknown →INVALID_SIGNATURE. - Time.
|now − timestamp| ≤ 300seconds. OtherwiseSTALE_TIMESTAMP. - Signature. Recompute and compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual,hash_equals,MessageDigest.isEqual,hmac.Equal,CryptographicOperations.FixedTimeEquals). A mismatch →INVALID_SIGNATURE. - Source IP (optional, ZooZ side only). If ZooZ has an IP allowlist for you and the caller's address is
not on it →
INVALID_SIGNATURE. - Nonce. Remember every accepted nonce for at least the full window (ZooZ keeps them for 600 s). A second
use →
REPLAY. Only store nonces after the signature check, so forged requests cannot fill your store.
A failed check is answered with HTTP 401 and only this body:
{"status":"error","code":"INVALID_SIGNATURE"}The code is INVALID_SIGNATURE, STALE_TIMESTAMP or REPLAY. Never return or log the expected signature,
computed values or secrets. Log only the reason, as ZooZ does. No setting can switch off signature checks,
not even in staging.
ZooZ also refuses operator requests with bodies larger than 64 KiB (INVALID_SIGNATURE).
#Response signature
Every response to a signed request carries its own signature. It is bound to the nonce of the request, so a response cannot be replayed as the answer to a different request.
| Header | Value |
|---|---|
X-ZooZ-Key-Id |
Id of the key that signed the response (same family as the request) |
X-ZooZ-Timestamp |
Unix seconds at the time of the response |
X-ZooZ-Signature |
v1= + lowercase hex HMAC-SHA256 of the response canonical string |
responseCanonical = "v1" + "\n"
+ "RESPONSE" + "\n"
+ requestNonce + "\n"
+ timestamp + "\n"
+ hex(SHA256(rawResponseBody))requestNonce is the X-ZooZ-Nonce of the request being answered. The response has no nonce header of its
own.
Who signs what:
- Your wallet responses. Sign every HTTP 200 answer,
okand business errors alike. ZooZ looks up the key by yourX-ZooZ-Key-Idwithin the wallet key family, and it checks that the timestamp is within ±300 s. The simplest choice is to sign with the same key that signed the request. A 401 answer to a request that failed verification does not need a signature, because ZooZ treats every non-200 answer as "outcome unknown". - ZooZ responses to
/api/v1/operator/*. ZooZ signs every response that passed request verification: 200 responses, and also 4xx/5xx error responses (ProblemDetails). The 401 answers to failed verification are not signed. ZooZ signs each response with the operator key that signed your request, so during a rotation you get back the key id you used. Still pick the secret by the response'sX-ZooZ-Key-Id.
Never use a response before its signature verifies. An unsigned or forged "ok" must not start a game or change a balance. ZooZ applies this to your wallet: a response with a bad signature means "outcome unknown", so debits are rolled back and credits are retried.
#Clock and nonces
- Use NTP on every server that signs or verifies. The window is ±300 s in both directions.
- Use a new UUIDv4 for every request. A retry is a new request with a new nonce and timestamp. The
body, including the
transactionId, stays the same. - Nonces are unique per operator. Keep your nonce store for at least 600 s (twice the window), because a timestamp can be up to 300 s in the future.
#Key rotation
A key family can hold two active keys at once (the old and the new one during a rotation). Any active key is accepted for verification, selected by key id. ZooZ generates every key itself, and a secret is shown to ZooZ staff only once, when it is created. Rotation never needs downtime:
Operator keys (you sign, ZooZ verifies):
- ZooZ adds a new key and sends it to you over a secure channel. Both keys now work.
- You add the new key to your list of keys that you accept for ZooZ responses. Then switch your signing to the new key id. ZooZ answers each request with the key that signed it.
- You tell ZooZ; ZooZ revokes the old key. From that moment, requests signed with it get
401 INVALID_SIGNATURE. - You delete the old key.
Wallet keys (ZooZ signs, you verify):
- ZooZ adds a new key and sends it to you. ZooZ keeps signing wallet calls with the old key for now.
- You add the new key to the keys you accept for wallet requests. Keep signing each response with the key that signed its request.
- You tell ZooZ; ZooZ revokes the old key. From the next call on, wallet calls carry the new key id.
- You delete the old key.
Rotate at least once a year, and immediately if a secret may have leaked. Staging and production keys are always different.
#Test vectors
Every implementation must reproduce these values exactly. The secret is public and only for testing. Never use it anywhere.
| Input | Value |
|---|---|
| Secret (base64) | Wm9vWi10ZXN0LXZlY3Rvci1zZWNyZXQtMzItYnl0ZXMhIQ== |
| Method, path, query | POST, /wallet/debit, (empty) |
| Timestamp | 1790000000 |
| Nonce | 5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11 |
Request body (exact bytes, no trailing newline):
{"transactionId":"r1-bet","roundId":"r1","gameId":"safari-king","sessionId":"s-42","playerId":"12345","currency":"EUR","amount":1.00}Request canonical string (the 4th line is the empty query):
v1
POST
/wallet/debit
1790000000
5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11
6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da| Output | Value |
|---|---|
SHA256(body) |
6114f6c4fe63af1458b115e2c4c295b88f4b73cb7a86bec728c4aa0f466937da |
X-ZooZ-Signature |
v1=84a363dd364fdd0962ca1c02274405f0fc860e59d1e2adba474e6164d7d7000d |
Response: timestamp 1790000001, answering the nonce above, body:
{"status":"ok","balance":999.00,"operatorTransactionId":"tx-1"}Response canonical string:
v1
RESPONSE
5f0c2d64-7a1b-4c8e-9d3f-2b6a1e0c9f11
1790000001
523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78| Output | Value |
|---|---|
SHA256(body) |
523ade2f8c8982b55cb95eeebda35ac6e030e3e123d48a7126a66f2cdb7b4c78 |
X-ZooZ-Signature |
v1=839bf4984976d32e6be6694e2ca9661d6104bbb9fef13e9cbf2398e50bd31f49 |
Ready-made implementations that print and check these values: samples.
#Why the scheme looks like this
| Decision | The problem it prevents |
|---|---|
| Method and path are signed | If only the body is signed, a debit body can be replayed against the credit endpoint |
| Hash of the raw body | Signing re-serialised JSON causes false mismatches and leaves fields unsigned |
| Scheme and host are not signed | Signing the full URL breaks behind TLS-terminating proxies and needs "rewrite to https" hacks |
| Timestamp and nonce are both checked | Sending a nonce and a timestamp without checking them still leaves the door open to replays |
| Money fields are inside the signed body | An unsigned transaction id lets an attacker replay a captured win under a new id |
| HMAC-SHA256, no MD5, no "secret + data" | MD5 and prefix/suffix-secret schemes are weak |
| Key ids and rotation | A single global secret cannot be rotated without downtime |
| Signature before session lookup | Unsigned requests must not be able to trigger database work |
| Signed responses | A forged "ok" or a fake balance must never be accepted |
| Checks cannot be disabled, secrets are never logged | Checks that were commented out or switched off by a flag, and secrets that leaked through logs |