Core operator integration

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.

Base URL (sandbox)https://api.urbanrats.com

Production base URL is issued at go-live.

Versioning/v1/…

Every path already starts with /v1; do not add another to the base URL.

FormatJSON · UTF-8

Strict schemas: unknown fields are rejected with BAD_REQUEST.

Integration statusVersion 1 is integration-ready and tested end to end against our own mock operator wallet only. No operator or aggregator has certified it yet; certification follows a joint acceptance test. Items marked PLANNED are not available yet.

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.

01 / AUTHENTICATION

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.

HeaderTypeReq.Description
x-ur-key-idstringYesYour key id (sandbox keys are issued in the developer portal).
x-ur-timestampintegerYesUnix time in milliseconds; within ±60 s of our clock.
x-ur-noncestringYes16–64 chars [A-Za-z0-9_-]; single use per key.
x-ur-signaturebase64YesEd25519 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).

Canonical value
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_ID
Node.js
import { 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 vector
# 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==
HMAC (Node.js)
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');
HMAC test vector
# 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=
02 / CATALOG

Get your games

GET/v1/gamesgames:read

Signed: the games your brands are entitled to, with the countries allowed per brand. Unsigned: the public catalogue (no entitlements).

FieldTypeReq.Description
gameIdstringYesStable game identity; send it as gameId at launch. Chaos Lane is traffic-pulse.
titlestringYesDisplay name.
rulesVersionstringYesVersion of the published rules.
mechanicstringYesTOTAL_COUNT_BANDS: bet on how many vehicles cross the line in the arrow's direction.
readinessstringYesSource readiness of the game (e.g. SIMULATION_ONLY until a monetisable camera source is approved).
realMoneyEnabledbooleanYestrue when at least one of your entitled sites runs real money (per site: entitlements[].moneyMode, section 03d).
requiredSourceCapabilitystringYesCamera capability the game needs (VIDEO_FIXED_ROAD).
entitlements[]object[]Signed{brandId, countries[]}: where each of your brands may launch the game.
Signed request
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>
200 response
{
  "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" }]
  }]
}
03 / LAUNCH

Create a game session

POST/v1/launch-sessionslaunch:create

Checks the brand, the game entitlement, the declared country and the currency, then returns a one-time launch URL for the player.

FieldTypeReq.Description
brandIdstringYesYour brand (site) id from onboarding.
externalPlayerIdstringYesStable player id in your system. No personal data.
gameIdstringYesFrom GET /v1/games — today only traffic-pulse (Chaos Lane). Do not hard-code a game list: retired games return GAME_RETIRED.
countryISO 3166-1YesThis 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.
currencystringNoAssertion only: the wallet currency is the brand's; a different value returns CURRENCY_MISMATCH.
displayCurrencystringNoPresentation only; must be enabled for the brand.
languagexx / xx-XXNoUI language, e.g. en, tr, de-DE.
allowedOriginoriginNoExact origin of the page embedding the game; must be in the brand allowlist.
operatorTokenstringRecommendedYour 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.
deviceenumNodesktop or mobile; never inferred.
lobbyUrl · depositUrl · returnUrlURLNoAbsolute 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.
siteNamestringNoAggregators: display name of the site sent as brandId (section 03c).
launchUrlURLResponseOpen in the player client (iframe or new window).
launchCodestringResponseSingle use, valid for expiresInS (60 s). Create it when the player presses Play.
brandIdstringResponseOur brand id for this launch (for aggregators: the brand of the site and currency, e.g. site-42.eur).
Configuration vs outageA 4xx at launch is a configuration rejection: retrying the same request will not help. 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.
SessionsA game session slides while it is used (4 h idle limit) and ends 24 h after the launch at the latest; then open a new launch. A suspended operator or site gets OPERATOR_SUSPENDED / BRAND_SUSPENDED.
Signed request
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"
}
200 response
{
  "launchCode": "lc_…",
  "launchUrl": "https://play.urbanrats.com/?launch=lc_…",
  "expiresInS": 60,
  "currency": "EUR",
  "displayCurrency": "EUR"
}
03b / FUN PLAY

Launch a demo session without the wallet

POST/v1/demo-sessionslaunch:create

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.

FieldTypeReq.Description
brandIdstringYesYour brand (site) id.
gameIdstringYesFrom GET /v1/games.
countryISO 3166-1YesDeclared jurisdiction; fun play obeys the same country rules.
languagexx / xx-XXNoUI language.
allowedOriginoriginNoExact embedding origin (brand allowlist).
deviceenumNodesktop or mobile.
lobbyUrlURLNoAbsolute https; “Back to the site” target.
currency · siteNamestringNoAggregators: the site's wallet currency and name (section 03c).
Kept apart from real playDemo players never appear in statistics, leaderboards, prizes, drop chests or campaigns, and their play money cannot become real money. Player ids starting with 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.
Signed request body
{
  "brandId": "acme-eu",
  "gameId": "traffic-pulse",
  "country": "DE",
  "language": "de",
  "device": "desktop"
}
200 response
{
  "launchCode": "lc_…",
  "launchUrl": "https://play.urbanrats.com/?launch=lc_…&game=traffic-pulse",
  "expiresInS": 60,
  "currency": "EUR",
  "displayCurrency": "EUR"
}
03c / AGGREGATORS

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.

RuleDetail
currencyRequired when your template allows several wallet currencies; must be one of them (CURRENCY_NOT_ALLOWED, CURRENCY_REQUIRED).
countryStill the player's own jurisdiction on every launch, checked against the template countries.
externalPlayerIdUnique across all your sites (e.g. prefix it with your site id). It is the id we send back on wallet callbacks.
allowedOriginWith the default DECLARED policy any https origin you declare is accepted for that site; with LIST only the template origins.
Site limitNew sites beyond the agreed maximum are refused with SITE_LIMIT.
operatorTokenYour downstream session token; echoed on every wallet callback as usual.
Per site, stillReports, risk limits, responsible-gaming settings, leaderboards and suspension work per site. We can suspend one of your sites (all its currencies) without touching the others; rewards (POST /v1/rewards) and fun play (POST /v1/demo-sessions, with currency) accept your site id too.
First launch of a site
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" }
03d / REAL & DEMO MONEY

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.

TopicHow it works
Switching a site to real moneySend 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).
RequirementsYour seamless wallet is connected (section 07) and the site's wallet currency is a real-money currency.
CurrenciesReal money: EUR, USD, TRY (scale 2). BTC, ETH, USDT are play money only.
SessionsA 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 catalogueGET /v1/games shows entitlements[].moneyMode per site; realMoneyEnabled is true when at least one of your entitled sites runs real money.
PromotionsOn 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.
CountriesThe 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 downA launch on a real-money site whose wallet connection is missing returns 503 REAL_MONEY_UNAVAILABLE; nothing falls back to play money.
What we do not claimUrbanRats does not hold a gambling licence and has no independent lab certification, measured capture-time bound or validated pricing model yet; the odds are fixed prices set by us. Real money runs under your licence and your responsibility towards your players and regulator.
GET /v1/player/session (excerpt)
{
  "walletCurrency": { "code": "TRY", "scale": 2, "demoOnly": false },
  "isDemo": false,
  "funPlay": false,
  "moneyMode": "REAL"
}
04 / EMBEDDING

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-stateready · error · session-expired · round.phase · bet.accepted · bet.rejected · bet.settled · balance.changed
Page messages never move moneyEmbed events are for the UI only. The wallet callbacks are the only source of balance changes.
Rules confirmation (nothing to implement)Before a player's first game the client shows a short illustrated summary of the counting rules — only the arrows' direction counts, a vehicle counts when its middle crosses the line, every vehicle (car, bus, truck, motorcycle) counts 1, only what the camera sees, no pedestrians or bicycles, the counted minute and refunds on a picture failure — and the player confirms it. We store the confirmation per player and rules version (it applies on every device; a rules change asks again). Players can choose not to see it again.
iframe
<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>
Embed SDK
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 });
05 / ROUNDS, BETS & EVIDENCE

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.

GET/v1/rounds/{gameRoundId}rounds:read

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.

GET/v1/rounds/{gameRoundId}/evidencerounds:read

Evidence bundle: manifest hash, media segment hashes, every counted crossing (track, class, confidence, frame time), status COMPLETE / INSUFFICIENT / SIMULATION_ONLY, signatures. null until written.

GET/v1/bets · /v1/bets/{betId}bets:read

Filters: externalPlayerId, gameRoundId, state, since, limit (≤ 500).

Bet fieldTypeDescription
betIdstringOur bet id (distinct from gameRoundId and the wallet round id).
gameRoundIdstringRound the bet belongs to.
partnerWalletRoundIdstring|nullWallet round used in callbacks (one per bet in v1).
selectionenumUNDER · RANGE · OVER · EXACT_LOW · EXACT_HIGH · EXACT
stake / cashDebitMoney{amountAtomic, currency, scale}; for a freebet the cash debit is zero.
stateenumACCEPTED → RESULT_WON/RESULT_LOST → SETTLED; void path REFUND_PENDING → VOIDED; also REJECTED, COMPENSATED.
payoutGrossMoney|nullTotal return including the stake.
payoutNetSignedMoney|nullNet result (payout minus stake).
Media retentionRound video is kept for 24 hours; the counted crossings, hashes and statistics are kept permanently.
Signed request
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>
200 response
{ "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": "…"
}] }
Evidence (excerpt)
{ "gameRoundId": "r_…", "status": "COMPLETE",
  "count": 23, "winningSelections": ["EXACT_HIGH"],
  "manifestHash": "…", "mediaSegmentHashes": ["…"],
  "events": [{ "trackId": "t-118", "vehicleClass": "car",
               "direction": "A_TO_B", "confidence": 0.91, … }] }
05b / PLAYER STATISTICS

A player's own statistics

GET/v1/players/{externalPlayerId}/statsbets:read

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.

FieldTypeDescription
aliasstringPublic alias used on leaderboards (no personal data).
firstPlayeddate|nullFirst day with a settled round (UTC).
totalsobjectrounds, bets, wins, exactHits, bestMultiplier (payout ÷ stake), playMinutes.
last30Daysobjectrounds, bets, wins.
money[]object[]One row per currency: stake, payout, net, biggestWin as atomic strings with scale. Never summed across currencies.
favouriteSelectionenum|nullMost played selection.
recent[]object[]gameRoundId, at, selection, stake, payout, currency, scale, finalCount, low, high, funding.
200 response
{
  "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" }]
}
06 / SEAMLESS WALLET

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.

CallbackWhenYour actionIdempotency key
/balanceSession start, on demandReturn the balance; change nothing—
/betPlayer places a betDebit the staketransactionId
/winRound resultCredit the total return; amount 0 for a loss closes the wallet roundtransactionId
/rollbackRound void, rejected bet, late debitReverse the debitoriginalTransactionId + reversalType
/statusLost response (optional)Report a transaction's state—
POST{base}/betyour wallet
FieldTypeDescription
protocolVersion1Always 1.
requestIdstringTransport id, new on every attempt.
playerIdstringYour externalPlayerId.
gameIdstringtraffic-pulse.
operatorTokenstring?Your token from the launch, on every callback (/balance too). Absent when the launch had none.
transactionIdstringEconomic id; identical on every retry.
betId · gameRoundId · walletRoundIdstringBet, game round and wallet round (one wallet round per bet).
amountWireMoney{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

FieldTypeDescription
statusenumOK or REJECTED (a business outcome, still HTTP 200).
codeenum|nullWallet error code when rejected.
transactionobject|nullFirst outcome of the operation: transactionId, partnerTransactionId, applied, alreadyProcessed, originalFound (rollback only).
balanceWireMoney|nullBalance at response time (a separate fact from transaction).
balanceVersionstring|nullYour balance version, if you keep one.
Signed request (UrbanRats → you)
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 }
}
200 response (signed by you)
{
  "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

Common mistake: a rejection sent as HTTP 5xxA refusal (insufficient funds, closed session, your own validation) must be HTTP 200 with the full signed body: 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).
Callback URL rulesRegistered hosts only, HTTPS, public addresses, no credentials in the URL, redirects never followed. The resolved address is pinned for the connection; TLS verifies the host name.
Wallet test vectors
# 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
Loss: /win with amount 0
{ "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 }
07 / WEBHOOKS

Receive bet and promotion events

POST/v1/webhookswebhooks:manage

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.

EventMeaning
bet.accepted / bet.rejectedBet accepted / rejected
bet.settledResult final (win or loss)
bet.refundedRound voided, stake refunded
bet.compensatedLate debit compensated
reward.*granted · cancelled · consumed · returned
webhook.testTest 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.

Canonical value
UR1-ED25519-WEBHOOK
EVENT_ID
TIMESTAMP_MS
LOWERCASE_SHA256_HEX_OF_BODY
KEY_ID
Event body
{
  "apiVersion": "1",
  "eventId": "evt-…",
  "type": "bet.settled",
  "occurredAt": "2026-10-04T12:01:12.410Z",
  "operatorId": "op-…",
  "data": { "bet": { /* bet object, section 05 */ } }
}
Verify (Node.js)
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'));
}
08 / PROMOTIONS

Grant freebets and boosts

POST/v1/rewardsrewards:write

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 fieldTypeReq.Description
operatorRewardIdstringYesYour id; the idempotency key.
kindenumYesFREEBET or BOOST.
faceValueMoney|nullYesFreebet value; null for a boost.
freebetPolicyenum|nullYesSTAKE_INCLUDED or PROFIT_ONLY.
boostBpsinteger|nullYesBoost in basis points, 1–10000.
gamesstring[]Yes["traffic-pulse"].
expiresAtISO date-time|nullYesUTC expiry.
Signed request body
{
  "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"
  }
}
08b / LEADERBOARDS

Show our leaderboards on your site

GET/v1/leaderboards · /v1/leaderboards/{id}games:read

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.

200 response (one board)
{
  "boardId": "lb-…", "name": "Weekly most active",
  "metric": "ROUNDS", "period": "WEEKLY",
  "startsAt": "…", "endsAt": "…",
  "top": [{ "rank": 1, "alias": "Lynx-4821", "country": "TR", "value": "312", "yours": true }]
}
09 / ERRORS

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.

BAD_REQUEST400 · NO

Body or query does not match the schema (unknown fields are rejected).

SIGNATURE_MISSING401 · FIX

All four signature headers are required.

NONCE_INVALID401 · FIX

Nonce must be 16–64 characters [A-Za-z0-9_-].

TIMESTAMP_SKEW401 · FIX

Timestamp more than 60 s from our clock (use NTP).

KEY_UNKNOWN401 · FIX

Unknown key id.

KEY_REVOKED401 · FIX

Key revoked; use your current key.

KEY_EXPIRED401 · FIX

Rotation overlap ended.

SIGNATURE_INVALID401 · FIX

Signature does not match the canonical request.

NONCE_REPLAY401 · SAFE

Nonce already used; resend with a new nonce.

IP_NOT_ALLOWED403 · FIX

Source address outside the key allowlist.

SCOPE_MISSING403 · FIX

Key lacks the scope for this route.

NOT_FOUND404 · NO

Not found in your tenant; other tenants' ids look the same as unknown ids.

GAME_NOT_ENTITLED403 · NO

The brand is not entitled to this game. Configuration, not an outage.

OPERATOR_SUSPENDED403 · FIX

The operator is suspended by UrbanRats; launches are refused.

BRAND_SUSPENDED403 · FIX

The site is suspended; launches are refused.

GAME_RETIRED403 · NO

The game is no longer offered; use the catalogue (GET /v1/games).

CURRENCY_REQUIRED400 · FIX

Aggregators: send currency (the site or template has several wallet currencies).

CURRENCY_NOT_ALLOWED409 · NO

Aggregators: the currency is not enabled in your template.

SITE_LIMIT403 · FIX

Aggregators: the agreed maximum number of sites is reached.

DEMO_PLAYER_RESERVED400 · FIX

Player ids starting with demo: are reserved for fun play; use POST /v1/demo-sessions.

NAVIGATION_URL_INVALID400 · NO

lobbyUrl, depositUrl and returnUrl must be absolute https URLs without credentials.

COUNTRY_NOT_ALLOWED403 · NO

Declared country not allowed for this brand or game.

CURRENCY_MISMATCH409 · NO

Asserted currency differs from the brand wallet currency.

DISPLAY_CURRENCY_NOT_ALLOWED400 · NO

Display currency not enabled for the brand.

ORIGIN_NOT_ALLOWED400 · NO

Embed origin not in the brand allowlist.

IDEMPOTENCY_CONFLICT409 · NO

Same idempotency id reused with a different payload.

REWARD_NOT_CANCELLABLE409 · NO

Reward already reserved, consumed or expired.

CALLBACK_HOST_NOT_REGISTERED400 · FIX

Callback host is not registered for your operator.

CALLBACK_URL_REJECTED400 · FIX

Scheme, credentials or address not allowed (HTTPS, public address, no redirects).

RATE_LIMITED429 · SAFE

Slow down; retry with backoff.

10 / GLOSSARY

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.
11 / ROADMAP

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.

ItemWhat it adds
Validation fieldThe offending field on BAD_REQUEST.
Catalog artworkImages, tags and allowed currencies per game in GET /v1/games.
Leaderboard webhooksleaderboard.prize_granted and drop.opened events (prizes already arrive as reward.granted).