Partner API for operators
A transfer-wallet integration: you keep the player's real balance, move chips in to play and out when done, and send players to the game with a one-time launch link. 23 endpoints, HMAC-signed, idempotent transfers, a full sandbox.
How it works
transfer-in to put chips in the Snowball wallet and transfer-out to take them back. All amounts are integer chips; one chip's value is your call.POST /v1/launch with your own externalPlayerId. The player is created on first use (no explicit create call, no password) and you get a one-time launchUrl to redirect the browser to.play.snowballcsn.app. Texas and baccarat run on Snowball; you read history, reports and reconcile through the same API.Base URLs
| Sandbox | https://partner-api-sandbox.snowballcsn.app/v1 |
|---|---|
| Production | https://partner-api.snowballcsn.app/v1 |
Going from sandbox to production changes only the base URL and the key; no code changes. Sandbox and production use different partner ids and keys; mixing them returns 401.
Conventions
- JSON, UTF-8. Times are epoch milliseconds (UTC); day boundaries (reports, daily limits) are UTC+8.
- Body at most 64 KB. Lists are cursor-paged:
?limit=1..200&cursor=...returning{items, nextCursor}. - Every response has
X-Trace-Id; quote it when you contact us. playerIdalways starts withu_; yourexternalPlayerIdmust not. Any{playerId}path segment accepts either.- Errors:
{"error":"code","message":"...","traceId":"..."}. Switch onerroronly. Full list in the reference.
Signing (HMAC-SHA256)
Every request is signed. Send these five headers each time:
| Header | Value |
|---|---|
X-Partner-Id | Your partner id (integer as a string). |
X-Key-Id | Key id, k1, k2, ... Two keys can be valid at once during rotation. |
X-Timestamp | Send time in epoch ms. More than 5 minutes off our clock: 401 timestamp_out_of_window. |
X-Nonce | 16-64 characters from [A-Za-z0-9_-]. Must not repeat for your partner within 5 minutes: 401 replay. |
X-Signature | Lowercase hex of HMAC_SHA256(secret, canonicalString). |
Canonical string
These eight lines joined by \n, no newline after the last line:
SNOWBALL-HMAC-SHA256-V1
{partnerId}
{timestamp} same value as X-Timestamp
{nonce} same value as X-Nonce
{METHOD} upper case, e.g. POST
{path} with the /v1 prefix, without the query, e.g. /v1/players/u_abc/transfer-in
{canonicalQuery} sorted by key, then by value, as plain strings; key=value joined by &;
keys and values RFC 3986 percent-encoded; empty line if there is no query
{sha256hex(body)} SHA-256 of the raw body bytes, lowercase hex; for GET / no body, the hash of
the empty string (e3b0c442...b855)
secretis the 64-character hex string we gave you, used as a UTF-8 string key. Do not hex-decode it first.- Hash and send the same raw bytes. Do not re-serialize the JSON after signing.
- A body, if present, must be
application/json. The Partner API does not enable CORS: call it from servers only.
What we check, in order
- Source IP on your allow-list (else
403 ip_not_allowed, before the signature is looked at). - Partner and key exist and are enabled.
- Timestamp within 5 minutes.
- Signature, with a constant-time comparison.
- Nonce is new (recorded only after the signature is good; a bad signature does not burn a nonce).
- Rate limit.
All authentication failures answer 401 without saying which part was wrong. Only timestamp_out_of_window and replay are explicit.
Test vectors
Secret for both vectors (a published test value, never use it for real): 0123456789abcdef repeated four times (64 characters). Reproduce these before you go further.
Vector 1: POST with body
partnerId 12, timestamp 1791273600000, nonce a1b2c3d4e5f60718293a4b5c6d7e8f90, POST /v1/players/u_abc/transfer-in, body {"requestId":"dep-20261006-0001","amount":500000}
canonical string (JSON-escaped newlines):
"SNOWBALL-HMAC-SHA256-V1\n12\n1791273600000\na1b2c3d4e5f60718293a4b5c6d7e8f90\nPOST\n/v1/players/u_abc/transfer-in\n\n2b93f252be31e469e0c561bad609eb1ac1ab4df7ee3f5428052b8b260267bc0f"
X-Signature:
aa658c169fbdd09e1fea4b1a5b7331edf06e08aeff042ead63da8a8160984c17
Vector 2: GET with query, no body
partnerId 12, timestamp 1791273600000, nonce b2c3d4e5f60718293a4b5c6d7e8f90a1, GET /v1/players/u_abc/transactions, query {"type":"transfer_in,transfer_out","from":"1791200000000","limit":"50"}, no body
canonical string (JSON-escaped newlines):
"SNOWBALL-HMAC-SHA256-V1\n12\n1791273600000\nb2c3d4e5f60718293a4b5c6d7e8f90a1\nGET\n/v1/players/u_abc/transactions\nfrom=1791200000000&limit=50&type=transfer_in%2Ctransfer_out\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
X-Signature:
856d360ba50dff25ea06ffafd8bc67ea9996e5aca527d0759481a67e2129465c
Code
// Snowball Partner API request signing. Node 18+, no dependencies.
const crypto = require('crypto');
// RFC 3986 percent-encoding (encodeURIComponent plus !'()* )
const enc = (s) =>
encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
function snowballHeaders({
partnerId, keyId, secret, method, path, query = {}, body = '',
ts = Date.now().toString(), // override only for tests
nonce = crypto.randomBytes(16).toString('hex'), // override only for tests
}) {
const q = Object.keys(query).sort()
.flatMap((k) => [].concat(query[k]).map(String).sort().map((v) => `${enc(k)}=${enc(v)}`))
.join('&');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const canonical = [
'SNOWBALL-HMAC-SHA256-V1', partnerId, ts, nonce, method.toUpperCase(), path, q, bodyHash,
].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
return {
'X-Partner-Id': partnerId, 'X-Key-Id': keyId, 'X-Timestamp': ts,
'X-Nonce': nonce, 'X-Signature': signature, 'Content-Type': 'application/json',
};
}
module.exports = { snowballHeaders };
// Usage:
// const body = JSON.stringify({ requestId: 'dep-20261006-0001', amount: 500000 });
// const path = '/v1/players/u_abc/transfer-in';
// const headers = snowballHeaders({ partnerId: '12', keyId: 'k1', secret: process.env.SNOWBALL_SECRET,
// method: 'POST', path, body });
// const res = await fetch('https://partner-api.snowballcsn.app' + path, { method: 'POST', headers, body });
<?php
// Snowball Partner API request signing. PHP 7.4+.
function snowball_headers(string $partnerId, string $keyId, string $secret, string $method,
string $path, array $query, string $body): array {
$ts = (string) round(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$parts = [];
foreach ($query as $k => $v) {
foreach ((array) $v as $one) { $parts[] = [(string) $k, (string) $one]; }
}
// sort by raw key, then raw value, as plain strings (not numerically), then percent-encode
usort($parts, fn($a, $b) => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));
$q = implode('&', array_map(fn($p) => rawurlencode($p[0]) . '=' . rawurlencode($p[1]), $parts));
$canonical = implode("\n", ['SNOWBALL-HMAC-SHA256-V1', $partnerId, $ts, $nonce,
strtoupper($method), $path, $q, hash('sha256', $body)]);
return [
'X-Partner-Id: ' . $partnerId, 'X-Key-Id: ' . $keyId, 'X-Timestamp: ' . $ts,
'X-Nonce: ' . $nonce, 'X-Signature: ' . hash_hmac('sha256', $canonical, $secret),
'Content-Type: application/json',
];
}
// Usage:
// $body = json_encode(['requestId' => 'dep-20261006-0001', 'amount' => 500000]);
// $path = '/v1/players/u_abc/transfer-in';
// $h = snowball_headers('12', 'k1', getenv('SNOWBALL_SECRET'), 'POST', $path, [], $body);
// $ch = curl_init('https://partner-api.snowballcsn.app' . $path);
// curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => $h,
// CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true]);
// $res = curl_exec($ch);
Key rotation: we add a new kid, both keys work, you switch to the new one, we retire the old one. Secrets are per environment and per partner and are shown to you once.
Launch: getting a player into the game
POST /v1/launch
{ "externalPlayerId": "ops-user-88421", "displayName": "Alex", "game": "baccarat",
"lang": "en", "returnUrl": "https://ops.example.com/lobby", "ttlSeconds": 60 }
200 { "playerId": "u_9f3c1a2b7d4e5f6a8b90", "created": true,
"launchToken": "lt_...(64 hex)",
"launchUrl": "https://play.snowballcsn.app/?launch=lt_...&game=baccarat&lang=en&ret=https%3A%2F%2Fops.example.com%2Flobby",
"expiresAt": 1791273660000 }
- Redirect the player's browser (new window or full page recommended) to
launchUrl. - The web app exchanges the token for a normal session (
POST /api/launch/exchange, unsigned, 20 per minute per IP) and removes the token from the URL. - The token is single use, 60 seconds by default (10-300). Reuse or expiry is
401 launch_token_invalid; a suspended player is403 banned.
gameis optional:texasorbaccaratgoes straight to table selection of that game; omitted lands in the Snowball lobby.tableIdjumps to one table.- Unknown
externalPlayerIdmeans the player is created on the spot (upsert). The same id always maps to the same player. No password is ever issued; partner players cannot use the normal login. returnUrlmust be on your allow-listed domains; the game then shows a back button and logout redirects there.- A new session kicks the older one. Sessions last 12 hours (
sessionHours1-24 inPUT /limits), not sliding. - Embedding in an iframe works only from your allow-listed domains; a new window is recommended.
Transfers and idempotency
POST /v1/players/ops-user-88421/transfer-in
{ "requestId": "dep-20261006-0001", "amount": 500000, "ref": "ops-order-77" }
200 { "ok": true, "replayed": false, "transferId": "tin_...", "txId": "ptr:12:dep-20261006-0001",
"requestId": "dep-20261006-0001", "type": "transfer_in", "amount": 500000,
"balance": 1500000, "ts": 1791273600000 }
requestId: 1-64 chars[A-Za-z0-9_.:-], unique per partner forever.- Same
requestIdand same parameters: the original result again withreplayed: true; nothing moves twice. - Same
requestId, different parameters:409 idempotency_conflict. - Timeout, 5xx or dropped connection: retry with the same
requestId, or askGET /v1/transfers/{requestId}(completedornot_found). - Business errors (
insufficient,seated) do not burn the id; retry later with the same one. transfer-outwhile the player is seated at Texas, has an unfinished buy-in or has unsettled baccarat bets:409 seatedwithretryable: true. CallPOST /v1/players/{id}/kick, then retry. Chips on a table are not wallet balance.- Rate limits: 50 req/s per partner (burst 100); transfers 20 req/s per partner and 5 req/s per player; history and reports 10 req/s. Over the limit:
429 rate_limitedwithRetry-After.
GET /v1/reports/rtp reports what actually happened; small samples swing widely.Webhook (optional)
Configure with PUT /v1/webhook; url must be https and public. The first call returns signingSecret once. Events are JSON with a unique eventId, delivered at least once: de-duplicate on eventId. Order is not guaranteed; use ts / eventId.
| Event | Payload |
|---|---|
balance.changed | {playerId, txId, type, amount, balance, ts}. Any wallet movement. Optional coalesceMs merges bursts per player to the latest balance. |
win.big | {playerId, game, roundId, net, threshold, ts}. Net win in one round at or above your threshold. |
transfer.completed | {playerId, requestId, type, amount, balance}. For asynchronous reconciliation. |
player.session | started or ended (launch exchange, kick, expiry). |
Verifying the signature
X-Snowball-Timestamp: 1791273600000
X-Snowball-Signature: v1=<hex>
v1 = hex( HMAC_SHA256( signingSecret, timestamp + "." + rawBody ) )
Reject if the timestamp differs from your clock by more than 5 minutes.
Delivery
Answer any 2xx within 5 seconds. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h (6 attempts in total); after that the delivery is marked failed and can be replayed from the partner back office. POST /v1/webhook/test sends a test event.
Sandbox
- Separate host, tenant, partner id and keys. Sandbox players and wallets are isolated and cannot enter production tables. Chips are fake.
POST /v1/sandbox/players/{id}/faucet{"amount": 1000000}adds fake chips (sandbox only).- Games run normally on a sandbox table pool; bots fill seats as usual.
- Error injection: header
X-Sandbox-Scenario: timeout | 500 | insufficient | seatedon any request, to test retries and idempotency.
Acceptance checklist (all ten before production keys are issued)
GET /pingsignature accepted.- First launch with an
externalPlayerIdauto-creates the player; a second launch does not create another. Also test explicit creation, including a duplicateexternalPlayerId. - Launch, land at a table.
- One transfer-in and one transfer-out, plus a replay with the same
requestId. - A deliberate timeout followed by a retry.
- Insufficient balance returns 409.
- Transfer-out while seated returns 409, then kick, then transfer-out succeeds.
- Ledger and rounds history agree with your books.
- (If used) Webhook received and signature verified.
- Rate limiting: a 429 is handled.
Go-live
We need from you:
- The list of egress IPs (CIDR) that will call the API.
- Your webhook URL, if you use webhooks.
- The domains allowed for
returnUrl, and any domains that will embed the game in an iframe. - Confirmation of the reconciliation time zone (UTC+8).
Then: all ten sandbox checks pass, we issue production keys, you change the base URL and the key. Commercial terms and settlement are agreed separately and are not part of this API.
Rate limits above are defaults and can be tuned per partner. Roulette is not included in v1.
Changelog
v0.1 (2026-10-06)
- First published version: 23 endpoints plus the sandbox faucet.
- Explicit player creation is optional:
POST /launchwithexternalPlayerIdcreates the player on first use (upsert) and returnsplayerIdandcreated. - Field naming unified as
externalPlayerId; any{playerId}path segment acceptsplayerId(u_...) orexternalPlayerId. POST /launchaccepts an optionalgame(texas|baccarat) to land directly in that game.