Documentation
Integration guide
Public reference. Signed-in operators find it under Integration in the console, with their own wallet mode and currency.
This guide covers everything you need to put the sportsbook on your site: API keys, player sessions, the embed (SDK or raw iframe), the wallet, the lifecycle of a bet ("round"), reports and the events the embed sends to your page.
| API base URL | https://api.thesportsbook.app |
| Embed base URL | https://play.thesportsbook.app |
| SDK | https://play.thesportsbook.app/sdk.js |
| Your wallet mode | managed or seamless |
| Currency label | TOKENS |
Managed or seamless? With the managed wallet we keep each player's balance and you move money with the API (section 3). With the seamless wallet you keep the balance and we call your wallet endpoint for every movement (section 4). Your console shows which one your sportsbook uses, and its Integration page renders this guide with your own URLs and currency.
All amounts are decimal numbers with at most 2 decimals, in your currency (TOKENS). All timestamps are ISO 8601 in UTC unless stated otherwise.
1. Quick start
The whole integration is three steps:
- Your server creates a session for the logged-in player with your API key (
POST /b2b/v1/sessions) and gets a single-uselaunch_url. - Your page loads the SDK and mounts the sportsbook with that
launch_url. - Your server handles money: either you top up and withdraw balances with our API (managed wallet), or we call your wallet for every movement (seamless wallet).
1.1 Create an API key
Owners create keys in Integration → API keys. A key looks like sbk_<prefix>_<secret> and is shown once; we only store a hash. Send it as Authorization: Bearer <api_key> from your server only. Never ship it to a browser or a mobile app. You can hold several keys (for rotation) and revoke any of them; a revoked key stops working on the very next request.
1.2 Open a session for the player (server side)
curl -X POST https://api.thesportsbook.app/b2b/v1/sessions \
-H "Authorization: Bearer $SPORTSBOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_ref": "viewer-42", "display_name": "Viewer 42", "locale": "en"}'
{
"launch_token": "eyJhbGciOi...",
"launch_url": "https://play.thesportsbook.app/embed?token=eyJhbGciOi...",
"expires_in": 60
}
| Field | Rules |
|---|---|
user_ref | Required. Your own stable id for the player (1–128 chars, no control characters). The player is created on first use and reused afterwards. Never use something that changes, like a display name. |
display_name | Optional. Shown in the sportsbook and in your console (max 64 chars kept). Updated on every session. |
locale | Optional. en, es, or a tag like en-US. Defaults to your sportsbook's default locale. |
The launch token is single use and expires after 60 seconds: create it right before you render the sportsbook, never cache it. The embed exchanges it for a player session that lasts 12 hours and removes it from the URL. A blocked player gets 403 {"code": "USER_BLOCKED"}.
1.3 Mount the sportsbook with the SDK (recommended)
<div id="sportsbook"></div>
<script src="https://play.thesportsbook.app/sdk.js"></script>
<script>
const sportsbook = TheSportsbook.init({
container: "#sportsbook",
launchUrl: "LAUNCH_URL_FROM_YOUR_SERVER",
height: "auto", // follows the content height; or a fixed number of px
onReady() { /* player session active, sportsbook rendered */ },
onBetPlaced(bet) { refreshBalance(); /* bet.bet_id, bet.stake, bet.potential_win */ },
onDepositRequested() { openCashier(); },
onMaintenance(e) { setBetPromptsPaused(e.active); /* e.scope: "tenant" | "platform" */ },
onTenantSuspended(e) { showUnavailableNotice(e.active); },
async onSessionExpired(e) {
if (e.reason === "user_blocked") return showBlockedMessage();
const { launch_url } = await fetch("/api/sportsbook/session", { method: "POST" }).then((r) => r.json());
sportsbook.updateToken(launch_url); // swaps the session without reloading the iframe
},
onError(e) { console.warn("sportsbook error", e.code, e.message); },
});
</script>
TheSportsbook.init(options) creates the iframe inside container and returns an instance.
| Option | Description |
|---|---|
container | Element or CSS selector the iframe is appended to. Required. |
launchUrl | The launch_url returned by POST /b2b/v1/sessions. |
token | Alternatively, the launch_token (combined with baseUrl). launchUrl wins if both are given. |
baseUrl | Embed origin. Defaults to the origin sdk.js was loaded from (https://play.thesportsbook.app). |
height | "auto" (default) or a fixed height in px. |
locale | Optional UI locale override (en, es). |
title | iframe title for assistive technology. Default Sportsbook. |
onReady, onResize, onBetPlaced, onSessionExpired, onSessionUpdated, onDepositRequested, onMaintenance, onTenantSuspended, onError | Event callbacks (see section 8). |
Instance methods: updateToken(launchTokenOrUrl) (swap the player session without reloading; resolves through a session-updated or error event), setHeight("auto" | px), on(type, cb) / off(type, cb) (use "*" for every event), destroy(). Properties: iframe, ready, height.
No-code variant: any element with data-thesportsbook is mounted automatically on page load. Events are dispatched on the element as thesportsbook:<type> DOM events (payload in event.detail):
<div data-thesportsbook data-launch-url="LAUNCH_URL_FROM_YOUR_SERVER" data-height="auto"></div>
<script src="https://play.thesportsbook.app/sdk.js"></script>
<script>
document.querySelector("[data-thesportsbook]")
.addEventListener("thesportsbook:bet-placed", (e) => console.log(e.detail.bet_id));
</script>
TypeScript definitions are served at https://play.thesportsbook.app/sdk.d.ts.
1.4 Raw iframe alternative (no SDK)
<iframe id="sportsbook" src="LAUNCH_URL_FROM_YOUR_SERVER"
style="width:100%;height:720px;border:0" allow="clipboard-write" title="Sportsbook"></iframe>
<script>
const iframe = document.getElementById("sportsbook");
window.addEventListener("message", (e) => {
if (e.origin !== "https://play.thesportsbook.app" || e.source !== iframe.contentWindow) return;
const msg = e.data;
if (!msg || msg.source !== "sportsbook") return;
switch (msg.type) {
case "resize": iframe.style.height = msg.payload.height + "px"; break;
case "bet-placed": refreshBalance(); break;
case "deposit-requested": openCashier(); break;
case "session-expired": renewSession(msg.payload.reason); break;
case "maintenance": setBetPromptsPaused(msg.payload.active); break;
case "tenant-suspended": showUnavailableNotice(msg.payload.active); break;
}
});
// Renew the session in place: send a fresh launch token (or launch_url) to the iframe.
async function renewSession(reason) {
if (reason === "user_blocked") return;
const { launch_token } = await fetch("/api/sportsbook/session", { method: "POST" }).then((r) => r.json());
iframe.contentWindow.postMessage(
{ source: "sportsbook-sdk", type: "session-token", payload: { token: launch_token } },
"https://play.thesportsbook.app",
);
}
</script>
Always check event.origin and event.source before trusting a message. When you post to the iframe, always pass the embed origin (https://play.thesportsbook.app) as the target origin, never "*". Reloading the iframe with a new launch_url also works.
2. Authentication and conventions
- Server-to-server API:
https://api.thesportsbook.app/b2b/v1/*, headerAuthorization: Bearer sbk_<prefix>_<secret>. - Requests and responses are JSON (
Content-Type: application/json). - Every endpoint that moves money takes an
idempotency_key(1–120 printable ASCII characters, no spaces). Generate one per logical operation (e.g. your own transaction id) and reuse it when you retry. A repeated key with the same parameters returns the original result ("replayed": true) without moving money again; the same key with different parameters returns409 IDEMPOTENCY_KEY_REUSED. - Lists are paginated with an opaque cursor: pass
next_cursorback as?cursor=until it isnull.limitdefaults to 50, max 200. - Errors use the shape
{ "statusCode": 409, "error": "Conflict", "message": "...", "code": "INSUFFICIENT_FUNDS" }.codeis present when there is a machine-readable reason (see section 9). - Maintenance. Your own maintenance (console → Settings) pauses betting in your sportsbook only: bet placement answers
503 MAINTENANCEwithscope: "tenant", everything else keeps working. A platform-wide maintenance answers503 MAINTENANCEwithscope: "platform"on the embed and on/b2b/v1/*(retry later, reusing youridempotency_keys). Your console always stays available, also during a platform maintenance. A platform maintenance never carries a custom text (maintenance_message: null): the embed shows its own maintenance screen in the player's language. The branding preview in your console keeps working during a platform maintenance.
2.1 Bet placement (inside the embed)
The embed places bets for the player with the player's session token (never with your API key). You do not call these endpoints from your server; they are listed so your security review and logs make sense:
| Endpoint | Body | Response |
|---|---|---|
POST https://api.thesportsbook.app/betting/place | a single: { matchId, sportKey, marketType, outcome, odds, stake, oddId } | 201 { success, bet: { id, status, odds, stake, potentialWin } } |
POST https://api.thesportsbook.app/betting/place-accumulator | { selections: [...2–N legs], stake } | 201 { success, bet: { id, status, bet_type, combined_odds, stake, potentialWin, selections } } |
Both accept an optional Idempotency-Key header (1–120 printable ASCII characters, no spaces), scoped to the player and kept for 24 hours:
- the same key with the same body returns the original bet (
201, headerIdempotent-Replayed: true); no second bet is placed and no money moves again; - the same key with a different body (or on the other endpoint) returns
409 IDEMPOTENCY_KEY_REUSED; - while the first request is still being processed (acceptance delay, wallet debit) a retry returns
409 IDEMPOTENCY_IN_PROGRESS: retry a few seconds later; - if the first request failed (for example
400orINSUFFICIENT_FUNDS), no bet exists and the key is released: a retry with the same key tries again.
One key per click of "Place bet" means a network retry or a double click never places the bet twice. The session token decides the tenant; a challengeId field is ignored for embedded players.
Checked again right before the bet is accepted. A live bet waits a few seconds before it is accepted (the acceptance delay), and on a seamless wallet your debit call can take up to 6 seconds more. The suspension of your sportsbook, a player block and maintenance (yours or the platform's) are checked when the request arrives and again right before the stake is taken (on a seamless wallet, also once more after your debit answered). A bet caught by one of them in that window is refused with the same response as at the start (403 USER_BLOCKED, 503 TENANT_SUSPENDED, 503 MAINTENANCE) and does not exist: either your debit endpoint is never called, or the debit you already accepted is compensated with a rollback (<bet_id>:rollback, reason placement_compensation, see 4.4).
The embed also reads the effective betting limits of your sportsbook (GET /embed/config → limits: { min_stake, max_stake_live, max_stake_prematch, max_payout, stake_limits_disabled }, null = no limit) so the bet slip warns before submitting. The server enforces the same limits.
3. Managed wallet
With the managed wallet we hold each player's TOKENS balance. You move money in (credit / top-up) and out (debit / withdraw); bets and settlements update the balance automatically, and you can read it at any time. These endpoints return 400 for a seamless sportsbook.
Credit (top-up)
curl -X POST https://api.thesportsbook.app/b2b/v1/users/viewer-42/credit \
-H "Authorization: Bearer $SPORTSBOOK_API_KEY" -H "Content-Type: application/json" \
-d '{"amount": 50, "idempotency_key": "topup-8f1c2", "reason": "Channel points redemption"}'
{ "balance": 150, "operation_id": "5b0c…", "replayed": false }
Creates the player if it does not exist yet, so you can load a balance before the first session. amount is 0.01 – 10,000,000 with at most 2 decimals; reason is optional (max 255 chars) and shows in the player's history in your console.
Debit (withdraw)
curl -X POST https://api.thesportsbook.app/b2b/v1/users/viewer-42/debit \
-H "Authorization: Bearer $SPORTSBOOK_API_KEY" -H "Content-Type: application/json" \
-d '{"amount": 20, "idempotency_key": "redeem-77a0"}'
Returns { balance, operation_id, replayed }, or 409 INSUFFICIENT_FUNDS when the balance is not enough (nothing is moved). The player must exist (404 otherwise).
Balance
curl https://api.thesportsbook.app/b2b/v1/users/viewer-42/balance -H "Authorization: Bearer $SPORTSBOOK_API_KEY"
{ "user_ref": "viewer-42", "balance": 130, "currency": "TOKENS" }
Retries
If a credit or debit times out or returns 5xx, repeat it with the same idempotency_key until you get a definitive answer. It is applied at most once.
Moving to a seamless wallet
Once your sportsbook is seamless this API can no longer read or withdraw managed balances, so the platform switches a managed sportsbook to seamless only when the managed ledger is empty: every player balance withdrawn to zero (debit above) and no open bet staked from it (let them settle). Until then the switch is refused with 409 WALLET_MODE_HAS_BALANCES and the figures. If the platform forces the switch anyway (recorded in its audit log), nothing is deleted: what was left stays in the managed ledger, open bets still settle into it, and switching back to managed makes those balances readable and withdrawable again.
4. Seamless wallet
With the seamless wallet you hold the balance and we call your wallet endpoint for every movement. Your wallet URL (wallet_base_url) and HMAC secret are configured by the platform; the URL must be https:// and resolve to a public address, and we do not follow redirects.
4.1 Requests we send
POST {wallet_base_url}/debit, POST {wallet_base_url}/credit, POST {wallet_base_url}/rollback
POST /wallet/debit HTTP/1.1
Content-Type: application/json
X-Timestamp: 1790000000
X-Signature: 5d1f0c…(64 hex chars)
{"user_ref":"viewer-42","amount":10,"currency":"TOKENS","bet_id":"0c9a8d5e-…","idempotency_key":"0c9a8d5e-…:debit","op":"debit","reason":"Bet placed — 1X2 / 1","timestamp":1790000000}
| Field | Meaning |
|---|---|
user_ref | Your player id (the one you passed to /sessions). |
amount | Amount to move, ≥ 0, 2 decimals. |
currency | Your currency label. |
bet_id | Our round id (UUID). All operations of a bet share it. |
idempotency_key | Unique per movement. Apply each key at most once. |
op | debit, credit or rollback (same as the path). |
reason | debit: a human description of the bet at placement, or settlement_reversal (see 5.4). credit: bet_settled or bet_lost. rollback: bet_refunded or placement_compensation. |
timestamp | Unix seconds, equal to X-Timestamp. |
4.2 Signature
X-Signature = hex( HMAC_SHA256( wallet_secret, X-Timestamp + "." + rawBody ) )
Compute it over the raw request bytes exactly as received (do not re-serialize the JSON), compare in constant time, and reject timestamps more than 5 minutes away from your clock. Every retry is re-signed with a fresh timestamp, so deduplicate by idempotency_key, never by signature.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SPORTSBOOK_WALLET_SECRET;
// Keep the raw bytes: the signature covers them exactly.
app.use("/wallet", express.raw({ type: "application/json", limit: "16kb" }));
function verifySignature(req) {
const ts = req.get("X-Timestamp") ?? "";
const sig = req.get("X-Signature") ?? "";
if (!/^\d{1,12}$/.test(ts) || !/^[0-9a-f]{64}$/i.test(sig)) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET).update(ts + ".").update(req.body).digest();
return crypto.timingSafeEqual(expected, Buffer.from(sig, "hex"));
}
for (const op of ["debit", "credit", "rollback"]) {
app.post(`/wallet/${op}`, async (req, res) => {
if (!verifySignature(req)) return res.status(401).json({ ok: false, code: "BAD_SIGNATURE" });
const body = JSON.parse(req.body.toString("utf8"));
// Idempotency: a unique index on idempotency_key. A repeat returns the stored answer.
const previous = await db.findWalletOp(body.idempotency_key);
if (previous) return res.json(previous.response);
const response = await applyMovement(op, body); // your ledger, in one DB transaction
await db.saveWalletOp(body.idempotency_key, response);
res.json(response); // { ok: true, balance, ref } or { ok: false, code: "INSUFFICIENT_FUNDS" }
});
}
PHP
<?php
$secret = getenv('SPORTSBOOK_WALLET_SECRET');
$raw = file_get_contents('php://input'); // raw bytes, not a re-encoded array
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
header('Content-Type: application/json');
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit(json_encode(['ok' => false, 'code' => 'STALE_TIMESTAMP']));
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit(json_encode(['ok' => false, 'code' => 'BAD_SIGNATURE']));
}
$body = json_decode($raw, true);
// Look up $body['idempotency_key']; if already processed, return the stored response.
// Otherwise apply the movement in one DB transaction and store the response under that key.
echo json_encode(['ok' => true, 'balance' => 130.00, 'ref' => 'tx_91822']);
4.3 Responses you send
| Your response | What we do |
|---|---|
2xx {"ok": true, "balance"?: number, "ref"?: string} | Accepted. ref (your transaction id, max 255 chars) is stored with the operation. |
2xx {"ok": false, "code": "INSUFFICIENT_FUNDS"} | Definitive rejection. For a placement debit the bet is refused and the player sees "insufficient balance". |
2xx {"ok": false, "code": "<ANY_OTHER>", "message"?: "..."} | Definitive rejection with your code. |
4xx (except 408, 425, 429) | Definitive rejection. |
5xx, 408, 425, 429, timeout (5 s), network error, unreadable 2xx body | Outcome unknown: we retry with the same idempotency_key. |
Answer within 5 seconds. Always return JSON with ok.
4.4 Delivery and retries
- Placement debit (
<bet_id>:debit) is synchronous: the player waits for it. We try up to 3 times (backoff 250 ms, 750 ms) with the same key, within 6 seconds in total: fast failures (connection refused,5xx) get all three attempts, but after a 5 s timeout there is only room for one short retry. The bet only exists if you accepted the debit. - If the debit outcome stays unknown, or our side fails after you accepted it, the bet is not placed and we send
POST /rollbackwith key<bet_id>:rollbackand reasonplacement_compensationfor the same amount. Refund it if you applied the debit; if you never saw the debit, answerok: trueanyway and remember the key, so a late duplicate of that debit can be refused. - If none of the attempts reached your wallet (connection refused, DNS failure, TLS certificate error), nothing was sent, so nothing was charged: the bet is not placed and no rollback is sent.
- Credits and rollbacks at settlement are never sent while the player waits. They go through a durable queue: first attempt within about a second, then exponential backoff (30 s, 1 min, 2 min … capped at 1 h), up to 20 attempts. Rejected credits and rollbacks are retried too, because you must accept them. After the last attempt the operation is marked failed and shows in Wallet → Reconciliation in your console.
- What needs reconciliation. Your console (and the platform) only flag operations whose outcome is unknown or that moved money and could not be delivered or compensated: credits, refunds and round closes that used up their attempts, compensation rollbacks that used up theirs, clawbacks of a re-settled bet that you refused and that later settlements have not recovered yet, and a placement debit with an unknown outcome and no compensation. Placement debits you refused (
INSUFFICIENT_FUNDS, a bad signature, anyok: falseor definitive4xx) and placement debits that never reached you moved no money: they show as Refused / Not sent in Wallet → Operations and never raise an alert. A placement debit with an unknown outcome is tracked through its compensation rollback (listed as Compensated). - Operations of the same bet are delivered in order (a credit is never overtaken by its reversal).
- Credits and rollbacks can arrive when the player has no active session, hours after they left, or after the session expired. Always accept them. Never tie them to a login.
5. Rounds (bet lifecycle)
A round is one bet: a single or an accumulator (all legs in one bet, one stake, one payout). The round id is bet_id.
5.1 States
| Status | Meaning | Paid to the player |
|---|---|---|
PENDING | Open, waiting for results | – |
MANUAL_REVIEW | Open, being checked by our trading team | – |
WON | Won | potential_win (stake × odds) |
HALF_WON | Asian line: half won, half refunded | half the stake × odds + half the stake |
HALF_LOST | Asian line: half lost, half refunded | half the stake (accumulators: see 5.2) |
LOST | Lost | 0 |
VOID | Void / push (e.g. match cancelled) | the stake |
CANCELLED | Cancelled by the platform | the stake |
Open rounds are PENDING and MANUAL_REVIEW. The others are settled.
5.2 Wallet movements per outcome
| Event | Managed wallet | Seamless call (key, amount, reason) |
|---|---|---|
| Bet placed | balance − stake | debit · <bet_id>:debit · stake · bet description |
WON / HALF_WON | balance + payout | credit · <bet_id>:credit · payout · bet_settled |
HALF_LOST | balance + stake/2 | credit · <bet_id>:credit · stake/2 · bet_settled |
LOST | nothing | credit · <bet_id>:credit · 0 · bet_lost |
VOID / CANCELLED | balance + stake | rollback · <bet_id>:rollback · stake · bet_refunded |
- Singles and accumulators follow the same table: an accumulator is one round with one debit and one settlement. A lost leg loses the whole accumulator. Otherwise the payout is
stake × product of the leg factors(won leg: its odds, void leg: 1, half-won leg: (odds + 1) / 2, half-lost leg: 0.5), and the status follows from it: factor 1 →VOID(rolled back), below 1 →HALF_LOST, above 1 →WONorHALF_WON(credited). - Lost rounds are closed with a
creditof amount 0 (seamless only). Accept it and use it to mark the round as closed on your side; it moves no money. Your console shows it as Round closed. With a managed wallet a lost round moves nothing and records no wallet operation. - Half results are paid with a single
creditfor the returned amount.
5.3 Settlement timing
Most rounds settle a few minutes after the final whistle; some wait for official results or for manual review. Settlement does not need the player online (see 4.4).
5.4 Late corrections (re-settlement)
Occasionally a result is corrected after settlement. We then undo the previous settlement and the round goes back to PENDING until it settles again:
- We recover what the previous settlement paid with a
debit, key<bet_id>:revert:<n>, reasonsettlement_reversal(n = 1 for the first correction, 2 for the second…). If the previous settlement paid 0 (lost), no call is made. - The round settles again with a new key that carries the generation:
<bet_id>:credit:<n>or<bet_id>:rollback:<n>. Keys are never reused, so do not treat it as a duplicate.
If you reject a settlement_reversal debit (for example INSUFFICIENT_FUNDS because the player already spent the winnings), we do not retry it: the unrecovered amount is deducted from the next settlement of that round before it is sent. If a settlement had not been delivered yet when it was corrected, it is cancelled and you only receive the new one, so generation numbers can skip. With the managed wallet all of this happens inside our ledger; the balance can go below zero if the player already withdrew the winnings.
5.5 Reading rounds
# A player's rounds (status: a status above, "open" or "settled")
curl "https://api.thesportsbook.app/b2b/v1/users/viewer-42/bets?status=settled&limit=50" -H "Authorization: Bearer $SPORTSBOOK_API_KEY"
# Open rounds and your total exposure (optionally for one player)
curl "https://api.thesportsbook.app/b2b/v1/rounds/open?user_ref=viewer-42" -H "Authorization: Bearer $SPORTSBOOK_API_KEY"
# One round
curl "https://api.thesportsbook.app/b2b/v1/rounds/0c9a8d5e-…" -H "Authorization: Bearer $SPORTSBOOK_API_KEY"
Round object:
{
"bet_id": "0c9a8d5e-…",
"user_ref": "viewer-42",
"status": "WON",
"stake": 10,
"odds": 2.1,
"potential_win": 21,
"payout": 21,
"placed_at": "2026-09-20T18:03:11.000Z",
"settled_at": "2026-09-20T20:01:40.000Z",
"selections": [
{ "match_id": "…", "match": "Home FC vs Away FC", "market": "1X2", "outcome": "1", "odds": 2.1, "status": "won" }
]
}
payout is null while the round is open. Selection status is pending, won, lost, void, half_won or half_lost. Only open rounds have pending selections, with one exception: when an accumulator loses automatically, legs on matches that are still being played stay pending until those matches finish. A round settled by hand by the platform (manual review) never keeps a pending leg: legs take their real result when their match has finished, and otherwise the result given to the whole round (won, lost or void).
GET /b2b/v1/rounds/open returns { items, total_exposure, next_cursor }, where total_exposure is the sum of potential_win of the matching open rounds: the most you could have to pay if they all win. Use it to reconcile a player's balance with their open bets (seamless: the stakes you debited and have not been credited or rolled back yet).
6. Reports
curl "https://api.thesportsbook.app/b2b/v1/reports/summary?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $SPORTSBOOK_API_KEY"
{
"bets_count": 1250,
"turnover": 18400,
"settled_turnover": 17900,
"payouts": 16110,
"ggr": 1790,
"margin_pct": 10,
"open_exposure": 1240
}
How the example adds up: 1,250 rounds staked 18,400 in the window; 17,900 of that belongs to rounds that are already settled, so 500 is still open. The settled rounds paid back 16,110, so ggr = 17,900 − 16,110 = 1,790 and margin_pct = 1,790 ÷ 17,900 × 100 = 10.00. The 500 still open could pay out up to 1,240 (open_exposure, all open rounds at report time — here the same ones, as nothing older is open).
The window (from inclusive, to exclusive, both optional, ISO 8601) applies to the placement date of each round. The same definitions are used everywhere (this report, your console's Overview, charts and breakdowns, and the platform's reports):
| Field | Definition |
|---|---|
bets_count | Rounds placed in the window. |
turnover | Stakes of all rounds placed in the window, open or settled. |
settled_turnover | Stakes of the rounds placed in the window that are already settled (WON, LOST, VOID, CANCELLED, HALF_WON, HALF_LOST). |
payouts | Amounts returned to players on those settled rounds (winnings, half refunds and refunded stakes). |
ggr | settled_turnover − payouts. Open rounds are not revenue until they settle. |
margin_pct | ggr ÷ settled_turnover × 100, two decimals; null when nothing in the window is settled yet. |
open_exposure | A snapshot, now, of the potential payout of all open rounds (not limited to the window). |
Your console (Overview) shows the same figures with charts, breakdowns by sport, league and market, and a CSV export of bets. It also warns when payouts to your seamless wallet are being retried (see section 4.4).
7. Players
user_refidentifies a player within your sportsbook only. Two sportsbooks can use the sameuser_refwithout any collision.- Blocking (console → Users → Block): the player cannot open sessions (
403 USER_BLOCKED), live sessions stop working on their next request (an open embed is told within about a second, shows the player that the account is on hold and emitssession-expiredwithreason: "user_blocked"), and bets are refused, including one that was already waiting for acceptance when you blocked the player (2.1). Open bets still settle normally, and their credits are still delivered. - Unblocking lets the player open new sessions. Sessions and launch tokens issued before the block stay invalid for good (
401 SESSION_REVOKED): callPOST /b2b/v1/sessionsagain and pass the new launch token to the embed. - Suspension (by the platform): every player request (embed, launch-token exchange, bets, listings with the session) answers
503 {"code": "TENANT_SUSPENDED"}("This sportsbook is temporarily unavailable.") instead of401; your API keys and console sign-in get403 {"code": "TENANT_SUSPENDED"}. A bet that was already waiting for acceptance is refused too. The session is not revoked: the embed shows "temporarily unavailable" and emitstenant-suspended(active: true), and once the sportsbook is reactivated the same session works again (tenant-suspendedwithactive: false).
8. Events from the embed (postMessage)
The embed posts { source: "sportsbook", type, payload } to the parent window. The SDK turns them into callbacks, on() listeners and thesportsbook:<type> DOM events.
type | payload | When |
|---|---|---|
loaded | { status: "booting" } | The embed document loaded and is exchanging the launch token. |
ready | { tenant } | The player session is active and the sportsbook rendered. Once per iframe load. |
resize | { height } | The content height changed (CSS px). |
bet-placed | { bet_id, stake, potential_win } | A bet was accepted. Refresh your balance widget from your server. |
session-expired | { reason: "expired" | "user_blocked" } | The session ended. expired: create a new session and call updateToken() (or reload the iframe). user_blocked: do not retry. |
session-updated | { tenant, user_id } | A token passed to updateToken() was exchanged and is active. |
deposit-requested | { balance, currency } | The player clicked "Deposit". Open your cashier or top-up flow. balance is null for seamless wallets. |
maintenance | { active, scope, message } | Betting paused for maintenance (active: true) and resumed (active: false). scope: "tenant" (your sportsbook, from the console) or "platform" (the whole platform). message is your maintenance text, or null (always null for "platform"). The embed already shows the pause to the player; use it to hide your own bet prompts, for example. SDK: onMaintenance. |
tenant-suspended | { active } | Your sportsbook was suspended by the platform (active: true; the embed shows "temporarily unavailable") and reactivated (active: false; the same session works again). Announced once per change. SDK: onTenantSuspended. |
error | { code, message? } | updateToken() failed (invalid_token, session_token_rejected) or the SDK rejected an option (init_failed). |
Events are informational. Never move money based on a postMessage event: the source of truth is the API (managed) or the calls to your wallet (seamless).
9. Error codes
| HTTP | code / message | Where | What to do |
|---|---|---|---|
| 400 | validation message | any | Fix the request (the message lists the invalid fields). |
| 400 | This operator uses a seamless wallet… | managed wallet endpoints | Your sportsbook is seamless: balances live in your wallet. |
| 401 | Invalid API key / API key revoked | /b2b/v1/* | Check the key; create a new one if it was revoked. |
| 401 | Invalid or expired launch token / Launch token already used or expired | embed | Create a new session: launch tokens are single use and live 60 s. |
| 403 | TENANT_SUSPENDED | /b2b/v1/*, console | Your sportsbook is suspended by the platform: API keys and console sign-in (with the correct password) stop until it is reactivated (message Tenant is suspended on /b2b/v1/*). Contact the platform. |
| 503 | TENANT_SUSPENDED | embed | The same suspension, seen by players: new and live player sessions, launch-token exchange and bets stop. The embed shows "temporarily unavailable" and emits tenant-suspended; sessions that were valid work again after reactivation. |
| 403 | USER_BLOCKED | /sessions, embed | The player is blocked in your console. |
| 401 | SESSION_REVOKED | embed | The session was opened before the player was blocked. Unblocking does not revive old sessions: open a new one. |
| 404 | User not found / Bet not found | reads, debit | Unknown user_ref or bet_id for your sportsbook. |
| 409 | INSUFFICIENT_FUNDS | managed debit | Balance too low; nothing was moved. |
| 409 | IDEMPOTENCY_KEY_REUSED | wallet endpoints, bet placement | Same key sent with different parameters: use a new key for a new operation. |
| 409 | IDEMPOTENCY_IN_PROGRESS | bet placement | The first request with this Idempotency-Key is still running: retry in a few seconds. |
| 409 | BALANCE_LIMIT_EXCEEDED | managed credit | The balance would exceed the maximum (99,999,999.99). |
| 429 | Too many requests + Retry-After header | /b2b/v1/*, embed | Wait Retry-After seconds (see section 10). |
| 429 | TOO_MANY_ATTEMPTS | console sign-in | Too many failed sign-ins from your network: wait retry_after seconds. Failed attempts from other networks never lock your account. |
| 503 | MAINTENANCE + maintenance message | embed / betting; /b2b/v1/* (platform only) | Your sportsbook (scope: "tenant", bet placement only) or the platform (scope: "platform": the embed and /b2b/v1/*, never the console) is in maintenance; betting is paused. maintenance_message is your custom text (scope: "tenant"), or null (always null for scope: "platform"). Also answered to a bet that was waiting for acceptance when the maintenance started. |
| 5xx | – | any | Retry with backoff; reuse the same idempotency_key for money movements. |
10. Rate limits
/b2b/v1/*: 600 requests per minute per API key (fixed one-minute windows), counted only after the key is authenticated: traffic of your other keys, or of someone who only knows your key prefix, never uses this key's budget. Above it you get429with aRetry-Afterheader (seconds; alsoretry_afterin the body). Ask the platform if you need more.- Failed authentications on
/b2b/v1/*: 30 per minute per IP (unknown, malformed or revoked keys, and keys of a suspended sportsbook). After that the IP gets429before its key is even checked, until the window passes. Make sure deployments do not ship a stale key. - Players inside the embed are limited per player, not per IP: 100 requests per minute per player on each endpoint (bet placement, bet history, balance, config, match listings), counted by the player's session, so players behind one public IP (a mobile carrier, a venue, a watch party) never share a budget. Launch-token exchange: 30 per minute per player. Requests without a valid session (an expired one, the
?tenant=preview) count per IP, 100 per minute per endpoint. Above the limit:429withRetry-After. Normal use never reaches it. - Console sign-in counts failures per network (an IPv6 /64 is one network): after 8 failed sign-ins on one account from one network, or 30 failed attempts from one network (any account, invitation acceptance included), within 15 minutes, that network gets
429 TOO_MANY_ATTEMPTSuntil the window passes. Failures from other networks never lock your account: past 80 failures on one account from all networks, each sign-in on it only waits a little longer (up to 5 s). Changing your password allows 8 wrong current passwords per account in the window. - Our calls to your seamless wallet: at most 32 placement debits in flight at once for your sportsbook; above that the player gets
503("Betting is temporarily unavailable. Please try again in a few seconds.") and your wallet is not called. Settlement credits and rollbacks go out up to 8 at a time for your sportsbook. Expect bursts at the end of popular matches (many settlements at once): keep your wallet endpoint fast (well under the 5 s timeout) and idempotent under concurrency.
11. Security checklist
- The API key lives only on your server (environment variable or secret manager), never in the browser, a mobile app or a repository.
- One key per environment; rotate by creating a new key, deploying it, then revoking the old one.
- Sessions are created by your server for the player who is logged in on your site. Never accept a
user_reffrom the browser. - Launch tokens are created right before rendering and never logged, cached or reused.
- Every site that embeds the sportsbook is listed in Settings → Allowed origins (exact origins such as
https://www.example.com, no paths or wildcards). - Your
messagelistener checksevent.origin === "https://play.thesportsbook.app"andevent.source, and you post to the iframe with that origin as target. - You never move money because of a postMessage event.
- Seamless: the signature is verified over the raw body in constant time, timestamps older than 5 minutes are rejected, and the wallet secret is stored like a password.
- Seamless: every movement is idempotent by
idempotency_key(unique index) and repeats return the original response. - Seamless: credits and rollbacks are accepted without an active session, including amount 0 credits for lost rounds.
- Seamless: a rollback for a debit you never saw is answered
ok: trueand the key is remembered, so a late copy of that debit is refused. - Seamless: your wallet endpoint is HTTPS only and answers within 5 seconds.
- Reconcile daily: compare
GET /b2b/v1/rounds/openand the reports with your ledger, and check Wallet → Reconciliation in the console for failed operations. - Console accounts use strong passwords; remove or disable staff who leave (their sessions end immediately).
SDK reference
Load https://play.thesportsbook.app/sdk.js with a script tag. It defines window.TheSportsbook (and exports it for CommonJS and AMD when bundled). The TypeScript definitions describe everything below.
const sportsbook = TheSportsbook.init({
container: "#sportsbook",
launchUrl, // from POST /b2b/v1/sessions, created by your server
height: "auto",
onBetPlaced(bet) { refreshBalance(bet.bet_id); },
});
sportsbook.on("session-expired", async ({ reason }) => {
if (reason === "user_blocked") return;
sportsbook.updateToken(await createLaunchUrl());
});Options
Passed to TheSportsbook.init(options).
| Name | Type | Description |
|---|---|---|
containerrequired | Element | string | Element, or CSS selector, the iframe is appended to. |
launchUrl | string | The launch_url returned by POST /b2b/v1/sessions. Takes precedence over token. |
token | string | The launch_token returned by POST /b2b/v1/sessions, combined with baseUrl. |
baseUrl | string | Embed origin, e.g. https://sportsbook.example.com. Defaults to the origin sdk.js was loaded from. |
height | 'auto' | number | 'auto' (default) follows the content height; a number fixes the iframe height in px. In both modes the SDK tells the sportsbook which part of the iframe is on screen, so the betslip and notifications stay visible. |
locale | string | UI locale override (en, es). |
title | string | iframe title for assistive technology. Default Sportsbook. |
onReady … onError | (payload, meta) => void | Event callbacks: onReady, onResize, onBetPlaced, onSessionExpired, onSessionUpdated, onDepositRequested, onMaintenance, onTenantSuspended, onError. this is the instance; meta is { type, instance }. |
Events
Each event reaches its on… option callback, the listeners added with on(type) or on('*'), and a thesportsbook:<type> DOM event on the container (payload in event.detail). Events are informational: never move money because of one.
| Event | Payload | When |
|---|---|---|
loaded | { status: 'booting' } | The embed document is up and listening for updateToken. |
readyonReady | { tenant: string } | The player session is active and the sportsbook rendered. Sent once per iframe load. |
resizeonResize | { height: number } | Content height of the sportsbook, in CSS pixels. |
bet-placedonBetPlaced | { bet_id: string; stake: number; potential_win: number } | A bet was accepted. Refresh your balance widget from your backend. |
session-expiredonSessionExpired | { reason: 'expired' | 'user_blocked' } | expired: request a new launch token from your backend and call updateToken(). user_blocked: the player is blocked; new sessions are refused. |
session-updatedonSessionUpdated | { tenant: string; user_id: string } | A token passed to updateToken() was exchanged and is now active. |
deposit-requestedonDepositRequested | { balance: number | null; currency: string } | The player clicked “Deposit”. Open your cashier. |
maintenanceonMaintenance | { active: boolean; scope: 'tenant' | 'platform'; message: string | null } | Betting paused for maintenance (active: true) and resumed (active: false). scope: 'tenant' (your sportsbook) or 'platform'. The embed already shows the pause and returns by itself; use it to mirror the state on your page. |
tenant-suspendedonTenantSuspended | { active: boolean } | Your sportsbook was suspended by the platform (active: true) and reactivated (active: false). The same player session works again; no new launch needed. |
erroronError | { code: string; message?: string } | updateToken() failed (invalid_token, session_token_rejected) or the SDK rejected an option (init_failed). |
Instance methods and properties
Returned by init(), get() and instances().
| Name | Returns / type | Description |
|---|---|---|
updateToken(token) | boolean | Swaps the player session without reloading the iframe. Pass a fresh launch_token or the full launch_url. Resolves through session-updated or error. Returns false if the value is not a token. |
setHeight(height) | void | Switch between 'auto' and a fixed height in px at runtime. |
on(type, cb) | this | Adds a listener for an event, or '*' for every event. |
off(type, cb?) | this | Removes one listener, or every listener of that type when cb is omitted. |
destroy() | void | Removes the iframe and every listener. |
container | Element | Read-only. The element the iframe lives in. |
iframe | HTMLIFrameElement | null | Read-only. The iframe, or null after destroy(). |
ready | boolean | Read-only. true between ready and session-expired. |
height | number | null | Read-only. Last content height reported by the sportsbook, in px. |
Static API
| Name | Returns / type | Description |
|---|---|---|
TheSportsbook.init(options) | Instance | Creates the iframe inside container and returns an instance. |
TheSportsbook.autoInit(scope?) | Instance[] | Mounts every [data-thesportsbook] element not mounted yet (data-launch-url, data-token, data-base-url, data-height, data-locale, data-title). Runs automatically on DOMContentLoaded. |
TheSportsbook.get(element) | Instance | null | The instance mounted in an element. |
TheSportsbook.instances() | Instance[] | Every live instance on the page. |
TheSportsbook.version | string | SDK version. |
TheSportsbook.events | string[] | Names of every event the SDK emits. |
TypeScript
Definitions are served next to the SDK at https://play.thesportsbook.app/sdk.d.ts. Copy the file into your project, or reference it with a triple-slash directive, to type the options, the event payloads and the global window.TheSportsbook.