Real traffic.
Real counts.
Server-to-server reference for signing, the game catalog, launching Chaos Lane (real or fun play), round evidence, player statistics, the seamless wallet, webhooks, promotions and leaderboards. Chaos Lane results come from vehicles counted crossing a line on live road cameras, never from a random number generator.
Production base URL is issued at go-live.
Every path already starts with /v1; do not add another to the base URL.
Strict schemas: unknown fields are rejected with BAD_REQUEST.
You → UrbanRats
Signed API calls: catalog, launch, bets, rounds and evidence, promotions, webhook management.
UrbanRats → you
Signed seamless-wallet callbacks (/balance, /bet, /win, /rollback, /status) and signed webhooks. The player balance always stays in your wallet.
Sign every API request
Requests to UrbanRats are signed with Ed25519 (recommended) or HMAC-SHA256. Choose one per key at onboarding; the headers and the canonical request are the same, only the first canonical line and the signature differ. With Ed25519 you keep the private key and we store only your public key, so a leak on our side can never be used to impersonate you.
| Header | Type | Req. | Description |
|---|---|---|---|
| x-ur-key-id | string | Yes | Your key id (sandbox keys are issued in the developer portal). |
| x-ur-timestamp | integer | Yes | Unix time in milliseconds; within ±60 s of our clock. |
| x-ur-nonce | string | Yes | 16–64 chars [A-Za-z0-9_-]; single use per key. |
| x-ur-signature | base64 | Yes | Ed25519 signature (or HMAC-SHA256, base64) of the canonical request. |
Canonical request
Lines joined with \n, no trailing newline. The canonical query form-decodes the query string, encodes every key and value with encodeURIComponent, sorts by key then value and joins with & (empty line when there is no query).
Keys and scopes
Self-service portal: with your partner account (ops.urbanrats.com, password + authenticator app) you register your own Ed25519 public key, issue and rotate sandbox keys, generate your wallet signing secrets yourself (shown once, only to you), request your wallet URL, wire scales and callback / webhook hosts (applied after our approval), manage webhooks and run the conformance suite. Your own key (recommended): keep the private key; nothing secret is exchanged. Scopes: games:read launch:create bets:read rounds:read rewards:write webhooks:manage. Optional IP allowlist per key. Sandbox keys rotate in the portal with a 1-hour overlap; for production you generate the pair and submit the public key, which a second UrbanRats user approves.
Retries
A reused nonce is refused (NONCE_REPLAY). Retry a business operation with a new nonce and the same business ids; idempotency returns the first result.
HMAC-SHA256 keys LIVE
For operators who prefer a shared secret. We generate the secret and show it once when the key is issued; we keep it encrypted at rest. Build the same canonical request with the first line UR1-HMAC-REQUEST and send x-ur-signature = base64(HMAC-SHA256(secret, canonical)). Same headers, scopes, nonce and timestamp rules, IP allowlist and rotation as Ed25519. Rotation issues a new secret (also shown once) with an overlap. Wallet callbacks use their own HMAC secrets (section 06).
UR1-ED25519-REQUEST # HMAC keys: UR1-HMAC-REQUEST
METHOD
EXACT_PATH_WITHOUT_QUERY
CANONICAL_QUERY
LOWERCASE_SHA256_HEX_OF_EXACT_BODY
TIMESTAMP_MS
NONCE
KEY_IDimport { createHash, createPrivateKey, randomBytes, sign } from 'node:crypto';
const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
const canonicalQuery = (search) => [...new URLSearchParams(search)]
.map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)])
.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0))
.map(([k, v]) => `${k}=${v}`).join('&');
export function signedHeaders(method, url, body, keyId, privateKeyPem) {
const u = new URL(url), ts = String(Date.now());
const nonce = randomBytes(18).toString('base64url');
const canonical = ['UR1-ED25519-REQUEST', method.toUpperCase(), u.pathname,
canonicalQuery(u.search), sha256(body ?? ''), ts, nonce, keyId].join('\n');
const sig = sign(null, Buffer.from(canonical), createPrivateKey(privateKeyPem));
return { 'x-ur-key-id': keyId, 'x-ur-timestamp': ts, 'x-ur-nonce': nonce,
'x-ur-signature': sig.toString('base64') };
}# TEST key (public seed 0x01..0x20) — never use it for anything real
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g
-----END PRIVATE KEY-----
POST /v1/launch-sessions
body {"brandId":"alpha-eu","externalPlayerId":"p-1","gameId":"traffic-pulse","country":"DE"}
ts 1790000000000 nonce n0nce-0000000000001 keyId sbx-test-vector
sha256(body) 3f10f49bbecdb60e6f7bca6f95ad2343a832d1671ab1d84e25e34a1ccc79e280
signature OOWcqLvAtoYJKU/Yeas+zkGcRxvhbZUbv9g0Ytd7Izf7nhhjIDFSNSdVWY1hubCgjW/T3ux672f49ZEWzlGwBw==
GET /v1/bets?state=SETTLED&externalPlayerId=p+1%2F%C3%A4&limit=10
ts 1790000000500 nonce n0nce-0000000000002
canonical query externalPlayerId=p%201%2F%C3%A4&limit=10&state=SETTLED
signature vNxyJO/2jBDsZIIeqcWfzWfu8fjR1BbnfvB2AjRtD3gSz7JKDd9fUYI7HKUepekKbs6bJHjhDPXXRa+fE+KwCQ==import { createHmac } from 'node:crypto';
// same canonical lines as above, first line UR1-HMAC-REQUEST
const canonical = ['UR1-HMAC-REQUEST', method.toUpperCase(), u.pathname,
canonicalQuery(u.search), sha256(body ?? ''), ts, nonce, keyId].join('\n');
const signature = createHmac('sha256', secret).update(canonical).digest('base64');# TEST secret — never use it for anything real
secret ur-test-hmac-secret-do-not-use
POST /v1/launch-sessions
body {"brandId":"alpha-eu","externalPlayerId":"p-1","gameId":"traffic-pulse","country":"DE"}
ts 1790000000000 nonce n0nce-0000000000001 keyId sbx-test-hmac
sha256(body) 3f10f49bbecdb60e6f7bca6f95ad2343a832d1671ab1d84e25e34a1ccc79e280
signature 2dDwMfMk4py9J1CdXEQpPhwa/yQXRSGLm0NyAEALnMQ=Get your games
Signed: the games your brands are entitled to, with the countries allowed per brand. Unsigned: the public catalogue (no entitlements).
| Field | Type | Req. | Description |
|---|---|---|---|
| gameId | string | Yes | Stable game identity; send it as gameId at launch. Chaos Lane is traffic-pulse. |
| title | string | Yes | Display name. |
| rulesVersion | string | Yes | Version of the published rules. |
| mechanic | string | Yes | TOTAL_COUNT_BANDS: bet on how many vehicles cross the line in the arrow's direction. |
| readiness | string | Yes | Source readiness of the game (e.g. SIMULATION_ONLY until a monetisable camera source is approved). |
| realMoneyEnabled | boolean | Yes | true when at least one of your entitled sites runs real money (per site: entitlements[].moneyMode, section 03d). |
| requiredSourceCapability | string | Yes | Camera capability the game needs (VIDEO_FIXED_ROAD). |
| entitlements[] | object[] | Signed | {brandId, countries[]}: where each of your brands may launch the game. |
GET /v1/games HTTP/1.1
x-ur-key-id: sbx-acme-01
x-ur-timestamp: 1790000000000
x-ur-nonce: 3b7f0c1e9a4d4f2bb8a1
x-ur-signature: <base64 Ed25519 signature>{
"games": [{
"gameId": "traffic-pulse",
"title": "Chaos Lane",
"rulesVersion": "tp-rules-0.1",
"mechanic": "TOTAL_COUNT_BANDS",
"requiredSourceCapability": "VIDEO_FIXED_ROAD",
"readiness": "SIMULATION_ONLY",
"realMoneyEnabled": false,
"entitlements": [{ "brandId": "acme-eu", "countries": ["DE", "AT"], "moneyMode": "DEMO" }]
}]
}Create a game session
Checks the brand, the game entitlement, the declared country and the currency, then returns a one-time launch URL for the player.
| Field | Type | Req. | Description |
|---|---|---|---|
| brandId | string | Yes | Your brand (site) id from onboarding. |
| externalPlayerId | string | Yes | Stable player id in your system. No personal data. |
| gameId | string | Yes | From GET /v1/games — today only traffic-pulse (Chaos Lane). Do not hard-code a game list: retired games return GAME_RETIRED. |
| country | ISO 3166-1 | Yes | This player's jurisdiction, declared per launch — never a fixed setting (we do not geolocate). An assigned ISO 3166-1 alpha-2 code (unknown codes: BAD_REQUEST). KYC stays with the operator. |
| currency | string | No | Assertion only: the wallet currency is the brand's; a different value returns CURRENCY_MISMATCH. |
| displayCurrency | string | No | Presentation only; must be enabled for the brand. |
| language | xx / xx-XX | No | UI language, e.g. en, tr, de-DE. |
| allowedOrigin | origin | No | Exact origin of the page embedding the game; must be in the brand allowlist. |
| operatorToken | string | Recommended | Your own wallet-session token (≤ 512 printable ASCII). We store it with the session and send it on every wallet callback, so you can refuse stakes from a session you closed and trace which of your sessions a bet came from. |
| device | enum | No | desktop or mobile; never inferred. |
| lobbyUrl · depositUrl · returnUrl | URL | No | Absolute https URLs (≤ 2048, no credentials). When the session ends the game offers “Back to the site” with lobbyUrl. Omitted → the site defaults set at onboarding. |
| siteName | string | No | Aggregators: display name of the site sent as brandId (section 03c). |
| launchUrl | URL | Response | Open in the player client (iframe or new window). |
| launchCode | string | Response | Single use, valid for expiresInS (60 s). Create it when the player presses Play. |
| brandId | string | Response | Our brand id for this launch (for aggregators: the brand of the site and currency, e.g. site-42.eur). |
GAME_NOT_ENTITLED enable the game for the brand · COUNTRY_NOT_ALLOWED the declared country is not allowed · CURRENCY_MISMATCH use the brand wallet currency · DISPLAY_CURRENCY_NOT_ALLOWED / ORIGIN_NOT_ALLOWED enable it for the brand · 503 REAL_MONEY_UNAVAILABLE real-money site without a connected wallet (retry after it is fixed). Only RATE_LIMITED (429) and 5xx are worth retrying with backoff. A failed launch never returns a URL.OPERATOR_SUSPENDED / BRAND_SUSPENDED.POST /v1/launch-sessions HTTP/1.1
Content-Type: application/json
x-ur-key-id: sbx-acme-01
x-ur-timestamp: 1790000000000
x-ur-nonce: 9c1d2e3f4a5b6c7d8e9f
x-ur-signature: <base64 Ed25519 signature>
{
"brandId": "acme-eu",
"externalPlayerId": "player-10293",
"gameId": "traffic-pulse",
"country": "DE",
"currency": "EUR",
"language": "de",
"allowedOrigin": "https://casino.example",
"operatorToken": "wallet-session-8f2c…",
"device": "mobile",
"lobbyUrl": "https://casino.example/lobby",
"depositUrl": "https://casino.example/deposit"
}{
"launchCode": "lc_…",
"launchUrl": "https://play.urbanrats.com/?launch=lc_…",
"expiresInS": 60,
"currency": "EUR",
"displayCurrency": "EUR"
}Launch a demo session without the wallet
For a “Play for fun” button or a site showcase. Same checks as a real launch (brand, entitlement, country, origin) but no player id: we create an anonymous player demo:… with play money on our own demo wallet. No wallet callback is ever sent to you for a demo session, even when your brand uses the seamless wallet. The game shows a “FUN PLAY” label.
| Field | Type | Req. | Description |
|---|---|---|---|
| brandId | string | Yes | Your brand (site) id. |
| gameId | string | Yes | From GET /v1/games. |
| country | ISO 3166-1 | Yes | Declared jurisdiction; fun play obeys the same country rules. |
| language | xx / xx-XX | No | UI language. |
| allowedOrigin | origin | No | Exact embedding origin (brand allowlist). |
| device | enum | No | desktop or mobile. |
| lobbyUrl | URL | No | Absolute https; “Back to the site” target. |
| currency · siteName | string | No | Aggregators: the site's wallet currency and name (section 03c). |
demo: are reserved: a real launch with such an id is refused with DEMO_PLAYER_RESERVED. Any other field (e.g. externalPlayerId) is rejected with BAD_REQUEST.{
"brandId": "acme-eu",
"gameId": "traffic-pulse",
"country": "DE",
"language": "de",
"device": "desktop"
}{
"launchCode": "lc_…",
"launchUrl": "https://play.urbanrats.com/?launch=lc_…&game=traffic-pulse",
"expiresInS": 60,
"currency": "EUR",
"displayCurrency": "EUR"
}Many sites, one integration
For aggregators and game platforms that bring the game to many sites of their own customers: one API key, one wallet connection, one webhook. You do not register your sites with us one by one.
How it works
Send your own site id as brandId and the player's wallet currency on every launch (optionally siteName). On the first launch of a site in a currency we create that site from your aggregator template (allowed currencies, player countries, games, page-address policy). Each site and currency is one of our brands with id {siteId}.{currency}, returned as brandId; you may use either id afterwards.
| Rule | Detail |
|---|---|
| currency | Required when your template allows several wallet currencies; must be one of them (CURRENCY_NOT_ALLOWED, CURRENCY_REQUIRED). |
| country | Still the player's own jurisdiction on every launch, checked against the template countries. |
| externalPlayerId | Unique across all your sites (e.g. prefix it with your site id). It is the id we send back on wallet callbacks. |
| allowedOrigin | With the default DECLARED policy any https origin you declare is accepted for that site; with LIST only the template origins. |
| Site limit | New sites beyond the agreed maximum are refused with SITE_LIMIT. |
| operatorToken | Your downstream session token; echoed on every wallet callback as usual. |
POST /v1/rewards) and fun play (POST /v1/demo-sessions, with currency) accept your site id too.POST /v1/launch-sessions
{
"brandId": "site-42",
"siteName": "Casino Forty-Two",
"currency": "EUR",
"externalPlayerId": "site-42:player-10293",
"gameId": "traffic-pulse",
"country": "DE",
"operatorToken": "downstream-session-…",
"allowedOrigin": "https://casino42.example"
}
# → 200 (site created: site-42.eur)
{ "launchUrl": "https://play.urbanrats.com/?launch=lc_…",
"expiresInS": 60, "currency": "EUR",
"brandId": "site-42.eur" }Real money and play money, per site
Every site (brand) runs either demo money (play money, or your test wallet) or real money. Real money runs under your gambling licence, on your own seamless wallet. Players can always be offered both: a normal launch plays in the site's mode, and fun play (POST /v1/demo-sessions) is always play money.
| Topic | How it works |
|---|---|
| Switching a site to real money | Send us your licence (jurisdiction, number, licence holder). UrbanRats switches the site after a second UrbanRats administrator approves; the request, the approval and your licence details are recorded. Switching back to demo is immediate. You see the status in the partner portal (Operators → Real money). |
| Requirements | Your seamless wallet is connected (section 07) and the site's wallet currency is a real-money currency. |
| Currencies | Real money: EUR, USD, TRY (scale 2). BTC, ETH, USDT are play money only. |
| Sessions | A session keeps the mode it was launched with: switching a site never turns a running demo session into real money, or back. GET /v1/player/session returns moneyMode: REAL, DEMO or FUN. |
| Your catalogue | GET /v1/games shows entitlements[].moneyMode per site; realMoneyEnabled is true when at least one of your entitled sites runs real money. |
| Promotions | On a real-money site our prizes, drop chests and campaigns are switched off. Your own freebets (POST /v1/rewards) are your decision and are funded by you. |
| Countries | The declared country must be an assigned ISO 3166-1 alpha-2 code, and allowed for the site. You decide which jurisdictions your licence covers; we only enforce the list you agree with us. |
| Wallet down | A launch on a real-money site whose wallet connection is missing returns 503 REAL_MONEY_UNAVAILABLE; nothing falls back to play money. |
{
"walletCurrency": { "code": "TRY", "scale": 2, "demoOnly": false },
"isDemo": false,
"funPlay": false,
"moneyMode": "REAL"
}Show the game on your site
Put launchUrl in an iframe. The game is responsive (phone, tablet, desktop). After the code exchange the game removes it from the address bar and history; responses carry Referrer-Policy: no-referrer.
Optional: the embed SDK (UrbanRatsEmbed) gives you commands and events over postMessage with exact-origin checks in both directions.
| Commands (you → game) | Events (game → you) |
|---|---|
| refresh-balance · mute · volume · open-history · close-panels · get-state | ready · error · session-expired · round.phase · bet.accepted · bet.rejected · bet.settled · balance.changed |
<iframe src="LAUNCH_URL" title="Chaos Lane"
allow="autoplay; fullscreen" referrerpolicy="no-referrer"
style="width:100%;aspect-ratio:16/9;min-height:560px;border:0"></iframe>const game = new UrbanRatsEmbed({
gameOrigin: 'https://play.urbanrats.com',
launchUrl, container: document.getElementById('game'),
});
game.on('balance.changed', (d) => header.setBalance(d.balance));
game.on('session-expired', relaunch);
await game.command('mute', { muted: true });Look up any round and prove its result
Every Chaos Lane round locks its rules before betting opens; the result is the number of crossings counted on the camera inside the counted window. You can fetch the round, the counted crossings and the evidence hashes for any round of a game your brands are entitled to.
Round snapshot: state, locked manifest (rules, line, timing, bands, odds), count, final count and winning selections. manifest.line is the counting segment a→b with countedDirection; the optional line.zone is the near edge of the yellow approach zone players see in front of the line — display only, counting never uses it.
Evidence bundle: manifest hash, media segment hashes, every counted crossing (track, class, confidence, frame time), status COMPLETE / INSUFFICIENT / SIMULATION_ONLY, signatures. null until written.
Filters: externalPlayerId, gameRoundId, state, since, limit (≤ 500).
| Bet field | Type | Description |
|---|---|---|
| betId | string | Our bet id (distinct from gameRoundId and the wallet round id). |
| gameRoundId | string | Round the bet belongs to. |
| partnerWalletRoundId | string|null | Wallet round used in callbacks (one per bet in v1). |
| selection | enum | UNDER · RANGE · OVER · EXACT_LOW · EXACT_HIGH · EXACT |
| stake / cashDebit | Money | {amountAtomic, currency, scale}; for a freebet the cash debit is zero. |
| state | enum | ACCEPTED → RESULT_WON/RESULT_LOST → SETTLED; void path REFUND_PENDING → VOIDED; also REJECTED, COMPENSATED. |
| payoutGross | Money|null | Total return including the stake. |
| payoutNet | SignedMoney|null | Net result (payout minus stake). |
GET /v1/bets?externalPlayerId=player-10293&limit=1 HTTP/1.1
x-ur-key-id: sbx-acme-01
x-ur-timestamp: 1790000000000
x-ur-nonce: 4e5f6a7b8c9d0e1f2a3b
x-ur-signature: <base64 Ed25519 signature>{ "bets": [{
"betId": "b_…", "gameId": "traffic-pulse",
"gameRoundId": "r_…", "partnerWalletRoundId": "r_…:b_…",
"selection": "OVER",
"stake": { "amountAtomic": "500", "currency": "EUR", "scale": 2 },
"cashDebit": { "amountAtomic": "500", "currency": "EUR", "scale": 2 },
"funding": "CASH", "state": "SETTLED",
"payoutGross": { "amountAtomic": "1150", "currency": "EUR", "scale": 2 },
"payoutNet": { "amountAtomic": "650", "currency": "EUR", "scale": 2 },
"submittedAt": "2026-10-04T12:00:01.204Z", "updatedAt": "…"
}] }{ "gameRoundId": "r_…", "status": "COMPLETE",
"count": 23, "winningSelections": ["EXACT_HIGH"],
"manifestHash": "…", "mediaSegmentHashes": ["…"],
"events": [{ "trackId": "t-118", "vehicleClass": "car",
"direction": "A_TO_B", "confidence": 0.91, … }] }A player's own statistics
The same summary the player sees in the game's history panel: totals over all sessions, the last 30 days, money per currency, favourite selection and the 15 most recent settled bets with the round's final count and thresholds. Statistics only — no video replay. Updated within about a minute of settlement. Players of other operators return 404.
| Field | Type | Description |
|---|---|---|
| alias | string | Public alias used on leaderboards (no personal data). |
| firstPlayed | date|null | First day with a settled round (UTC). |
| totals | object | rounds, bets, wins, exactHits, bestMultiplier (payout ÷ stake), playMinutes. |
| last30Days | object | rounds, bets, wins. |
| money[] | object[] | One row per currency: stake, payout, net, biggestWin as atomic strings with scale. Never summed across currencies. |
| favouriteSelection | enum|null | Most played selection. |
| recent[] | object[] | gameRoundId, at, selection, stake, payout, currency, scale, finalCount, low, high, funding. |
{
"alias": "Lynx-4821", "firstPlayed": "2026-10-02",
"totals": { "rounds": 58, "bets": 74, "wins": 29, "exactHits": 3,
"bestMultiplier": 12.5, "playMinutes": 96 },
"last30Days": { "rounds": 58, "bets": 74, "wins": 29 },
"money": [{ "currency": "EUR", "scale": 2, "stake": "7400",
"payout": "6930", "net": "-470", "biggestWin": "1250" }],
"favouriteSelection": "OVER",
"recent": [{ "gameRoundId": "r_…", "at": "…", "selection": "OVER",
"stake": "100", "payout": "230", "currency": "EUR", "scale": 2,
"finalCount": 27, "low": 14, "high": 22, "funding": "CASH" }]
}Expose five signed callbacks
UrbanRats calls your wallet; your wallet is the authority for the balance. Register one HTTPS base URL (e.g. https://wallet.casino.example/urbanrats); we append /balance, /bet, /win, /rollback and /status. All are POST + JSON, signed both ways.
| Callback | When | Your action | Idempotency key |
|---|---|---|---|
| /balance | Session start, on demand | Return the balance; change nothing | — |
| /bet | Player places a bet | Debit the stake | transactionId |
| /win | Round result | Credit the total return; amount 0 for a loss closes the wallet round | transactionId |
| /rollback | Round void, rejected bet, late debit | Reverse the debit | originalTransactionId + reversalType |
| /status | Lost response (optional) | Report a transaction's state | — |
| Field | Type | Description |
|---|---|---|
| protocolVersion | 1 | Always 1. |
| requestId | string | Transport id, new on every attempt. |
| playerId | string | Your externalPlayerId. |
| gameId | string | traffic-pulse. |
| operatorToken | string? | Your token from the launch, on every callback (/balance too). Absent when the launch had none. |
| transactionId | string | Economic id; identical on every retry. |
| betId · gameRoundId · walletRoundId | string | Bet, game round and wallet round (one wallet round per bet). |
| amount | WireMoney | {amount, currency, scale}: integer string in the agreed wire scale. |
/win adds debitTransactionId and roundClosed: true. Freebet bets (a freebet from you or a prize from us) send no /bet: their /win has funding: "FREEBET", promotionId and debitTransactionId: null — credit the amount (profit only; 0 for a loss). /rollback adds originalTransactionId and reversalType (BET_REJECTED · ROUND_VOID · LATE_DEBIT_COMPENSATION). /balance sends playerId, gameId, currency.
Response
| Field | Type | Description |
|---|---|---|
| status | enum | OK or REJECTED (a business outcome, still HTTP 200). |
| code | enum|null | Wallet error code when rejected. |
| transaction | object|null | First outcome of the operation: transactionId, partnerTransactionId, applied, alreadyProcessed, originalFound (rollback only). |
| balance | WireMoney|null | Balance at response time (a separate fact from transaction). |
| balanceVersion | string|null | Your balance version, if you keep one. |
POST /urbanrats/bet HTTP/1.1
Content-Type: application/json
x-ur-key-id: ur-wallet-acme
x-ur-timestamp: 1790000000000
x-ur-nonce: n-00000001
x-ur-signature: <hex HMAC-SHA256>
{
"protocolVersion": 1,
"requestId": "req-00000001",
"playerId": "player-10293",
"gameId": "traffic-pulse",
"operatorToken": "wallet-session-8f2c…",
"transactionId": "debit:r_…-b_…",
"betId": "b_…",
"gameRoundId": "r_…",
"walletRoundId": "r_…:b_…",
"amount": { "amount": "100000", "currency": "EUR", "scale": 5 }
}{
"protocolVersion": 1,
"requestId": "req-00000001",
"status": "OK",
"code": null,
"transaction": {
"transactionId": "debit:r_…-b_…",
"partnerTransactionId": "op-tx-981",
"applied": true, "alreadyProcessed": false, "originalFound": null
},
"balance": { "amount": "99900000", "currency": "EUR", "scale": 5 },
"balanceVersion": "7"
}Idempotency
Persist each mutation and its first response atomically. Same transactionId + same payload → return the stored outcome (applied:false, alreadyProcessed:true). Same id + different amount or player → REJECTED / IDEMPOTENCY_CONFLICT.
One outcome per bet
Every bet ends with exactly one /win or one /rollback, never both. There is no rollback after a successful win: results come from a counted camera window and are final.
Rollback before bet
Unknown original debit → return OK with originalFound:false and keep a tombstone; a later /bet with that id must be refused with DEBIT_CANCELLED.
Closed operator session
To refuse a stake from a session you closed, answer /bet with REJECTED / SESSION_EXPIRED; the player is told to return to your site. Never refuse /win or /rollback because the token expired: the bet was accepted and its result or refund must be applied.
Timeouts are unknown, not failed
HTTP 5xx or a timeout is an unknown outcome. We retry with the same transactionId; for non-idempotent wallets we use /status or manual reconciliation instead of re-sending.
Signing (both directions)
Our request: hex(HMAC-SHA256(ourSecret, canonical)) where canonical = METHOD\npath\ncanonicalQuery\ntimestampMs\nnonce\nsha256hex(body). path is the operation path only (/bet), not your base path.
Your response: headers x-ur-timestamp, x-ur-signature = hex(HMAC-SHA256(yourSecret, "RESPONSE\nrequestNonce\nresponseTimestampMs\nsha256hex(body)")). An unsigned or mis-signed response is treated as an unknown outcome.
Amounts
Integer strings in your wire scale per currency (agreed at onboarding, e.g. EUR scale 5). Strings keep crypto amounts exact. Stakes must be exactly representable; payouts are floored to the wire scale and the floored amount is what we record.
Wallet error codes
INSUFFICIENT_FUNDS · SESSION_EXPIRED (bet only) · UNKNOWN_PLAYER · IDEMPOTENCY_CONFLICT · DEBIT_CANCELLED · ROUND_CLOSED · CURRENCY_MISMATCH · INVALID_SIGNATURE · REPLAYED_REQUEST · LIMIT_EXCEEDED · INTERNAL
protocolVersion, the echoed requestId, status: "REJECTED", code, transaction: null, and the x-ur-timestamp / x-ur-signature headers. HTTP 5xx, a timeout, an unsigned or a malformed answer all mean "we don't know whether you debited": the stake is never accepted, we retry with the same transactionId, and when betting closes we send /rollback with reversalType: LATE_DEBIT_COMPENSATION — answer it OK with originalFound: false if you never debited. The developer portal's delivery log shows the reason (HTTP_5XX, UNSIGNED_RESPONSE, INVALID_RESPONSE, TIMEOUT).# request (our signature)
secret ur-test-secret-do-not-use
POST /bet ts 1790000000000 nonce n-00000001
body {"amount":{"amount":"100000","currency":"EUR","scale":5},"betId":"demo-r000001-b0001","gameId":"traffic-pulse","gameRoundId":"demo-r000001","playerId":"ext-42","protocolVersion":1,"requestId":"req-00000001","transactionId":"debit:demo-r000001-b0001","walletRoundId":"demo-r000001:demo-r000001-b0001"}
sha256 d000b4ae22736bbf93baf7fe8d8054f55912f333230d7ad5d31886e0538d59b1
signature d2c63cdcc0d149bdf36efe8c980288c9ac6b335c6ab0277470005970af898b86
# response (your signature)
secret partner-test-secret-do-not-use
nonce n-00000001 response ts 1790000000123
body {"balance":{"amount":"99900000","currency":"EUR","scale":5},"balanceVersion":"7","code":null,"protocolVersion":1,"requestId":"req-00000001","status":"OK","transaction":{"alreadyProcessed":false,"applied":true,"originalFound":null,"partnerTransactionId":"op-tx-981","transactionId":"debit:demo-r000001-b0001"}}
sha256 76ef6b7890b64045171aa2d24478f2b1db95be38ddcd09759f4efd62bff98bff
signature 7e5067970199e7e077e7c74ecb5e9cfbde2f39d6b2b1fb4741ff3eb5a7e39c4c{ "protocolVersion":1, "requestId":"credit:…#1", "playerId":"player-10293",
"gameId":"traffic-pulse", "transactionId":"credit:r_…-b_…",
"betId":"b_…", "gameRoundId":"r_…", "walletRoundId":"r_…:b_…",
"debitTransactionId":"debit:r_…-b_…",
"amount":{ "amount":"0", "currency":"EUR", "scale":5 }, "roundClosed":true }Receive bet and promotion events
Register {url, events[]} on a registered host. Also: GET /v1/webhooks, POST …/{id}/disable, POST …/{id}/test, GET /v1/webhooks/deliveries, POST …/deliveries/{id}/replay.
| Event | Meaning |
|---|---|
| bet.accepted / bet.rejected | Bet accepted / rejected |
| bet.settled | Result final (win or loss) |
| bet.refunded | Round voided, stake refunded |
| bet.compensated | Late debit compensated |
| reward.* | granted · cancelled · consumed · returned |
| webhook.test | Test delivery |
At-least-once delivery: deduplicate by eventId (a replay keeps it). 2xx = delivered; anything else, including 3xx, is retried at 2, 4, 8 … s and dead after 8 attempts. Events are written in the same transaction as the state change.
Signature: headers x-ur-key-id, x-ur-timestamp, x-ur-event-id, x-ur-signature = Ed25519 over the canonical value, verified with our WEBHOOK key from GET /v1/keys. Reject timestamps older than 60 s.
UR1-ED25519-WEBHOOK
EVENT_ID
TIMESTAMP_MS
LOWERCASE_SHA256_HEX_OF_BODY
KEY_ID{
"apiVersion": "1",
"eventId": "evt-…",
"type": "bet.settled",
"occurredAt": "2026-10-04T12:01:12.410Z",
"operatorId": "op-…",
"data": { "bet": { /* bet object, section 05 */ } }
}export function verifyWebhook(rawBody, h, publicKeyPem) {
const ts = Number(h['x-ur-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 60_000) return false;
const canonical = ['UR1-ED25519-WEBHOOK', h['x-ur-event-id'], String(ts),
createHash('sha256').update(rawBody).digest('hex'), h['x-ur-key-id']].join('\n');
return verify(null, Buffer.from(canonical), createPublicKey(publicKeyPem),
Buffer.from(h['x-ur-signature'], 'base64'));
}Grant freebets and boosts
Rewards we grant ourselves (leaderboard prizes, drop chests, campaigns) reach you as reward.granted webhooks with origin LEADERBOARD, DROP or CAMPAIGN; they are used like your own freebets. Grant a reward to a player yourself: idempotent by operatorRewardId. Value must be in the brand wallet currency. Also GET /v1/rewards and POST /v1/rewards/{id}/cancel (unused rewards only, idempotent).
| grant field | Type | Req. | Description |
|---|---|---|---|
| operatorRewardId | string | Yes | Your id; the idempotency key. |
| kind | enum | Yes | FREEBET or BOOST. |
| faceValue | Money|null | Yes | Freebet value; null for a boost. |
| freebetPolicy | enum|null | Yes | STAKE_INCLUDED or PROFIT_ONLY. |
| boostBps | integer|null | Yes | Boost in basis points, 1–10000. |
| games | string[] | Yes | ["traffic-pulse"]. |
| expiresAt | ISO date-time|null | Yes | UTC expiry. |
{
"brandId": "acme-eu",
"externalPlayerId": "player-10293",
"grant": {
"operatorRewardId": "welcome-player-10293",
"kind": "FREEBET",
"faceValue": { "amountAtomic": "500", "currency": "EUR", "scale": 2 },
"freebetPolicy": "PROFIT_ONLY",
"boostBps": null,
"games": ["traffic-pulse"],
"expiresAt": "2026-10-31T23:59:59.000Z"
}
}Show our leaderboards on your site
Leaderboards your brands take part in (global ones and your own), the current period and — for one board — the top 100 with player aliases and country only. yours: true marks your players. Leaderboard and drop-chest prizes are freebets or boosts granted to your players (you receive reward.granted); who funds them is agreed per campaign. Leaderboards, prizes and drop chests can be switched off per operator, per site and per country.
{
"boardId": "lb-…", "name": "Weekly most active",
"metric": "ROUNDS", "period": "WEEKLY",
"startsAt": "…", "endsAt": "…",
"top": [{ "rank": 1, "alias": "Lynx-4821", "country": "TR", "value": "312", "yours": true }]
}Stable machine-readable outcomes
Every API error body is {"code": "…", "message": "…", "correlationId": "…"}; every response carries the same id in the x-correlation-id header — quote it to support. Retry guidance: SAFE retry with a new nonce and the same business ids · FIX correct the request or configuration first · NO retrying cannot change the outcome.
Body or query does not match the schema (unknown fields are rejected).
All four signature headers are required.
Nonce must be 16–64 characters [A-Za-z0-9_-].
Timestamp more than 60 s from our clock (use NTP).
Unknown key id.
Key revoked; use your current key.
Rotation overlap ended.
Signature does not match the canonical request.
Nonce already used; resend with a new nonce.
Source address outside the key allowlist.
Key lacks the scope for this route.
Not found in your tenant; other tenants' ids look the same as unknown ids.
The brand is not entitled to this game. Configuration, not an outage.
The operator is suspended by UrbanRats; launches are refused.
The site is suspended; launches are refused.
The game is no longer offered; use the catalogue (GET /v1/games).
Aggregators: send currency (the site or template has several wallet currencies).
Aggregators: the currency is not enabled in your template.
Aggregators: the agreed maximum number of sites is reached.
Player ids starting with demo: are reserved for fun play; use POST /v1/demo-sessions.
lobbyUrl, depositUrl and returnUrl must be absolute https URLs without credentials.
Declared country not allowed for this brand or game.
Asserted currency differs from the brand wallet currency.
Display currency not enabled for the brand.
Embed origin not in the brand allowlist.
Same idempotency id reused with a different payload.
Reward already reserved, consumed or expired.
Callback host is not registered for your operator.
Scheme, credentials or address not allowed (HTTPS, public address, no redirects).
Slow down; retry with backoff.
Shared vocabulary
- Brand
- One operator site: wallet currency, allowed countries, display currencies, embed origins.
- gameRoundId
- One Chaos Lane round. Rules are locked in its manifest before betting opens.
- betId
- One bet. Distinct from the round id and the wallet round id.
- walletRoundId
- The round as your wallet sees it (v1: one per bet).
- transactionId
- Economic id of a wallet operation; the idempotency key; never changes on retry.
- requestId / nonce
- Transport ids; new on every attempt.
- Counted window
- The time span whose line crossings decide the result. Betting closes before it starts.
- Bands
- The low / high thresholds of a round, placed from the camera's recent traffic.
- Void
- A round cancelled for a feed problem (frozen picture, gap, lost counter); every bet is refunded.
- Evidence
- Hashes of the manifest and media plus every counted crossing, signed by us.
- Wire scale
- Number of decimals your wallet uses per currency for callback amounts.
- Payout gross / net
- Gross is the total return including the stake; net is the result after the stake.
Planned additions
Not available yet. Listed so you can plan; each item ships with its own schema in GET /v1/openapi.json before it is announced as live.
| Item | What it adds |
|---|---|
| Validation field | The offending field on BAD_REQUEST. |
| Catalog artwork | Images, tags and allowed currencies per game in GET /v1/games. |
| Leaderboard webhooks | leaderboard.prize_granted and drop.opened events (prizes already arrive as reward.granted). |