Signature self-check
Compute all three canonicals in your own browser, compare against the golden vectors, and tell a canonical fault apart from a clock, nonce, Host or key fault.
Three different canonical strings run through this integration, and all three fail the same way: a wrong canonical still produces a well-formed HMAC, so the only symptom is a bare 401 with nothing attached. A wrong clock, a replayed nonce, a wrong Host, the wrong key and a wrong canonical are indistinguishable from the outside — deliberately, because a distinguishable one would let an attacker probe. This page closes that gap from your side: compute the canonical in your browser, read it line by line, and compare it against the vector our own test suites pin.
Which canonical is which
| Field | Type | Description | |
|---|---|---|---|
| sa1 credential | you sign | required | The entry credential your backend mints so the player's browser can open the game directly. Not a request signature — the whole credential is the string, and it is redeemed once for a session. The golden vector below and the lab are its reference until the Direct-embed guide ships with the SDK. |
| B2B request | you sign | required | Six fields joined with a newline, ending with the RAW body. Every call to /b2b/v1/* carries it as X-Signature. It used to be seven — the PLAYER-ID line is gone, and a player is now named in the body of the session call instead. The session token itself is never signed. See Authentication. |
| Wallet hook | you verify | required | Five fields joined with a newline, ending with the HEX SHA-256 OF the body rather than the body itself. This is the one the operator sends to you, and the one that has already caused an outage when the two sides drifted. See Wallet hooks. |
The two request canonicals differ in more than length. The B2B one signs the body bytes directly; the hook one signs a hash of them. Sharing a verifier between the two is the most common way to end up with a signature that is correct for the wrong contract.
Compute it here
Paste your own values, or load a golden vector and change one field at a time until your output matches. The byte counts beside each line are there for the failure that reads as impossible: an invisible trailing space, a stray carriage return, or a smart quote pasted out of a document.
Your secret does not leave this tab
- Everything below is computed in your browser with WebCrypto. The page makes no network request once it has loaded — nothing you type is sent anywhere, to us or to anyone else.
- Nothing is stored: no
localStorage, nosessionStorage, no cookie, no console output, and no value is put in the URL. Closing the tab is all the cleanup there is. - You can verify both claims: open your browser's network panel and watch it stay silent while you type, or read the page source. Even so, prefer a sandbox key over a production one — a support tool is not a good place to build the habit of pasting live secrets.
No query string.
Exactly the bytes after ? on the wire. Do not rebuild it from a map, sort it, or re-encode it.
The exact bytes you put on the wire — signed raw, not hashed. Empty for GET.
Golden vectors
These are the vectors pinned by our own test suites: the same fixed inputs and the same fixed signatures that the platform builds against, so a drift on either side turns a build red rather than turning into a support ticket. Reproduce them in your language before you sign anything real — if your implementation agrees with every one of them, the canonicals are right and any later 401 is about the clock, the nonce, the Host, the key or the session rather than the string.
The secrets below are printed in public on purpose and are not accepted anywhere. Never put a real secret in a document, a ticket, or a test fixture that is shared.
1 · sa1 entry credential
secret = test-embed-secret
payload JSON = {"ak":"ak_test","p":"player-001","h":"localhost","ts":1755400000,"n":"5a6e0d0e-6f3a-4b8e-9c1d-2f3a4b5c6d7e"}
# key order is part of the contract: ak, p, h, ts, n. ts is a NUMBER, not a string.
payload seg = base64url-nopad(payload JSON)
= eyJhayI6ImFrX3Rlc3QiLCJwIjoicGxheWVyLTAwMSIsImgiOiJsb2NhbGhvc3QiLCJ0cyI6MTc1NTQwMDAwMCwibiI6IjVhNmUwZDBlLTZmM2EtNGI4ZS05YzFkLTJmM2E0YjVjNmQ3ZSJ9
canonical = "sa1." + payload seg # the prefix IS signed
sig seg = base64url-nopad(HMAC-SHA256(secret, canonical))
= wF9g5ExQXfFdA1XdBV92r0JMlBuC4YRqiB-eqYC3t_o
credential = sa1.<payload seg>.<sig seg> # this is the ?st= valueTwo details decide whether your output matches. The signature covers sa1. plus the payload segment, not the payload JSON — sign the encoded form, not the bytes you encoded. And both segments are base64url with the padding stripped, so =, + or / anywhere in your credential means the wrong alphabet.
2 · B2B request signature
Two vectors, for the same reason the wallet hook has two: one canonical cannot exercise both failure modes. The POST has a body and shows that the empty RAWQUERY line survives; the GET has neither query nor body and shows that the string then ends with a newline. An implementation that reproduces only the first will still fail every signed read.
secret = demo_secret_do_not_use
X-Timestamp = 1767225600
METHOD = POST
PATH = /b2b/v1/orders
RAWQUERY = (empty)
X-Nonce = b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40
BODY = {"market_id":"mkt-demo-0001","side":1,"stake":"10.00","idem_key":"order-20260101-0001"}
canonical = # 156 bytes
1767225600\n
POST\n
/b2b/v1/orders\n
\n <- RAWQUERY is empty; the LINE is still there
b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40\n
{"market_id":"mkt-demo-0001","side":1,"stake":"10.00","idem_key":"order-20260101-0001"}
<- BODY ends the string: no trailing newline
X-Signature = hex(HMAC-SHA256(secret, canonical))
= b0067625ada5020a2ecba5aeb9c4d64dd674e5f14cdc10d552cf28307aa45420secret = demo_secret_do_not_use
X-Timestamp = 1767225600
METHOD = GET
PATH = /b2b/v1/wallet/balance
RAWQUERY = (empty)
X-Nonce = b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40
BODY = (empty)
canonical = # 76 bytes
1767225600\n
GET\n
/b2b/v1/wallet/balance\n
\n <- RAWQUERY is empty; the LINE is still there
b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40\n
<- BODY is empty, so the string ENDS with that newline
X-Signature = hex(HMAC-SHA256(secret, canonical))
= 0de7d3fcb42b05a860442da76b75239ca5d4de8b5e4cc717f1649555fedd4fabsecret = demo_secret_do_not_use
X-Timestamp = 1767225600
METHOD = GET
PATH = /b2b/v1/orders
RAWQUERY = status=2&limit=20
X-Nonce = b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40
BODY = (empty)
canonical = # 85 bytes
1767225600\n
GET\n
/b2b/v1/orders\n
status=2&limit=20\n <- byte for byte as you send it: not sorted, not re-encoded
b6f3c2e1-4a5d-4c9e-9f10-2b8a7d6e5c40\n
<- BODY is empty, so the string ENDS with that newline
X-Signature = hex(HMAC-SHA256(secret, canonical))
= ebab014c79f9f1a302d54c22eeb1c73d83a755f95b07ac26f4561f16eb1236a1The query string enters the canonical exactly as it goes on the wire. It is not sorted, not normalised and not re-encoded, so ?limit=20&status=2 signs to a different value than ?status=2&limit=20. Some HTTP clients reorder query parameters when you pass them as a map — if yours does, build the query string yourself and sign the same bytes you send. The failure looks like a 401 with no further detail, which sends people to check their key.
The single most common miss is the empty RAWQUERY line on a POST. Five newlines separate six fields whether or not the fields are populated; a canonical with four newlines is a different string and hashes to a different value. The second most common is the opposite mistake on the GET — trimming the trailing newline that the empty body leaves behind, which is what printf and shell command substitution do for free. Full rules on Authentication.
If your signer still emits a PLAYER-ID line, these vectors will not reproduce — the canonical dropped it and is six fields now. That change travels with the retirement of the X-Player-Id header, so an integration in that state also gets a 400 player_id_header_retired rather than a bare 401, which is the point of refusing the header instead of ignoring it.
3 · Wallet hook signature
Two vectors, because the money commands and the balance read exercise different parts of the string: the write has an empty RAWQUERY and a body, the read has a query and no body. An implementation that passes only the first will still reject every balance call.
secret = demo_secret_do_not_use
canonical = ts \n METHOD \n PATH \n RAWQUERY \n sha256hex(BODY) # five fields
--- write command ---------------------------------------------------------
ts = 1767225600
METHOD = POST
PATH = /wallet/bet
RAWQUERY = (empty — the line remains)
BODY = {"amount":"12.34","biz":"bet","currency":"EUR","idem_key":"bet-order-20260101-0001","market_id":"mkt-demo-0001","merchant_id":"mch_demo","order_id":"ord-demo-0001","player_id":"player-1001"}
# keys arrive alphabetically sorted; hash the bytes you RECEIVED
sha256hex = 210ae723f58da85a5af58f71132674d655e7219978b0ac0ca8497230892b1012
signature = b9a85257f552839f9458022c40007fa655ba6c3d995b813314f8ebf16d6240bd
--- balance read ----------------------------------------------------------
ts = 1767225600
METHOD = GET
PATH = /wallet/balance
RAWQUERY = currency=EUR&merchant_id=mch_demo&player_id=player-1001
BODY = (none)
sha256hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# sha256 of the empty string — hash "" and not the literal "undefined"
signature = 9ff3e9e4979823b50d7c4edf528dd944f520365c77183849c81b0341fa2f25b2Hash the raw bytes as they arrived. Parsing the JSON and re-serializing it produces a different byte string for the same object — a re-ordered key, a dropped space, a number rendered differently — and therefore a different hash and a failed verification on a request that was perfectly valid. Full rules on Wallet hooks.
The canonical matches and you still get 401
Once your implementation reproduces the vectors above, the string is not the problem. Work down this list in order — it is sorted by how often each cause turns out to be the real one, and each row has a check that tells the causes apart instead of guessing.
| Field | Type | Description | |
|---|---|---|---|
| clock skew | 401 | optional | The timestamp must be within ±300 seconds of server time. Sign with the server's own clock in mind, not the container's: a host that never ran NTP drifts far enough to fail within days. Check by signing with a timestamp you print in the same log line and comparing it against the Date header on the response. |
| replayed nonce | 401 | optional | Each nonce is single-use within the timestamp window, scoped per API key. Retrying means RE-SIGNING, not resending the same envelope — the nonce was consumed before the handler ran, so a resend of a request that timed out answers 401 and looks like a credentials fault. Check by confirming a fresh random nonce per attempt, and that you are not caching signed headers. |
| nonce store unreachable | 502 | optional | Not a 401 at all — when our replay store cannot be reached we refuse rather than admit a possible replay, and it surfaces as upstream_error. A burst of 502s on signatures that were fine a minute ago is this, and it is worth retrying. A 401 never means this. |
| wrong key or wrong environment | 401 | optional | A sandbox key against production (or the reverse) is a signature over a canonical we cannot verify with the key you named. Check by calling one signed read that needs no player, in both environments, with the same code. |
| source IP not allowlisted | 403 | optional | Distinguishable from a signing fault by status alone: 403, not 401. Worth knowing before you enable it — under the strict policy an empty list rejects everything rather than allowing everything. |
| X-Player-Id still sent | 400 | optional | player_id_header_retired. The header is refused across all of /b2b/v1, before the signature is examined — so this answer is not a verdict on your key, and it arrives even when your signature is wrong. It is also the tell that your canonical is still seven fields, since the two changed together: remove the header and remove the PLAYER-ID line in the same edit. |
| missing Authorization | 401 | optional | session_required, on a player-scoped endpoint. The signature verified; you simply sent no session. Do not go looking for a signing bug — take a session at POST /b2b/v1/auth/session and send it as Authorization: Bearer. |
| session no longer valid | 401 | optional | session_invalid. Again the signature verified. Idle past 30 minutes, past the four-hour absolute cap, kicked by a newer session for the same player, or belonging to a different merchant than your key. Take a new session; it resolves to the same player. |
| sa1: Host mismatch | 401 | optional | Specific to the entry credential. h in the payload must equal the Host the browser actually sends, lowercased and without a port, and that domain must be registered and enabled for the merchant that signed. Embedding on an unregistered domain fails here even though the credential is perfectly signed. |
| sa1: credential already redeemed | 401 | optional | The exchange burns the nonce, and a launch token is consumed on first read. Mint a fresh credential per launch rather than reusing one across a reload — a page refresh is a second redemption. |
The mirror case is worth stating too. When your wallet hook rejects our signature with 401 or 403, we do not retry — resending an identical signature cannot help — and it surfaces to the player as upstream_error (502) while being logged loudly on our side, because in practice it means the signing secret or the canonical drifted between two deployments. If money commands start failing after a release of yours, verify one of the vectors above against your live verifier before looking anywhere else.