SoftBet API
A single, programmable iGaming back-end: slots, sportsbook, crypto wallets, free-spin campaigns, white-label hosting, and operator dashboards. Same auth model, consistent error envelope, predictable rate-limits.
Base URLs
| Environment | Host | Notes |
|---|---|---|
| Production | https://api.softbet.io | Backend of record. Use this for all server-to-server traffic. |
| Production (alias) | https://api.provide.bet | White-label browser alias only. Resolves to the same upstream. Do not point webhooks here. |
| Staging | https://staging-api.softbet.io | Sandbox. Pre-funded test crypto. Webhook deliveries are best-effort. |
Versioning
All paths are explicitly versioned. The dashboard/auth surface lives under /v1/*; the slots/games surface lives under /api/v1/* for byte-compatibility with the legacy integration shape; new platforms (hosting, sportsbook) live under /v1/<platform>/*. Breaking changes ship as a new major prefix.
Content type
All requests and responses use application/json; charset=utf-8 unless explicitly noted. Set Accept: application/json on every request. Send Content-Type: application/json on every request with a body.
Pagination
List endpoints accept limit (default and max per-endpoint, never exceeds 200) and either cursor (opaque, stable across page-loads) or page for legacy admin views. Responses include { items: [...], next_cursor: "…" | null }. Stop when next_cursor is null.
Time
All timestamps are RFC 3339 / ISO 8601 in UTC with millisecond precision, e.g. 2026-05-21T12:34:56.789Z. Unix epoch where used is integer seconds.
SDKs
No official SDKs today — the API is REST + JSON over HTTPS, intentionally small enough to be one file in any language. Examples throughout these docs are in cURL, JavaScript (fetch), and Python (requests).
Authentication
Three authentication modes. Pick by the URL prefix you're calling.
1) Session cookie — Dashboard & admin
Endpoints under /v1/auth/*, /v1/dashboard/*, /v1/admin/*, and /v1/hosting/* (when called from a browser) authenticate by HttpOnly; Secure; SameSite=Lax session cookies set by POST /v1/auth/login. The cookie domain is .softbet.io — cross-subdomain requests work; cross-origin browser requests must call from a *.softbet.io host or include credentials: 'include' against api.softbet.io.
api.softbet.io (not api.provide.bet) because the cookie's Domain attribute is .softbet.io. Per project-hosting-2fa-totp.2) API key + HMAC — Programmatic slots / gateway
Endpoints under /api/v1/* (slots, games, freespins, freevouchers, self-exclusion) use a key+secret pair with an HMAC-SHA1 request signature. Create the pair in Dashboard → API keys; the secret is shown once.
On every request, send these headers in addition to the body:
| Header | Value |
|---|---|
| X-API-Key | Your public key id, e.g. bs_live_3f4a… |
| X-Timestamp | Unix epoch seconds. Must be within ±5 min of server time, otherwise 401. |
| X-Nonce | 8 – 32 chars, random per request. Reusing a nonce within the validity window returns 409 nonce_reused. |
| X-Sign | Lowercase-hex HMAC-SHA1, see HMAC signing for the canonicalisation. |
Signature computed as HMAC-SHA1(canonicalQuery(params + authHeaders), bs_live_<secret>). The canonical string is the URL-encoded, sorted-by-key form of the request parameters concatenated with the four auth headers. See the HMAC signing reference for the exact algorithm and a worked example.
3) Bearer PAT — Hosting platform (programmatic)
Server-to-server access to /v1/hosting/* uses a personal access token. Create one in Dashboard → Hosting → API tokens. Tokens are prefixed prv_ and shown once.
Send as Authorization: Bearer prv_…. The token is scoped to your tenant; the URL-prefix guard rejects any request to a non-hosting path. PATs bypass TOTP 2FA (intentional — they're for unattended automation).
# 1) Session cookie (login first, then reuse the cookie jar)
curl -c cookies.txt -X POST https://api.softbet.io/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"·······"}'
curl -b cookies.txt https://api.softbet.io/v1/auth/me
# 2) API key + HMAC (slots / gateway)
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
SIG=$(echo -n "page=1&limit=50&X-API-Key=bs_live_…&X-Timestamp=$TS&X-Nonce=$NONCE" \
| openssl dgst -sha1 -hmac 'bs_live_<secret>' -hex | cut -d' ' -f2)
curl https://api.softbet.io/api/v1/games?page=1&limit=50 \
-H "X-API-Key: bs_live_…" -H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" -H "X-Sign: $SIG"
# 3) Bearer PAT (hosting)
curl https://api.softbet.io/v1/hosting/servers \
-H 'Authorization: Bearer prv_…'
// 1) Session cookie
await fetch('https://api.softbet.io/v1/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const me = await fetch('https://api.softbet.io/v1/auth/me',
{ credentials: 'include' }).then(r => r.json());
// 2) API key + HMAC — see HMAC section for sign()
const sign = (params, secret) => { /* HMAC-SHA1 canonical */ };
const ts = Math.floor(Date.now()/1000);
const nonce = crypto.randomUUID().replace(/-/g,'').slice(0,16);
const params = { page: 1, limit: 50 };
const headers = {
'X-API-Key': 'bs_live_…', 'X-Timestamp': ts, 'X-Nonce': nonce,
};
headers['X-Sign'] = sign({...params, ...headers}, 'bs_live_<secret>');
await fetch('https://api.softbet.io/api/v1/games?page=1&limit=50', { headers });
// 3) Bearer PAT
await fetch('https://api.softbet.io/v1/hosting/servers', {
headers: { 'Authorization': 'Bearer prv_…' }
});
import requests, time, secrets, hmac, hashlib
s = requests.Session()
# 1) Session cookie
s.post('https://api.softbet.io/v1/auth/login',
json={'email': '[email protected]', 'password': '·······'})
me = s.get('https://api.softbet.io/v1/auth/me').json()
# 2) API key + HMAC
def sign(params, secret):
canon = '&'.join(f'{k}={params[k]}' for k in sorted(params))
return hmac.new(secret.encode(), canon.encode(), hashlib.sha1).hexdigest()
ts = str(int(time.time())); nonce = secrets.token_hex(8)
params = {'page': 1, 'limit': 50}
headers = {'X-API-Key': 'bs_live_…', 'X-Timestamp': ts, 'X-Nonce': nonce}
headers['X-Sign'] = sign({**params, **headers}, 'bs_live_<secret>')
r = requests.get('https://api.softbet.io/api/v1/games', params=params, headers=headers)
# 3) Bearer PAT
requests.get('https://api.softbet.io/v1/hosting/servers',
headers={'Authorization': 'Bearer prv_…'})
Cookie security & sessions
Sessions expire after 30 days of inactivity. Logging in from a new device emits an email notification (configurable in Settings). Calling POST /v1/auth/logout revokes the current session; calling POST /v1/auth/totp/disable with a verified TOTP code also revokes all other active sessions for the account.
Errors
Every error returns a JSON envelope with a stable machine-readable code and a human-readable message. Always switch on error.code, never on the message string.
Error envelope
{
"ok": false,
"error": {
"code": "validation_error",
"message": "amount_usd must be at least 10",
"details": { "field": "amount_usd", "min": 10 }
}
}
{ "ok": false, "error": { "code": "validation_error", "message": "…", "details": {} } }{ "ok": false, "error": { "code": "validation_error", "message": "…", "details": {} } }HTTP status codes
| Status | Meaning | Typical error.code values |
|---|---|---|
| 200 | OK — request succeeded. | — |
| 201 | Created — resource created. | — |
| 204 | No content — successful action with no body. | — |
| 400 | Malformed request — JSON parse failed or unsupported parameter. | bad_request, missing_field |
| 401 | Not authenticated — missing/invalid session, key, or signature. | unauthenticated, bad_signature, expired_timestamp, invalid_token |
| 402 | Payment required — balance, credit, or quota exhausted. | insufficient_balance, insufficient_amount, quota_exceeded |
| 403 | Forbidden — authenticated but not permitted. | forbidden, role_required, self_exclusion_active, tenant_mismatch |
| 404 | Not found — id doesn't exist or you can't see it. | not_found |
| 409 | Conflict — concurrent edit, duplicate, or out-of-order state. | nonce_reused, conflict, already_exists, action_pending |
| 422 | Validation — request was well-formed but a field is invalid. | validation_error |
| 429 | Rate limited — slow down. Includes Retry-After seconds header. | rate_limited |
| 500 | Server error — bug or unhandled exception. Always safe to retry once. | server_error |
| 502 / 503 / 504 | Upstream provider is down (game studio, blockchain RPC, etc.). | sg_upstream_error, sg_timeout, sg_network, upstream_unavailable |
Common error.code values
| Code | HTTP | When |
|---|---|---|
| unauthenticated | 401 | No session cookie, no API key, or signature missing. |
| bad_signature | 401 | HMAC didn't verify. Check canonicalisation and that you signed all 4 auth headers. |
| expired_timestamp | 401 | X-Timestamp drifted >5 min from server time. |
| nonce_reused | 409 | Same X-Nonce seen within the validity window. Use a fresh random value per request. |
| rate_limited | 429 | Exceeded the key's configured RPS budget. See Rate limits. |
| validation_error | 422 | Field present but invalid (range, format, length). details.field names the offending field. |
| insufficient_balance | 402 | Player can't afford the bet, deposit, or withdrawal. |
| not_found | 404 | Either the id doesn't exist or it belongs to another tenant and is hidden. |
| conflict | 409 | Lost an optimistic write or attempted a state transition that isn't allowed (e.g. cancelling a settled bet). |
| action_pending | 409 | Hosting only — server already has a queued lifecycle action (reboot/destroy/resize). Wait for it to finish. |
| sg_upstream_error | 502 | The upstream slot provider returned a non-200. The mirror is unchanged. Retry shortly. |
Retry guidance
- 429 — always honour
Retry-After. Exponential back-off otherwise. - 500 / 502 / 503 / 504 — safe to retry idempotent requests. For non-idempotent ones (e.g.
POST /v1/billing/deposit) use a uniqueX-Nonceand retry — server-side de-duplication will return the prior response. - 401 / 403 / 404 / 409 / 422 — never retry without changing the request. The same input will fail the same way.
Rate limits
Per-API-key, configurable, soft-fail with 429 and a Retry-After header.
Default budget
| Scope | Limit | Window | Configurable |
|---|---|---|---|
| API key (slots / gateway) | 50 RPS (default), 1 – 2000 (max) | per second, token-bucket with 1.5 × burst | Per key, via PATCH /v1/keys/{id} |
| Login / register / forgot | 5 attempts | per 15 min, per IP and per email | No — abuse protection. |
| TOTP verify | 5 failures | locks the account for 15 min | No — locks reset on next successful login + TOTP success. |
| Email verify resend | 3 | per hour, per email | No. |
| Hosting webhook test event | 1 | per 30 s, per endpoint | No. |
| Webhook digest test email | 1 | per hour, per user | No — anchored on notification_digest_state.last_sent_at. |
429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{ "ok": false, "error": {
"code": "rate_limited",
"message": "Too many requests. Slow down and retry in ~12 s.",
"details": { "retry_after_seconds": 12, "limit_rps": 50, "scope": "key" }
}}
// Headers: Retry-After: 12
{ ok: false, error: { code: 'rate_limited', message: '…', details: { retry_after_seconds: 12, limit_rps: 50, scope: 'key' } } }# r.status_code == 429; r.headers['Retry-After'] == '12'
{ 'ok': False, 'error': { 'code': 'rate_limited', 'details': { 'retry_after_seconds': 12 } } }Raising your budget
From the Dashboard, edit the key and set rate_limit_rps up to 2000. Above that, contact support — we'll discuss a dedicated capacity plan and pinned upstream slots quota.
Currencies & supported devises
Every monetary value in the API is denominated in one of the currencies below. Pass currency codes in upper-case ISO 4217 for fiat, in the canonical short form for crypto (with the network suffix where ambiguous).
Crypto — deposits, payouts, gateway
| Code | Name | Network | Min deposit (USD) | Max deposit (USD) | Decimals |
|---|---|---|---|---|---|
| BTC | Bitcoin | Bitcoin mainnet | $10 | $100,000 | 8 |
| BTC·LN | Bitcoin Lightning | Lightning Network | $5 | $5,000 | 8 (msat-precision) |
| ETH | Ether | Ethereum L1 | $10 | $100,000 | 18 |
| SOL | Solana | Solana L1 | $10 | $100,000 | 9 |
| USDT·TRC20 | Tether USD (TRON) | TRC-20 | $10 | $100,000 | 6 |
| USDC·ERC20 | USD Coin (Ethereum) | ERC-20 | $10 | $100,000 | 6 |
| USDC·SOL | USD Coin (Solana) | SPL | $10 | $100,000 | 6 |
| DASH | Dash | Dash mainnet | $10 | $100,000 | 8 |
src/lib/fx.js). The USD-bounded min/max therefore translates to a different native-asset minimum every minute.Fiat — wallet denomination & FX conversion
Player wallets are denominated in a single fiat currency you choose at account creation; bet/win/refund callbacks all move that currency. The list below is the universe of supported account-currency choices.
| Code | Name | Region |
|---|---|---|
| USD | US Dollar | Default. Used for all USD-denominated min/max bounds in this doc. |
| EUR | Euro | EEA |
| GBP | British Pound | UK |
| CAD | Canadian Dollar | Canada |
| AUD | Australian Dollar | Australia / Oceania |
| JPY | Japanese Yen | Japan (zero-decimal) |
| CHF | Swiss Franc | Switzerland |
| NOK / SEK / DKK | Nordics | NO / SE / DK |
| PLN / CZK / HUF / RON | CEE | Poland / Czech / Hungary / Romania |
| BRL | Brazilian Real | Brazil |
| MXN / ARS / CLP | LatAm | Mexico / Argentina / Chile |
| INR | Indian Rupee | India |
| TRY | Turkish Lira | Turkey |
| ZAR | South African Rand | South Africa |
FX rates are sourced from the European Central Bank reference set + on-chain oracles for crypto-fiat, cached for 60 s, and exposed through GET /v1/billing/currencies. Use that endpoint to drive UI dropdowns rather than hard-coding the list above.
Encoding amounts
Send amounts as decimal strings for crypto (e.g. "0.00025000") and as numbers for fiat (e.g. 25.00). Never round to fewer decimals than the table above — we will reject the request with 422 validation_error.
Auth endpoints
Account lifecycle, sign-in, email verification, password reset, and TOTP 2FA. Hosted at /v1/auth/*. All endpoints accept JSON and use session cookies on success — there is no JWT.
Create a new account. Sends a verification email containing a single-use token (24 h lifetime).
| Field | Type | Required | Description |
|---|---|---|---|
| string | yes | Valid email, ≤254 chars. Lower-cased for the unique index. | |
| password | string | yes | ≥10 chars, must include ≥2 character classes (lower, upper, digit, symbol). |
| company_name | string | no | Display name in the dashboard greeting. ≤120 chars. |
| country_code | string | no | ISO 3166-1 alpha-2. |
{ "ok": true, "user": { "id": "uuid", "email": "[email protected]", "status": "pending" } }
409 already_exists— email is taken422 validation_error— weak password / invalid email
curl -X POST https://api.softbet.io/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"AnExcellentPass1!","company_name":"Acme"}'await fetch('https://api.softbet.io/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, company_name: 'Acme' }),
});requests.post('https://api.softbet.io/v1/auth/register', json={
'email': '[email protected]', 'password': 'AnExcellentPass1!', 'company_name': 'Acme'
})Consume an email-verification token. On success, marks email_verified_at = now(), flips the user to active, and issues a session cookie.
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | yes | Verification token from the email link. |
401 invalid_token— already used, expired, or unknown
curl -X POST https://api.softbet.io/v1/auth/verify \
-H 'Content-Type: application/json' -d '{"token":"ver_…"}' -c cookies.txtawait fetch('/v1/auth/verify', { method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'}, body: JSON.stringify({ token }) });s.post('https://api.softbet.io/v1/auth/verify', json={'token': token})Sign in with email + password. On success sets a session cookie. If TOTP is enabled, returns { ok: true, totp_required: true, challenge: "…" } — call /v1/auth/totp/verify to complete.
| Field | Type | Required | Description |
|---|---|---|---|
| string | yes | Account email. | |
| password | string | yes | Account password. |
401 bad_credentials— wrong email or password403 email_not_verified— must click the verification link first429 rate_limited
curl -X POST https://api.softbet.io/v1/auth/login -c cookies.txt \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"·······"}'const r = await fetch('https://api.softbet.io/v1/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
}).then(r => r.json());
if (r.totp_required) { /* show TOTP screen, then POST /v1/auth/totp/verify */ }r = s.post('https://api.softbet.io/v1/auth/login',
json={'email': email, 'password': pw}).json()
if r.get('totp_required'):
s.post('https://api.softbet.io/v1/auth/totp/verify',
json={'challenge': r['challenge'], 'code': input('TOTP: ')})Return the currently-authenticated user. The canonical way to determine "am I logged in?" — returns { ok: false } on no session rather than 401, so the dashboard's first-paint flow doesn't have to swallow an error.
curl https://api.softbet.io/v1/auth/me -b cookies.txt
const me = await fetch('/v1/auth/me', { credentials: 'include' }).then(r => r.json());
if (me.ok) { /* signed in */ } else { /* signed out */ }me = s.get('https://api.softbet.io/v1/auth/me').json()Revoke the current session. Subsequent requests with the cookie return { ok: false, error: { code: 'unauthenticated' } }.
curl -X POST https://api.softbet.io/v1/auth/logout -b cookies.txt
await fetch('/v1/auth/logout', { method: 'POST', credentials: 'include' });s.post('https://api.softbet.io/v1/auth/logout')Request a password-reset email. Always returns 200 even if the email is unknown — prevents user enumeration. The email contains a single-use token valid for 1 h.
| string | yes | The account email. |
curl -X POST https://api.softbet.io/v1/auth/forgot \
-H 'Content-Type: application/json' -d '{"email":"[email protected]"}'await fetch('/v1/auth/forgot', { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ email }) });s.post('https://api.softbet.io/v1/auth/forgot', json={'email': email})Consume a password-reset token and set a new password. Revokes all existing sessions on the account.
| token | string | yes | Reset token from the email link. |
| password | string | yes | New password (same rules as register). |
curl -X POST https://api.softbet.io/v1/auth/reset \
-H 'Content-Type: application/json' \
-d '{"token":"rst_…","password":"NewExcellentPass1!"}'await fetch('/v1/auth/reset', { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token, password }) });s.post('https://api.softbet.io/v1/auth/reset', json={'token': token, 'password': pw})TOTP 2-factor authentication
Opt-in. Once enabled, every POST /v1/auth/login returns a challenge instead of a session — the client must then POST the 6-digit TOTP (or a backup code) to /v1/auth/totp/verify. PATs and webhook HMAC bypass 2FA (intentional). Per project-hosting-2fa-totp: otplib@13 async API, sha256-hashed single-use backup codes, 5-fail lockout for 15 min.
Returns { enabled: boolean, enabled_at: ts|null, backup_codes_remaining: int }.
Generate a pending TOTP secret + 10 backup codes. Returns { secret, otpauth_url, qr_svg, backup_codes: ["…", …] }. The setup is not active until the user proves possession by calling /totp/confirm.
Activate the pending TOTP setup by submitting a current 6-digit code. Flips totp_enabled = true.
| code | string | yes | 6-digit TOTP from the authenticator app. |
Complete a login that returned totp_required: true. Submit either a 6-digit TOTP or a backup code (backup codes are sha256-hashed and consumed on use).
| challenge | string | yes | Token returned by /v1/auth/login. |
| code | string | yes | 6-digit TOTP or 10-char backup code. |
Turn off TOTP. Requires the current TOTP code as proof. Revokes all other active sessions.
Replace all 10 backup codes with fresh ones. Previously-issued codes are immediately invalidated. Requires the current TOTP code.
API keys
Programmatic access to the slots / gateway surface (/api/v1/*). Each key has a public id (sent as X-API-Key) and a secret (used to compute the HMAC signature). Secrets are shown once at creation and on rotate; we store only the SHA-256 hash.
Create a key + secret pair. Up to 10 active keys per account.
| label | string | yes | Short human label, ≤80 chars. Used in the dashboard listing. |
| scopes | array of string | no | Subset of {games:read, games:launch, webhooks:receive}. Default = all three. |
| rate_limit_rps | integer | no | 1 – 2000, default 50. |
{ "ok": true, "key": {
"id": "bs_live_3f4a…", "secret": "bs_live_9k8j…", // ← shown ONCE
"label": "Production app", "scopes": ["games:read","games:launch","webhooks:receive"],
"rate_limit_rps": 50, "created_at": "2026-05-21T12:34:56Z"
} }
secret immediately. We will never display or re-issue it. To recover from a lost secret, call /v1/keys/{id}/rotate.curl -X POST https://api.softbet.io/v1/keys -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"label":"Production app","rate_limit_rps":200}'const k = await fetch('/v1/keys', { method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ label:'Production app', rate_limit_rps: 200 }) }).then(r=>r.json());
saveToVault(k.key.secret);k = s.post('https://api.softbet.io/v1/keys',
json={'label':'Production app','rate_limit_rps':200}).json()
save_to_vault(k['key']['secret'])List all keys on the account. Secrets are never returned.
curl https://api.softbet.io/v1/keys -b cookies.txt
const { keys } = await fetch('/v1/keys', { credentials:'include' }).then(r=>r.json());keys = s.get('https://api.softbet.io/v1/keys').json()['keys']Update mutable fields on an existing key.
| label | string | no | New label. |
| rate_limit_rps | integer | no | 1 – 2000. |
| scopes | array of string | no | Replace the scope set. Removing games:launch stops new launches immediately; existing sessions continue. |
Issue a new secret for the key. The old secret stops working immediately. The id stays the same so you only need to redeploy the secret, not change config.
{ "ok": true, "key": { "id": "bs_live_3f4a…", "secret": "bs_live_NEW…", "rotated_at": "…" } }
Revoke the key. Subsequent requests with that id return 401 unauthenticated. The id and label remain visible in audit history.
Slots & games
Catalog browsing, real-money and demo game launches, lobby views, freespin context. Hosted under /api/v1/* — all endpoints require API-key + HMAC authentication.
/games/init* launch requires a client-generated session_id (UUID-hex). Without it the upstream returns 400. The dashboard "test launch" page generates one for you; programmatic callers must mint their own. See project-sg-init-session-id.Raw upstream catalog passthrough. Returns the full per-game record (id, name, provider, image, RTP where available, paylines, freespin support, mobile flag). For programmatic catalog browsing, prefer /games/catalog which returns a stable, mirrored shape.
| page | integer | no | 1-based, default 1. |
| perPage | integer | no | 1 – 50, default 25. Capped at 50 by the upstream to dodge their staging flake. |
| provider | string | no | Filter to a single provider (e.g. pragmatic-play). |
| mobile | boolean | no | Mobile-only games when true. |
curl 'https://api.softbet.io/api/v1/games?page=1&perPage=25' \ -H "X-API-Key: …" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Sign: $SIG"
await fetch('https://api.softbet.io/api/v1/games?page=1&perPage=25', { headers: signedHeaders });requests.get('https://api.softbet.io/api/v1/games',
params={'page':1,'perPage':25}, headers=signed_headers)Tag taxonomy across the catalog (e.g. megaways, cluster-pays, buy-feature, jackpot). Cached server-side for 1 h. Note: the staging upstream returns 500 on this endpoint ~50% of the time; we serve the last successful response while degraded.
curl https://api.softbet.io/api/v1/game-tags -H "X-API-Key: …" -H "X-Sign: …" …
await fetch('/api/v1/game-tags', { headers: signedHeaders });requests.get('https://api.softbet.io/api/v1/game-tags', headers=signed_headers)Stable mirror of the catalog. Returned shape never breaks across upstream changes — we ETL the raw upstream into { games: [{ id, name, provider, system_category, tags, image_url, mobile, demo, rtp_pct, paylines, has_freespin }] }. Updated by a daily 03:00 UTC cron and on-demand by POST /v1/admin/catalog/sync (admin only).
| q | string | no | Free-text search over name + provider. |
| provider | string | no | Provider slug filter. |
| system_category | string | no | One of {slots, table, live, instant, bingo, virtual, poker, other}. |
| tag | string | no | Single tag filter. |
| limit | integer | no | 1 – 200, default 50. |
| cursor | string | no | Opaque cursor from the previous response. |
Launch a real-money game session for a player. Returns the game URL to iframe into your casino UI.
| game_uuid | string | yes | From the catalog. |
| player_id | string | yes | Your stable player id. We use this to dedupe and to scope balance callbacks. |
| session_id | string | yes | Client-generated UUID-hex, ≥16 chars. Without it the upstream returns 400. |
| currency | string | yes | The player's wallet currency (ISO 4217 or crypto code). |
| language | string | no | BCP-47 language tag, e.g. en, pt-BR. Default en. |
| return_url | string | no | Where the iframe redirects on "Back to lobby". |
{ "ok": true, "session": {
"id": "sess_…", "url": "https://launcher.softbet.io/…?token=…",
"expires_at": "2026-05-21T13:34:56Z"
} }
400 missing_session_id— generate a UUID-hex client-side and pass it402 insufficient_balance403 self_exclusion_active422 unsupported_currency502 sg_upstream_error— staging flake; retry once
curl -X POST https://api.softbet.io/api/v1/games/init \
-H "X-API-Key: …" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Sign: $SIG" \
-H "Content-Type: application/json" \
-d '{
"game_uuid": "5e0a…",
"player_id": "p_42",
"session_id": "'"$(openssl rand -hex 16)"'",
"currency": "USD",
"language": "en"
}'const session_id = crypto.randomUUID().replace(/-/g,'');
const r = await signedFetch('POST', '/api/v1/games/init', {
game_uuid, player_id, session_id, currency: 'USD',
}).then(r => r.json());
iframe.src = r.session.url;import secrets
r = signed_post('/api/v1/games/init', {
'game_uuid': game_uuid, 'player_id': player_id,
'session_id': secrets.token_hex(16), 'currency': 'USD',
}).json()
iframe_url = r['session']['url']Launch a demo (no-balance) game session. Same body shape as /init minus currency (always demo credits). Same session_id requirement.
Curated lobby — featured + new + popular + jackpot rails, each pre-paginated. Drop-in for a casino home page.
User-curated category list. Editable in Admin → Categories. Each category is a label + slug + ordered game list.
Games in a single category, in admin-curated order.
All available game studios with logos + game counts. Use to power a "browse by provider" tile grid.
Built-in system categories (slots / table / live / instant / bingo / virtual / poker / other) with localized labels.
Authoritative min/max bet, win, and session-loss caps as configured by the operator. Drive your UI sliders from this.
Freespin-specific caps: max spins per campaign, max bet-equivalent per spin, max winnings per spin.
Real-time progressive jackpot pool values across the catalog. Suitable for the ticker on a casino home page. Updated every 5 s.
Freespins
Grant a player N free spins on a specific game at a fixed bet amount. The spins are server-tracked through SoftBet — every spin's result is mirrored locally so you keep the full history even after SoftBet burns through the campaign.
/freespins/set returns 403 freespins_not_enabled, the upstream merchant node hasn't been provisioned for freespins — open a support ticket. Per project-sg-freespins-gate: the full freespin_campaigns infra (migration 044, routes, dashboard UIs) ships regardless and queues your campaign until enable.List individual freespin spin results. Each row is one spin: which campaign, which player, bet equivalent, win, timestamp.
| campaign_id | string | no | Restrict to a single campaign. |
| player_id | string | no | Restrict to one player. |
| from / to | ISO timestamp | no | Date range. |
| limit / cursor | — | no | Standard pagination. |
Issue a new freespin campaign to a player.
| campaign_id | string | yes | Your unique id, ≤80 chars. Re-using returns the existing campaign. |
| player_id | string | yes | Recipient player. |
| game_uuid | string | yes | The game on which the spins are valid. |
| spins | integer | yes | 1 – 1000. |
| bet_value | number | yes | Per-spin bet in the player's wallet currency. |
| valid_until | RFC-3339 UTC, trailing Z | no | Default = +14 days. Unconsumed spins expire here. Must be YYYY-MM-DDTHH:MM:SSZ (e.g. 2026-07-01T12:00:00Z; millis optional). A space-separated datetime, an offset-less ISO string, a date only, or a Unix epoch are rejected with 422 Invalid datetime. |
curl -X POST https://api.softbet.io/api/v1/freespins/set \
-H "X-API-Key: …" -H "X-Sign: …" \
-H "Content-Type: application/json" \
-d '{"campaign_id":"welcome_2026_05","player_id":"p_42","game_uuid":"5e0a…","spins":50,"bet_value":0.20}'await signedFetch('POST','/api/v1/freespins/set', {
campaign_id, player_id, game_uuid, spins: 50, bet_value: 0.2,
});signed_post('/api/v1/freespins/set', {
'campaign_id': cid, 'player_id': pid, 'game_uuid': uid,
'spins': 50, 'bet_value': 0.2,
})Fetch a single campaign's state.
| campaign_id | string | yes | The id you supplied to /set. |
Cancel an outstanding campaign. Unconsumed spins are voided. Already-consumed spins keep their bet/win history.
List all your active and past campaigns with per-campaign roll-up: spins used, total bet equivalent, total winnings, current ROI.
Dashboard mirror of the campaign list, accepting session-cookie auth instead of HMAC. Same response shape.
Create a campaign from the Dashboard. Same body shape as /api/v1/freespins/set.
Cancel from the dashboard.
Freevouchers
Single-use cash credits redeemable by code. Unlike freespins, freevouchers are not tied to a game — they're a balance top-up the player redeems explicitly.
Issue a voucher.
| voucher_code | string | yes | Your unique code. Shown to the player to redeem. |
| player_id | string | yes | Restrict redemption to this player. Pass "any" to allow open redemption (single-use overall). |
| amount | number | yes | Credit in the player's wallet currency. |
| currency | string | yes | ISO 4217 or crypto code. |
| valid_until | RFC-3339 UTC, trailing Z | no | Default = +30 days. Must be YYYY-MM-DDTHH:MM:SSZ (e.g. 2026-07-01T12:00:00Z; millis optional). Space-separated, offset-less, date-only or epoch values are rejected with 422 Invalid datetime. |
Lookup a voucher by code. Returns redemption state (unredeemed / redeemed / cancelled / expired).
Void an unredeemed voucher. No-op if already redeemed.
Self-exclusion
Responsible-gaming primitive. When a player self-excludes, all downstream actions (game launches, freespin sets, voucher redemptions, deposits) fail with 403 self_exclusion_active until the cool-off window expires.
Trigger self-exclusion for a player. Cannot be reversed by you — only expiry or a compliance-team admin action lifts it.
| duration_days | integer | yes | 1 – 3650 (10 years). Use -1 for permanent. |
| reason | string | no | Free-text, ≤500 chars. Stored for audit, never shown to other players. |
Check current self-exclusion state. Returns { active: bool, expires_at: ts|null, duration_days, set_at }.
Billing & deposits
Take crypto deposits from your players, convert at oracle spot, credit their fiat wallet. We handle the on-chain address, the confirmations, and the FX leg.
List supported crypto currencies, their networks, min/max deposit windows (in USD), and the four quick-pick suggested amounts ($25, $100, $500, $1,000). Drive your deposit-form UI from this endpoint, not from a hard-coded list — supported assets evolve.
curl https://api.softbet.io/v1/billing/currencies -b cookies.txt
const { currencies } = await fetch('/v1/billing/currencies',
{ credentials:'include' }).then(r => r.json());s.get('https://api.softbet.io/v1/billing/currencies').json()['currencies']Create a crypto deposit order. Returns a deposit address + QR + expiry — present those to the player, and we'll credit their wallet when the chain confirms.
| currency | string | yes | One of the codes from /v1/billing/currencies. |
| amount_usd | number | yes | $10 – $100,000. Converted to native at order time using the spot rate. |
| memo | string | no | Free-text note that travels with the deposit on your dashboard. |
{ "ok": true, "deposit": {
"id": "dep_…", "currency": "BTC", "amount_usd": 25.00,
"amount_native": "0.00041500", "address": "bc1q…",
"qr_svg_url": "https://…", "expires_at": "2026-05-21T13:34:56Z",
"confirmations_required": 1, "status": "awaiting_payment"
} }
curl -X POST https://api.softbet.io/v1/billing/deposit -b cookies.txt \
-H 'Content-Type: application/json' \
-d '{"currency":"BTC","amount_usd":25}'const { deposit } = await fetch('/v1/billing/deposit', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ currency:'BTC', amount_usd: 25 })
}).then(r => r.json());
showQr(deposit.qr_svg_url); pollUntil(deposit.id);d = s.post('https://api.softbet.io/v1/billing/deposit',
json={'currency':'BTC','amount_usd':25}).json()['deposit']List all deposit orders for the account.
| status | string | no | Filter by {awaiting_payment, confirming, credited, expired, failed}. |
| currency | string | no | Filter by currency code. |
| limit / cursor | — | no | Pagination. |
Fetch a single deposit's current state. Use this for polling, or subscribe to the billing.deposit.credited webhook for push-style updates.
Dashboard data
Operator-facing endpoints powering the dashboard UI: account config, wallet balance, usage metrics, transaction history, sessions, recent requests, integration probe. All require session-cookie auth.
Fetch operator settings: webhook URL, callback signing secret hash, default game language, low-balance alert threshold, default currency, notification preferences.
Update settings. Send only the fields you wish to change.
| webhook_url | string | no | HTTPS only, reachable from our infra, ≤2 s response. |
| low_balance_alert_usd | number | no | 0 to disable. |
| default_currency | string | no | Wallet currency for new players. |
| default_language | string | no | BCP-47. |
Operator account balance in USD across all funded currencies + per-currency breakdown.
Summary stats for the dashboard home: 24h/7d/30d GGR, active sessions count, top games, top providers, last 24h API request volume.
Time-bucketed API request volume + RTP slice for the usage chart.
| granularity | string | no | hour / day / week. Default day. |
| from / to | ISO timestamp | no | Default last 30 days. |
List recent transactions (bets, wins, refunds, rollbacks, deposits, payouts).
| player_id | string | no | Restrict to one player. |
| game_uuid | string | no | Restrict to one game. |
| type | string | no | {bet, win, refund, rollback, deposit, payout}. |
| failed | boolean | no | Only forward-failed ones. |
| from / to / limit / cursor | — | no | Standard. |
Single transaction with full upstream payload (received-from-SoftBet body, relayed-to-callback body, signing nonce, latency, response status). Powers the inspector modal.
List recent game sessions, oldest spin → latest spin, GGR per session.
All transactions on one game session, ordered by occurred_at.
Last 50 API requests on the account with method, path, status, latency. The "Live tester" output stream.
Server-side runs a signed self-validating request against any of your endpoints using your active API key. Returns the round-trip result for the integration checklist.
Gateway / Crypto
Take crypto deposits + send payouts on behalf of your end-users. Distinct from the simpler billing flow above — gateway is multi-tenant: you are the operator, your end-users are individual customers. Tatum is the underlying custody provider; we own KMS and address allocation.
PUT /v4/subscription {hmacSecret} on Tatum, not just in your .env. Per project-tatum-hmac-account-secret.Settings
Read gateway settings: callback URL, HMAC secret hash, supported currencies, payout approval policy.
Update gateway settings.
Keys
Create a gateway API key separate from the slots key. Same HMAC scheme.
List your gateway keys.
Revoke. Idempotent.
Balances + history
Per-currency available + reserved crypto balances aggregated across all end-users.
List deposit history. Filterable by end-user, currency, status, and date.
Single deposit with chain confirmations + KMS audit trail.
End-users
Register a new end-user with us. Your ref is the lookup key.
| ref | string | yes | Your unique id for this end-user, ≤80 chars. |
| string | no | Used for compliance correspondence. | |
| country_code | string | no | ISO 3166 alpha-2. |
Allocate (or reuse) a deposit address for the end-user in the requested currency. Idempotent on (ref, currency) — re-calling returns the same address.
Look up the current address (returns 404 if none allocated).
Payouts
Request a payout to an external address. Subject to the approval policy on your account (instant / single-approver / dual-approver). Pending payouts can be cancelled until approved.
| currency | string | yes | Currency code. |
| amount | string | yes | Native-units decimal string. |
| to_address | string | yes | Recipient. |
| end_user_ref | string | no | Attribute the payout to a specific end-user. |
| idempotency_key | string | yes | Your unique key. Re-using returns the prior payout. |
List payouts.
Single payout with on-chain tx id + approval audit trail.
DASH-specific (legacy)
Dash has its own short-circuit set of endpoints kept for historical contracts. Treat as deprecated for new integrations; use the unified /v1/gateway/* surface with currency: "DASH" instead.
DASH ops overview.
DASH-specific address allocation.
DASH-specific payout.
Hosting — Plans & images
Choose what hardware tier and base image to provision your VMs on. Read-only — Plans and Images are curated by us, you can't add custom ones via the API.
All hosting endpoints accept either a session cookie (dashboard) or Authorization: Bearer prv_… (PAT). Examples below show the PAT form for the most common server-to-server case.
List all hosting plans (CPU / RAM / disk / monthly price). Each plan has an id you pass to POST /v1/hosting/servers to provision at that tier.
curl https://api.softbet.io/v1/hosting/plans -H 'Authorization: Bearer prv_…'
await fetch('/v1/hosting/plans', { headers:{Authorization:'Bearer prv_…'}});requests.get('https://api.softbet.io/v1/hosting/plans',
headers={'Authorization':'Bearer prv_…'})Available base OS images (Debian 12, Ubuntu 24.04 LTS, Rocky 9, etc.). Each image has an image_code you pass at server creation.
Available CMS templates (WordPress, Ghost, Nextcloud, etc.) that can be installed on top of a provisioned server.
Hosting — Servers
Provision, query, mutate, and tear down virtual machines. Each server belongs to a tenant; PATs are tenant-scoped.
List your servers.
| status | string | no | {requested, building, running, stopped, destroyed, error} |
| tag | string | no | Filter by a tag from servers.tags. |
Provision a new server. Returns { id, status: "requested" } immediately; the orchestrator picks it up within a few seconds. Subscribe to server.provisioned webhook or poll GET /v1/hosting/servers/{id} until status: "running".
| name | string | yes | Unique within your tenant. Used as the VM hostname. |
| plan_id | integer | yes | From GET /v1/hosting/plans. |
| image_code | string | yes | From GET /v1/hosting/images. |
| tags | array of string | no | Free-form, used for filtering & alert rule scoping. |
| description | string | no | ≤500 chars. |
| restore_from_backup_id | uuid | no | Provision from a snapshot instead of the bare image. |
curl -X POST https://api.softbet.io/v1/hosting/servers \
-H 'Authorization: Bearer prv_…' -H 'Content-Type: application/json' \
-d '{"name":"web-01","plan_id":2,"image_code":"debian-12","tags":["prod"]}'const s = await fetch('/v1/hosting/servers', {
method:'POST', headers: { Authorization: 'Bearer prv_…',
'Content-Type':'application/json' },
body: JSON.stringify({ name:'web-01', plan_id:2, image_code:'debian-12' })
}).then(r => r.json());requests.post('https://api.softbet.io/v1/hosting/servers',
headers={'Authorization':'Bearer prv_…'},
json={'name':'web-01','plan_id':2,'image_code':'debian-12'})Single server detail (status, IP addresses, plan, image, tags, action_pending, errors).
Update mutable fields (name, description, tags). Not the plan — use /resize.
Queue a lifecycle action. The orchestrator picks it up asynchronously and writes the result back as a status change + audit row.
| action | string | yes | {start, stop, reboot, destroy}. |
409 action_pending— there's already a queued action on this server. Wait or queryaction_pending.
Change to a different plan (CPU/RAM/disk). The orchestrator stops the VM, resizes the disk + memory, starts it back up. Allow ~60 s of downtime.
| plan_id | integer | yes | New plan. Must be ≥ current disk size — grow-only. |
Reveal the SSH access credentials (bastion user / hostname / current password) for the server. Audit-logged.
Rotate the root SSH password. The new password is propagated to the VM immediately via the QEMU guest agent.
Time-bucketed CPU / RAM / disk-IO / network metrics for a server, last 24 h by default.
Per-server audit log (who did what, when, from which IP).
Setup module
Configurable post-provision setup: timezone, swap, motd, hostname override, extra packages. Strict allow-list per project-hosting-setup-allowlist: hostname regex, 35-entry TZ list, motd ≤500, swap 64–4096 MiB, 37-package list.
Get current setup state (dirty flag, last error, applied-at timestamp).
Configure setup. Sets setup_dirty = true; the orchestrator picks it up within ~11 s and applies (60 s cooldown gate per project-hosting-setup-isolation).
Force-apply without waiting for the cooldown.
Hosting — Volumes
Additional disks attached to a server. LVM-thin per-server; no cross-server move in v1.
List additional disks on the server.
Allocate & attach a new disk in one call (Proxmox qm set --scsiN <storage>:NG). The orchestrator picks the lowest free scsi1..30 slot.
| size_gb | integer | yes | 1 – 4096. |
| label | string | no | Display label, ≤80 chars. |
Resize an existing disk. Grow-only. The guest needs to growpart + filesystem-resize.
Detach + destroy the disk (data is unrecoverable). Idempotent.
Hosting — Firewall
Per-server stateless L3/L4 firewall rules enforced at the Proxmox bridge. Default-deny when enabled.
Get the current rule set + enabled flag.
Enable/disable the firewall.
Add a rule.
| direction | string | yes | {in, out} |
| action | string | yes | {accept, drop, reject} |
| protocol | string | no | {tcp, udp, icmp, any}. Default any. |
| source | string (CIDR) | no | Default 0.0.0.0/0. |
| dest_port | string | no | Single port ("443") or range ("8000-8080"). |
| position | integer | no | Insert index. Default = append. |
Update a rule.
Delete a rule.
Replace the rule set with a named preset (web, web+ssh, db-private, locked-down, etc.).
Hosting — DNS & domains
Attach domains to a server, point them at our nameservers (ns1.softbet.io, ns2.softbet.io), manage records through PowerDNS (CT110 master + CT111 slave). 100-record quota per domain. Per project-hosting-dns-permanent-error: 4xx upstream errors mark dirty=false; customer edits the record or hits POST /retry.
List domains attached to a server.
Attach a new domain. We seed the zone with sensible defaults (A → server IP, MX null, SPF rejecting all).
| domain | string | yes | FQDN, lower-case, no trailing dot. |
Detach a domain.
Force a PowerDNS sync retry on a domain stuck in a permanent-error state.
List DNS records in the zone.
Create a record. Validation prevents apex CNAME, malformed FQDN, unquoted TXT, reserved names.
| name | string | yes | Hostname, e.g. www. "@" = apex. |
| type | string | yes | {A, AAAA, CNAME, MX, TXT, SRV, CAA, NS} |
| content | string | yes | Type-dependent. TXT values must be quoted. |
| ttl | integer | no | 60 – 86400 s. Default 300. |
| priority | integer | no | MX/SRV only. |
Update a record.
Delete a record.
Hosting — SSH keys
Account-level key library + per-server attach/detach. Adding/removing a key on a server propagates via the QEMU guest agent (queued-detach on server transfer per project-hosting-transfer-atomic).
List account-level SSH keys.
Upload a new public key. Accepts ssh-ed25519 or ssh-rsa ≥2048 bits.
| label | string | yes | ≤80 chars. |
| public_key | string | yes | OpenSSH single-line format. |
Delete a key from the account library. Any servers it's attached to keep the key until you also call DELETE /v1/hosting/servers/{id}/ssh-keys/{kid}.
List keys attached to a server.
Attach an account key to a server's authorized_keys.
Detach from authorized_keys.
Hosting — Snapshots & backups
Two distinct primitives. Snapshots are local Proxmox volume snapshots (fast, cheap, deleted with the server). Backups are off-host archive copies (slower, durable, survive server destroy, billed separately).
Snapshots
List existing snapshots, oldest first.
Take a snapshot now.
| label | string | no | Display label, ≤80 chars. |
Roll the server's disk back to a snapshot. The VM is stopped, rolled back, started.
Delete a snapshot.
Backups
List archived backups.
Trigger an on-demand backup. Sent to off-host storage.
Delete an archived backup.
Configure the recurring backup job (schedule, retention count).
| enabled | boolean | yes | Turn the job on/off. |
| schedule | string | no | cron-like (5-field). Default 0 3 * * * (daily 03:00 UTC). |
| keep | integer | no | 1 – 30. Default 7. Older copies auto-deleted. |
Hosting — Alerts & notifications
Define rules ("CPU > 80 % for 5 min", "free disk < 10 %") on your servers and we'll route firings to in-app, email-instant, or email-daily-digest channels per project-hosting-notif-digest.
Alert rules
List alert rules on a server.
Add a rule.
| metric | string | yes | {cpu_pct, mem_pct, disk_pct, load_5m, net_in_mbps, net_out_mbps} |
| op | string | yes | {gt, lt, gte, lte} |
| threshold | number | yes | Compare value. |
| for_seconds | integer | no | Sustained-duration requirement. Default 60. |
| cooldown_seconds | integer | no | Don't re-fire within this window. Default 600. |
Update a rule.
Delete a rule.
Synthesise a firing of this rule (delivered through the full notification pipeline). Rate-limited to 1 per 30 s per rule.
Notifications
List in-app notifications.
| unread_only | boolean | no | If 1, returns only unread. |
| limit | integer | no | 1 – 200. |
Mark one notification as read.
Mark all unread as read.
Read notification settings (per-event-type channel = off / inapp / email / daily).
Update settings.
Send a test email to verify deliverability. Rate-limited to 1 / hour per user.
Hosting — CMS install
One-click install of WordPress / Ghost / Nextcloud / etc. on a provisioned server. Installs are orchestrated server-side; you poll the install record for completion.
List CMS apps currently installed on the server.
Install a CMS template from the catalog.
| template_id | string | yes | From /v1/hosting/cms/catalog. |
| domain | string | no | FQDN to bind. Must already be attached to the server. |
| admin_email | string | no | Initial admin user email. |
Uninstall a CMS app + clean up its files / databases / vhosts.
Hosting — API tokens (PAT)
Create tokens for unattended automation. Tokens prefix prv_, are tenant-scoped, shown once. Per project-hosting-pat-scope, the URL-prefix guard rejects PATs on any path that isn't /v1/hosting/*.
List active tokens (token strings are not returned, only id + label + last_used).
Create a new token. Returns the secret once.
| label | string | yes | ≤80 chars. |
| expires_at | ISO timestamp | no | Default = never. Recommended ≤180 days for least-privilege. |
Revoke a token. Subsequent requests with that token return 401.
Hosting — Webhook subscriber endpoints
Manage YOUR HTTPS endpoints that receive our outbound hosting webhooks. Per project-hosting-webhooks-fanout, the dispatcher matches subscribers by tenant_id; rows with NULL tenant are silently dropped (intentional — that's only audit/alert data). For the wire format of the events you'll receive, see Webhooks → Outbound.
List your registered endpoints.
Register a new HTTPS receiver. Returns the signing secret once. Use it in your HMAC verification on every received event.
| url | string | yes | HTTPS. Must respond <2 s. |
| event_types | array of string | no | Subset of supported events. Default = all. |
| label | string | no | Display label. |
Update url, event_types, label.
Generate a fresh signing secret. Returned once.
Delete the endpoint. No future events are delivered to it.
List recent delivery attempts: event id, type, HTTP status, response body, retry-count, age. Useful for debugging "your endpoint returned a 500" issues.
Send a synthetic webhook.test event to the endpoint. Rate-limited to 1 per 30 s.
Sportsbook — Player API
Real-money sports betting. Sessions are short-lived per player and signed with the same HMAC scheme as slots.
Mint a player session token (15 min lifetime). Subsequent calls pass it as Authorization: Bearer sb_….
| player_id | string | yes | Stable player id. |
| currency | string | yes | Wallet currency. |
| locale | string | no | BCP-47. |
Current player session (balance, limits, open bet count).
List sports + counts of live + upcoming events.
List events.
| sport | string | no | Sport slug. |
| league | string | no | League slug. |
| state | string | no | {live, upcoming, finished} |
Full event with all markets + selections + current odds.
Place a bet (single, multiple, system).
| bet_ref | string | yes | Your unique bet id. |
| stake | number | yes | In wallet currency. |
| selections | array | yes | Each: { event_id, market_id, selection_id, odds }. Submit the odds you displayed — if they have moved, you get 409 odds_changed with the new odds in details; re-submit with the new value to confirm. |
| type | string | no | {single, multiple, system}. Default inferred from selection count. |
My bets — pending + settled.
Live cashout price for a pending bet. Returns { available: bool, amount, expires_at }. Quote is valid ~3 s.
Accept a cashout offer. Pass the amount from the offer — if it has drifted, returns 409 cashout_price_changed.
Submit a list of currently-displayed odds and we return only the ones that have moved. Suitable for a 5-second polling loop on a coupon page.
Sportsbook — Operator settings
Operator-side configuration for the sportsbook product (margins, embed code generation, dashboard toggles).
Read settings.
Update settings.
| margin_bps | integer | no | Operator margin in basis points (0 – 1500). |
| auto_cashout_threshold | number | no | Auto-cashout offered when bet value ≥ this fraction of potential payout. |
| max_stake_usd | number | no | Operator-wide single-bet cap. |
Returns a drop-in <script> + <div id="sb-mount"> snippet you can paste into your casino's HTML to render the full sportsbook UI.
Webhooks — Outbound (we send)
Events SoftBet POSTs to your registered endpoint. JSON body, signed with HMAC-SHA256 in the X-Provide-Hosting-Signature header (also used by the Phase E2 alerts pipeline — one format for both).
Common envelope
POST /your-webhook-receiver HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: SoftBet-Webhook/1.0
X-Provide-Hosting-Signature: sha256=<hex>
X-Event-Type: server.provisioned
X-Event-Id: evt_01HW…
X-Event-Delivery: dlv_01HW… # unique per delivery attempt; same event_id retries get fresh delivery
{ "event_id": "evt_…", "event_type": "server.provisioned",
"occurred_at": "2026-05-21T12:34:56.789Z",
"tenant_id": "uuid",
"data": { …event-specific payload… } }
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// IMPORTANT: read the raw body — JSON.parse alters whitespace and breaks the signature.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-provide-hosting-signature'] || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body);
switch (event.event_type) {
case 'server.provisioned': /* … */ break;
case 'alert.fired': /* … */ break;
}
res.sendStatus(204);
});
import os, hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/webhook')
def webhook():
raw = request.get_data() # raw bytes — needed for HMAC
sig = request.headers.get('X-Provide-Hosting-Signature', '')
expected = 'sha256=' + hmac.new(
os.environ['WEBHOOK_SECRET'].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
if event['event_type'] == 'server.provisioned':
...
return '', 204
Event types
| event_type | When fired | Key fields in data |
|---|---|---|
server.provisioned | Newly-created VM transitions to running for the first time. | server_id, name, plan_id, image_code, ipv4, ipv6 |
server.resized | Resize completed. Includes before/after plans. | server_id, from_plan_id, to_plan_id |
server.destroyed | Server irreversibly deleted. | server_id, name, destroyed_at |
cms.installed | CMS install finished successfully. | server_id, install_id, template_id, url |
cms.removed | CMS uninstall finished. | server_id, install_id |
alert.fired | An alert rule's threshold was breached for the required duration. | server_id, rule_id, metric, threshold, value, started_at |
alert.resolved | Metric returned below threshold after a firing. | server_id, rule_id, resolved_at, duration_seconds |
webhook.test | Synthetic event triggered by you via POST /webhook_endpoints/{id}/test. | at, endpoint_id |
Delivery, retries, ordering
- Delivery is at-least-once. Your receiver must be idempotent on
event_id. - Retries on non-2xx: 5 attempts with exponential back-off (~30 s, 2 min, 10 min, 30 min, 2 h). After the 5th failure the delivery is marked
deadand surfaced in/deliveries. - Ordering is not guaranteed. Use
occurred_atto order; ignore stale events with olderoccurred_at. - Timeout: your endpoint has 2 s to respond. Longer = treated as failure.
Webhooks — Inbound (slot callbacks)
When a player bets / wins / refunds / rolls back on one of your slot games, we POST the event to your callback URL. The wire format is preserved byte-for-byte compatible with the upstream provider's contract so any existing integration migrates without code changes.
Callback URL
Configure under Dashboard → Settings → Webhook URL. HTTPS only, ≤2 s response, must return HTTP 200 with a JSON body acknowledging the action — failure to ack causes the wallet to refuse the bet (which then reverses on the upstream).
Signature header
Every request carries X-Signature: <hex>. The signature is HMAC-SHA1(rawBody, <your API secret>). Verify before processing.
Event types you'll receive
| type | Meaning | Must return |
|---|---|---|
bet | Player wagers an amount — may be 0 (bonus-buy feature spins, see below). | { ok: true, balance: 123.45 } — debit the wallet first, then ack with the new balance. |
win | Player wins an amount (can be 0). | { ok: true, balance: 130.00 } — credit then ack. |
refund | A previously-acked bet didn't actually happen (game disconnect, etc.). | { ok: true, balance: … } — credit then ack. |
rollback | Reverse an already-completed bet+win pair (compliance / chargeback). | { ok: true, balance: … } — undo the net effect and ack. |
Bonus buy & zero-amount callbacks
You will receive bet (and sometimes win) callbacks with amount: 0. This is normal: in a bonus buy the player pays once for the feature, then each feature spin/choice arrives as a bet with amount = 0 (the round stays open until the feature ends). Zero-win spins arrive as win with amount = 0.
bad_amount / RC_INVALID_AMOUNT for amount = 0 stalls the whole round: the game retries the same spin and the player is stuck in a bonus-buy loop.bet/win we confirm your balance with a balance probe and ack the round so it still advances — but the contract is that you accept amount = 0 directly. Build for it; don't rely on the safety net.Example: bet callback
POST https://your-casino.example/wallet-callback
Content-Type: application/json
X-Signature: a1b2c3…
{ "type": "bet",
"transaction_id": "sb_tx_…",
"round_id": "sb_round_…",
"related_transaction_id": null,
"player_id": "p_42",
"game_uuid": "5e0a…",
"session_id": "sess_…",
"amount": 0.50,
"currency": "USD",
"occurred_at": "2026-05-21T12:34:56.789Z" }
import crypto from 'node:crypto';
app.post('/wallet-callback', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['x-signature'];
const expected = crypto.createHmac('sha1', process.env.SOFTBET_SECRET)
.update(req.body).digest('hex');
if (sig !== expected) return res.status(401).json({ ok: false, error: 'bad_signature' });
const evt = JSON.parse(req.body);
const balance = await wallet.applyAndReturnBalance(evt);
res.json({ ok: true, balance });
});
@app.post('/wallet-callback')
def cb():
raw = request.get_data()
expected = hmac.new(SOFTBET_SECRET.encode(), raw, hashlib.sha1).hexdigest()
if request.headers.get('X-Signature') != expected:
return {'ok': False, 'error': 'bad_signature'}, 401
evt = request.get_json()
bal = wallet.apply_and_return_balance(evt)
return {'ok': True, 'balance': bal}
Idempotency
Always dedupe on transaction_id. If you've already processed that id, return the previously-computed balance without re-applying — never double-debit on a retry. We retry failed callbacks with the same transaction_id for up to 24 h.
Refund / rollback must be idempotent. If you receive a refund/rollback you have already applied, do not error — ack with { ok: true, balance } (the current balance). Returning a generic failure or a duplicate/conflict error makes us retry the refund indefinitely, so the player never settles. Key your refund idempotency on the refund's own transaction_id, not on the parent bet's related_transaction_id.
HMAC signing reference
The canonicalisation algorithm used for slot/gateway API requests. Source: /opt/bc/api/src/lib/sg-sign.js (canonicalQuery) + customer-hmac.js.
Algorithm
- Collect all request
params(query-string for GET, JSON body keys for POST/PUT/PATCH). - Add the four auth header values:
X-API-Key,X-Timestamp,X-Nonce(NOTX-Sign). - Sort all keys lexicographically (case-sensitive).
- URL-encode BOTH keys and values using
application/x-www-form-urlencodedrules (PHPhttp_build_query/ RFC 1738) — NOT RFC 3986. The one rule that bites most integrators: a space encodes to+, never%20. Concretely, the server doesencodeURIComponent(v)then replaces%20→+and percent-encodes! * ' ( )→%21 %2A %27 %28 %29. Unreserved- _ . ~stay literal;:/etc. are percent-encoded. - Join as
key=value&key=value&…. - Compute
HMAC-SHA1(canonical, secret). - Encode as lowercase hex.
- Send as
X-Sign.
Space → +: if any signed value contains a space (e.g. a campaign name or player_name), it MUST be encoded as +. Using %20 (RFC 3986 / Python quote()) yields a different canonical string and the request is rejected with 401 hmac_bad_signature.
Worked example
Suppose you're calling GET /api/v1/games?page=1&perPage=25 with:
X-API-Key: bs_live_3f4aX-Timestamp: 1779400000X-Nonce: 9f7e1c8a- secret:
bs_live_S3CR3T
Canonical string (sorted by key):
X-API-Key=bs_live_3f4a&X-Nonce=9f7e1c8a&X-Timestamp=1779400000&page=1&perPage=25
Signature:
HMAC-SHA1(canonical, "bs_live_S3CR3T") = e4f8a92c1d…
Example with a space in a value — POST /api/v1/freespins/set with name=weekend promo. The space becomes + in the canonical string:
…¤cy=EUR&freespin_bet=0.40&freespins_count=50&game_uuid=0b6e…&name=weekend+promo&player_id=p_42&…
Reference implementations
canon='X-API-Key=bs_live_3f4a&X-Nonce=9f7e1c8a&X-Timestamp=1779400000&page=1&perPage=25' echo -n "$canon" | openssl dgst -sha1 -hmac "bs_live_S3CR3T" -hex | cut -d' ' -f2
import crypto from 'node:crypto';
const encode = v => encodeURIComponent(String(v))
.replace(/%20/g, '+') // form-encoding (RFC 1738): space → +
.replace(/[!*'()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
function sign(params, secret) {
const keys = Object.keys(params).sort();
// encode BOTH keys and values
const canon = keys.map(k => `${encode(k)}=${encode(params[k])}`).join('&');
return crypto.createHmac('sha1', secret).update(canon).digest('hex');
}
import hmac, hashlib
from urllib.parse import quote_plus # quote_plus → space becomes '+'
def sign(params, secret):
# encode BOTH keys and values; quote_plus matches the server's form-encoding
canon = '&'.join(
f'{quote_plus(str(k), safe="")}={quote_plus(str(params[k]), safe="")}'
for k in sorted(params)
)
return hmac.new(secret.encode(), canon.encode(), hashlib.sha1).hexdigest()
Common signing pitfalls
- Encoding —
application/x-www-form-urlencoded(RFC 1738), NOT RFC 3986. Space →+(use JSencodeURIComponent(v).replace(/%20/g,'+')or Pythonquote_plus(v, safe='')— plainquote()emits%20and will fail). Also encode!*'(). - Sort order — case-sensitive lexicographic.
X-API-Keysorts beforepagebecause'X' < 'p'in ASCII. - Header inclusion — include
X-API-Key,X-Timestamp,X-Nonce; do NOT includeX-Sign(chicken-and-egg). - Number coercion — coerce all values to strings before encoding; integers vs strings produce different signatures.
- Body vs query — for POST with a JSON body, sign the body's top-level scalar fields. Nested objects/arrays are excluded from the canonical and must be hashed separately if you need their integrity protected.
Changelog
Documentation revisions and notable API changes. Endpoint deprecations are announced here 90 days before removal.
2026-05-21
- Initial reference build. 100+ endpoints across auth, slots, freespins, billing, dashboard, gateway, hosting (12 sub-areas), and sportsbook.
- Whitelabel rebrand: dashboard + admin UI no longer expose the upstream slot-provider name. Public contract unchanged (route paths, callback shape, HMAC algorithm all preserved).
- Hosting Phase F4.5 daily digest documented under Alerts & notifications.