API reference

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

1. Transfer walletYou hold the real balance. Your server calls 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.
2. LaunchYour backend calls 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.
3. PlayThe player lands at the game on play.snowballcsn.app. Texas and baccarat run on Snowball; you read history, reports and reconcile through the same API.
4. ControlOperator limits, bot mode and Texas table types (bots-only or mixed with rake) are settable through the API, within platform bounds.

Base URLs

Sandboxhttps://partner-api-sandbox.snowballcsn.app/v1
Productionhttps://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

Signing (HMAC-SHA256)

Every request is signed. Send these five headers each time:

HeaderValue
X-Partner-IdYour partner id (integer as a string).
X-Key-IdKey id, k1, k2, ... Two keys can be valid at once during rotation.
X-TimestampSend time in epoch ms. More than 5 minutes off our clock: 401 timestamp_out_of_window.
X-Nonce16-64 characters from [A-Za-z0-9_-]. Must not repeat for your partner within 5 minutes: 401 replay.
X-SignatureLowercase 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)

What we check, in order

  1. Source IP on your allow-list (else 403 ip_not_allowed, before the signature is looked at).
  2. Partner and key exist and are enabled.
  3. Timestamp within 5 minutes.
  4. Signature, with a constant-time comparison.
  5. Nonce is new (recorded only after the signature is good; a bad signature does not burn a nonce).
  6. 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 });

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 }
  1. Redirect the player's browser (new window or full page recommended) to launchUrl.
  2. 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.
  3. The token is single use, 60 seconds by default (10-300). Reuse or expiry is 401 launch_token_invalid; a suspended player is 403 banned.

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 }
Realized return is a measured result, not a setting. Bot mode only changes how bots play. Baccarat payouts are fixed by the rules, Texas dealing comes from a verifiable shuffle. 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.

EventPayload
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.sessionstarted 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

Acceptance checklist (all ten before production keys are issued)

  1. GET /ping signature accepted.
  2. First launch with an externalPlayerId auto-creates the player; a second launch does not create another. Also test explicit creation, including a duplicate externalPlayerId.
  3. Launch, land at a table.
  4. One transfer-in and one transfer-out, plus a replay with the same requestId.
  5. A deliberate timeout followed by a retry.
  6. Insufficient balance returns 409.
  7. Transfer-out while seated returns 409, then kick, then transfer-out succeeds.
  8. Ledger and rounds history agree with your books.
  9. (If used) Webhook received and signature verified.
  10. Rate limiting: a 429 is handled.

Go-live

We need from you:

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)