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 URLhttps://api.thesportsbook.app
Embed base URLhttps://play.thesportsbook.app
SDKhttps://play.thesportsbook.app/sdk.js
Your wallet modemanaged or seamless
Currency labelTOKENS

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:

  1. Your server creates a session for the logged-in player with your API key (POST /b2b/v1/sessions) and gets a single-use launch_url.
  2. Your page loads the SDK and mounts the sportsbook with that launch_url.
  3. 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
}
FieldRules
user_refRequired. 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_nameOptional. Shown in the sportsbook and in your console (max 64 chars kept). Updated on every session.
localeOptional. 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"}.

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

OptionDescription
containerElement or CSS selector the iframe is appended to. Required.
launchUrlThe launch_url returned by POST /b2b/v1/sessions.
tokenAlternatively, the launch_token (combined with baseUrl). launchUrl wins if both are given.
baseUrlEmbed origin. Defaults to the origin sdk.js was loaded from (https://play.thesportsbook.app).
height"auto" (default) or a fixed height in px.
localeOptional UI locale override (en, es).
titleiframe title for assistive technology. Default Sportsbook.
onReady, onResize, onBetPlaced, onSessionExpired, onSessionUpdated, onDepositRequested, onMaintenance, onTenantSuspended, onErrorEvent 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/*, header Authorization: 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 returns 409 IDEMPOTENCY_KEY_REUSED.
  • Lists are paginated with an opaque cursor: pass next_cursor back as ?cursor= until it is null. limit defaults to 50, max 200.
  • Errors use the shape { "statusCode": 409, "error": "Conflict", "message": "...", "code": "INSUFFICIENT_FUNDS" }. code is 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 MAINTENANCE with scope: "tenant", everything else keeps working. A platform-wide maintenance answers 503 MAINTENANCE with scope: "platform" on the embed and on /b2b/v1/* (retry later, reusing your idempotency_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:

EndpointBodyResponse
POST https://api.thesportsbook.app/betting/placea 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, header Idempotent-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 400 or INSUFFICIENT_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}
FieldMeaning
user_refYour player id (the one you passed to /sessions).
amountAmount to move, ≥ 0, 2 decimals.
currencyYour currency label.
bet_idOur round id (UUID). All operations of a bet share it.
idempotency_keyUnique per movement. Apply each key at most once.
opdebit, credit or rollback (same as the path).
reasondebit: 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.
timestampUnix 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 responseWhat 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 bodyOutcome 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 /rollback with key <bet_id>:rollback and reason placement_compensation for the same amount. Refund it if you applied the debit; if you never saw the debit, answer ok: true anyway 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, any ok: false or definitive 4xx) 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

StatusMeaningPaid to the player
PENDINGOpen, waiting for results–
MANUAL_REVIEWOpen, being checked by our trading team–
WONWonpotential_win (stake × odds)
HALF_WONAsian line: half won, half refundedhalf the stake × odds + half the stake
HALF_LOSTAsian line: half lost, half refundedhalf the stake (accumulators: see 5.2)
LOSTLost0
VOIDVoid / push (e.g. match cancelled)the stake
CANCELLEDCancelled by the platformthe stake

Open rounds are PENDING and MANUAL_REVIEW. The others are settled.

5.2 Wallet movements per outcome

EventManaged walletSeamless call (key, amount, reason)
Bet placedbalance − stakedebit · <bet_id>:debit · stake · bet description
WON / HALF_WONbalance + payoutcredit · <bet_id>:credit · payout · bet_settled
HALF_LOSTbalance + stake/2credit · <bet_id>:credit · stake/2 · bet_settled
LOSTnothingcredit · <bet_id>:credit · 0 · bet_lost
VOID / CANCELLEDbalance + stakerollback · <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 → WON or HALF_WON (credited).
  • Lost rounds are closed with a credit of 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 credit for 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:

  1. We recover what the previous settlement paid with a debit, key <bet_id>:revert:<n>, reason settlement_reversal (n = 1 for the first correction, 2 for the second…). If the previous settlement paid 0 (lost), no call is made.
  2. 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):

FieldDefinition
bets_countRounds placed in the window.
turnoverStakes of all rounds placed in the window, open or settled.
settled_turnoverStakes of the rounds placed in the window that are already settled (WON, LOST, VOID, CANCELLED, HALF_WON, HALF_LOST).
payoutsAmounts returned to players on those settled rounds (winnings, half refunds and refunded stakes).
ggrsettled_turnover − payouts. Open rounds are not revenue until they settle.
margin_pctggr ÷ settled_turnover × 100, two decimals; null when nothing in the window is settled yet.
open_exposureA 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_ref identifies a player within your sportsbook only. Two sportsbooks can use the same user_ref without 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 emits session-expired with reason: "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): call POST /b2b/v1/sessions again 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 of 401; your API keys and console sign-in get 403 {"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 emits tenant-suspended (active: true), and once the sportsbook is reactivated the same session works again (tenant-suspended with active: 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.

typepayloadWhen
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

HTTPcode / messageWhereWhat to do
400validation messageanyFix the request (the message lists the invalid fields).
400This operator uses a seamless wallet…managed wallet endpointsYour sportsbook is seamless: balances live in your wallet.
401Invalid API key / API key revoked/b2b/v1/*Check the key; create a new one if it was revoked.
401Invalid or expired launch token / Launch token already used or expiredembedCreate a new session: launch tokens are single use and live 60 s.
403TENANT_SUSPENDED/b2b/v1/*, consoleYour 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.
503TENANT_SUSPENDEDembedThe 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.
403USER_BLOCKED/sessions, embedThe player is blocked in your console.
401SESSION_REVOKEDembedThe session was opened before the player was blocked. Unblocking does not revive old sessions: open a new one.
404User not found / Bet not foundreads, debitUnknown user_ref or bet_id for your sportsbook.
409INSUFFICIENT_FUNDSmanaged debitBalance too low; nothing was moved.
409IDEMPOTENCY_KEY_REUSEDwallet endpoints, bet placementSame key sent with different parameters: use a new key for a new operation.
409IDEMPOTENCY_IN_PROGRESSbet placementThe first request with this Idempotency-Key is still running: retry in a few seconds.
409BALANCE_LIMIT_EXCEEDEDmanaged creditThe balance would exceed the maximum (99,999,999.99).
429Too many requests + Retry-After header/b2b/v1/*, embedWait Retry-After seconds (see section 10).
429TOO_MANY_ATTEMPTSconsole sign-inToo many failed sign-ins from your network: wait retry_after seconds. Failed attempts from other networks never lock your account.
503MAINTENANCE + maintenance messageembed / 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–anyRetry 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 get 429 with a Retry-After header (seconds; also retry_after in 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 gets 429 before 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: 429 with Retry-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_ATTEMPTS until 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_ref from 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 message listener checks event.origin === "https://play.thesportsbook.app" and event.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: true and 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/open and 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.

Usage
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).

NameTypeDescription
containerrequiredElement | stringElement, or CSS selector, the iframe is appended to.
launchUrlstringThe launch_url returned by POST /b2b/v1/sessions. Takes precedence over token.
tokenstringThe launch_token returned by POST /b2b/v1/sessions, combined with baseUrl.
baseUrlstringEmbed 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.
localestringUI locale override (en, es).
titlestringiframe title for assistive technology. Default Sportsbook.
onReady … onError(payload, meta) => voidEvent 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.

EventPayloadWhen
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().

NameReturns / typeDescription
updateToken(token)booleanSwaps 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)voidSwitch between 'auto' and a fixed height in px at runtime.
on(type, cb)thisAdds a listener for an event, or '*' for every event.
off(type, cb?)thisRemoves one listener, or every listener of that type when cb is omitted.
destroy()voidRemoves the iframe and every listener.
containerElementRead-only. The element the iframe lives in.
iframeHTMLIFrameElement | nullRead-only. The iframe, or null after destroy().
readybooleanRead-only. true between ready and session-expired.
heightnumber | nullRead-only. Last content height reported by the sportsbook, in px.

Static API

NameReturns / typeDescription
TheSportsbook.init(options)InstanceCreates 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 | nullThe instance mounted in an element.
TheSportsbook.instances()Instance[]Every live instance on the page.
TheSportsbook.versionstringSDK version.
TheSportsbook.eventsstring[]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.

Open sdk.d.ts