Reference

REST API

Everything in the dashboard is available over a JSON API: register games, fetch the install snippet, verify the SDK is live, change settings and read stats. The CLI and the MCP server are built on exactly these endpoints.

Basics

export BONUSROUND_API_KEY=br_sk_…
curl https://bonusround.io/api/v1/whoami -H "Authorization: Bearer $BONUSROUND_API_KEY"

Account & API keys

GET/api/v1/whoami

Which account a key belongs to, plus a summary of its games. The quickest way to check a key works.

{
  "user": { "id": "usr_…", "email": "you@studio.com", "name": "You" },
  "auth": "api_key",
  "key": { "id": "key_…", "name": "Claude Code", "prefix": "br_sk_AbCdEf", "createdAt": 1791000000000, "lastUsedAt": 1791000100000 },
  "games": [{ "id": "gm_…", "pubId": "pub_…", "name": "Orb Dash", "url": "https://orbdash.example", "status": "ready", "testMode": true, "lastSeenAt": 1791000050000 }],
  "urls": { "developers": "https://bonusround.io/app/developers/", "docs": "https://bonusround.io/docs/", "integrate": "https://bonusround.io/integrate.md", "mcp": "https://bonusround.io/mcp" }
}
GET/api/keys

Your keys: [{ id, name, prefix, created_at, last_used_at, revoked_at }]. The full key is never returned again.

POST/api/keys { name }

Creates a key. The response is the only time the full key appears: { id, key: "br_sk_…", prefix, note }.

DELETE/api/keys/:id

Revokes a key immediately. { ok: true }

Games

POST/api/games { url, name? }

Registers a game and creates its public pub_ id. url is where the game runs; its domain (and subdomains) are allowed to serve ads. Returns { game }.

curl -X POST https://bonusround.io/api/games \
  -H "Authorization: Bearer $BONUSROUND_API_KEY" -H "content-type: application/json" \
  -d '{ "url": "https://orbdash.example/play", "name": "Orb Dash" }'
GET/api/games

All your games, newest first: [game] (a lighter shape, plus last7 earnings).

GET/api/games/:id

One game. The fields you'll use most:

{
  "id": "gm_…", "pubId": "pub_…", "name": "Orb Dash", "url": "https://orbdash.example/play",
  "status": "learning",                  // pending → detected → learning → ready (or paused)
  "testMode": true,
  "settings": { … },                     // see PATCH below
  "rating": "everyone", "categories": ["casual", "platformer"],
  "integration": {
    "firstSeenAt": 1791000000000,
    "lastSeenAt": 1791000050000,         // null until the SDK has pinged from an allowed domain
    "sdkVersion": "1.0.0", "threeRevision": "186",
    "origins": ["orbdash.example"]
  },
  "world": { … } ,                        // what the play agent learned, or null
  "snippet": {
    "scriptTag": "<script async src=\"https://bonusround.io/v1/br.js\" data-pub=\"pub_…\"></script>",
    "attachLine": "BonusRound.attach({ THREE, scene, camera, renderer });",
    "attachQueued": "(window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer }));",
    "breakLine": "await window.BonusRound?.break('intermission');",
    "testLine": "BonusRound.break('test');",
    "adsTxt": "bonusround.io, pub_…, DIRECT"
  }
}
Verifying an install

Put snippet.attachQueued in game code, not attachLine: the tag is async, so the bare call can run before window.BonusRound exists. Load the game in a browser, then poll GET /api/games/:id until integration.lastSeenAt is set. The first ping also moves status from pending to detected and starts learning.

PATCH/api/games/:id

Change any of: name, testMode, rating (everyone | teen | mature), categories, url, domains (hostnames allowed to serve ads; replaces the list), paused, and settings. settings is merged into the current settings, so send only what changes. Returns the game.

{
  "settings": {
    "formats": {
      "takeover": { "enabled": true, "triggers": ["intermission", "interval", "rewarded"], "intervalSec": 300 },
      "ambient": { "enabled": true }
    },
    "floorCpmMicros": { "takeover": 2000000, "ambient": 500000 },
    "blockedCategories": ["alcohol", "gambling"],
    "frequencyCap": { "perHour": 4 },
    "madeForKids": "no"
  }
}

settings.madeForKids is "yes", "mixed", "no" or null (not answered yet). Anything but "no" on a Teen-or-above game serves contextual ads only (Games for kids); the read-only directedToChildren is true for yes and mixed. GET /api/games/:id/compliance returns the game's privacy mode, its ads.txt lines and detection status, and its sellers.json listing.

GET/api/games/_meta

Valid triggers, ratings, game categories, blockable brand categories and default settings.

POST/api/games/:id/learn

(Re)starts the play agent: { ok, runId }. It starts on its own after the first SDK ping; call this after big changes to your game.

PUT/api/games/:id/agent-login { loginUrl?, username, password, notes? }

A test account for games that need a login to play. Stored encrypted and never returned; the agent types it through a secret tool so the model never sees it. { ok }. DELETE the same path to remove it.

GET/api/games/:id/stats?days=30
{
  "totals": { "requests": 1840, "fills": 1210, "impressions": 1180, "viewable": 1090, "engagements": 520, "clicks": 31,
              "earningsMicros": 41800000, "ecpmMicros": 35400000, "fillRate": 0.66 },
  "daily":  [{ "day": "2026-10-05", "requests": 61, "impressions": 40, "engagements": 18, "clicks": 1, "earningsMicros": 1400000 }],
  "byBrand": [ … ], "byFormat": [ … ], "byTrigger": [ … ],
  "test":   { "requests": 6, "impressions": 6, "engagements": 0, "completions": 4, "lastAt": 1791000200000 }
}

Real traffic only. Test-mode traffic (the Fizzpop test ad) is never in totals, daily or earnings; it's counted on its own under test, so you can see your test rounds firing.

Live events

GET/api/live/game:<id>

Server-sent events for one of your games: sdk_ping, status, agent_log, agent_image, agent_done, test_ad, earning. The last 200 events are replayed when you connect.

curl -N https://bonusround.io/api/live/game:gm_… -H "Authorization: Bearer $BONUSROUND_API_KEY"

Earnings & payouts

GET/api/billing/earnings

{ balanceMicros, pendingMicros, paidMicros, nextPayoutAt, history }. See Payouts.

POST/api/billing/connect

Returns { url } to Stripe Connect Express onboarding.

POST/api/billing/payouts { amountMicros }

Requests a payout of available earnings (minimum $50).

Brands & campaigns

For advertisers. The same keys work.

EndpointWhat
POST /api/brands { url, assets? }Start learning a brand from its website → { brand }
GET /api/brands, GET /api/brands/:idBrands, and one brand's { brand, kit }
GET /api/brands/:id/matchesMatched games with scores and a breakdown
POST /api/creatives { brandId, gameId, campaignId? }Generate a Bonus Round for one game
GET /api/creatives/:id, POST …/approve, POST …/reject { reason }Review generated rounds
POST /api/campaigns { brandId, name, objective, formats, bidStrategy, bidMicros, dailyBudgetMicros, targeting, frequencyCap, cta }Create a campaign
GET /api/campaigns?brandId=, GET|PATCH /api/campaigns/:id, POST /api/campaigns/:id/status { status }Manage campaigns
GET /api/campaigns/:id/stats?days=30Impressions, viewable, engagements, completions, clicks, spend, CTR, eCPM
GET /api/billing/accounts, POST /api/billing/checkout { accountId, amountMicros }Balances and prepaid top-ups (minimum $50)

Ad serving (public)

These are called by the SDK from your players' browsers. They're CORS-open, keyed by pub, and need no API key. You shouldn't need to call them yourself, but they're useful for debugging.

EndpointBody → response
POST /v1/ping{ pub, origin, sdkVersion, threeRevision } → { ok, status, testMode, first, settings, world }. Marks the game as seen. From a page that isn't on the game's domains: { ok: false, status: 'origin_not_allowed' }.
POST /v1/ad{ pub, format, trigger, playerId } → { fill, requestId, manifestUrl, cta, token, brand, test }, or { fill: false, reason }
POST /v1/event{ token, type, value } → { ok, billed } (a repeat of the same type for the same ad is { ok, duplicate: true }, not counted again).
GET /v1/ads.txt?pub=Your ads.txt line, as text
GET /v1/br.jsThe SDK loader

MCP endpoint

POST/mcp

A hosted Model Context Protocol server (streamable HTTP, stateless) with the same tools as the @bonusround/mcp package. Authenticate with the same Authorization: Bearer br_sk_… header. See Integrate with AI agents.