Developer Documentation
Complete guide to integrating Challenge into your game.
Challenge Platform
Challenge lets players wager real money against each other in any web game. You add one script and a few lines of config — Challenge handles everything else.
What It Looks Like
- You add a script tag and call
Challenge.init()in your game page - You place Challenge's branded button — either inline next to your play buttons with
Challenge.renderButton("#container"), or as a floating button (created automatically byChallenge.init()). The button includes the Challenge wave logo, gradient styling, and glow animation. You don't build this button yourself. - A player clicks it → Challenge's modal opens and walks them through account creation, age verification, and depositing funds
- Player clicks "Play Now" → If matchmaking is enabled, Challenge finds an opponent; otherwise, your game's own matchmaking handles pairing. Either way, Challenge collects both entry fees and holds them in escrow
- Your game gets a callback (
onMatchStart) → you start the game - Game ends → you report the score or winner → Challenge pays the winner and shows the result
You never touch payments, accounts, identity verification, or matchmaking. You just start the game when told to and report who won.
Think of it like Stripe Checkout — Stripe handles the entire payment UI, you just get a callback when it's done. Challenge handles the entire wagering flow, you just get a callback when the match starts.
Integration
Two decisions determine your integration:
| Decision | Options |
|---|---|
| How is the winner determined? | By score — platform compares scores automatically. By gameplay — your game reports the winner. |
| Do you need Challenge to find opponents? | Yes — platform matchmaking (skill-based or FIFO). No — your game handles matchmaking. |
See the Integration Guide for the complete walkthrough.
Requirements
To integrate Challenge, you need:
- A registered developer account with an approved game
- A game ID (UUID assigned when you register your game)
- The widget script tag in your game page:
<script src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>Note: The SDK script (
challenge-sdk.js) is only needed if your game creates matches or reports winners directly. When usingmatchmakingmode, the widget handles match creation internally — no SDK needed.
What the Platform Handles
The developer does not need to build any of the following. The platform provides all of these through the widget and backend:
- User accounts — registration, login, email verification, SSO across games
- Identity verification — age verification, KYC via Veriff
- Payments — credit card and ACH deposits via Stripe, bank linking via Plaid, withdrawals
- Escrow — entry fees are held until the match settles
- Matchmaking — FIFO lobby or skill-based queue with ELO ratings
- Settlement — winner receives 85% of the prize pool (15% total fee: 10.5% platform + 4.5% developer revenue share); draws refund both players
- Rematch flow — players can rematch the same opponent or find a new one
- Responsible gaming — deposit limits, self-exclusion, state-based restrictions
Key Terminology
| Term | Definition |
|---|---|
| Entry fee | The amount each player stakes on a match ($2 or $5, configured per game at registration) |
| Match | A single contest between two players with money at stake |
| Settlement | The process of determining the winner and distributing funds |
| Widget | The embeddable UI component (Challenge global object) that handles player onboarding, matchmaking UI, and result screens |
| SDK | The JavaScript library (ChallengeSDK class) that handles match creation and settlement via API calls |
| Pairing | A temporary link between two matched players before a match is created |
| Pairing ID | UUID identifying a pairing, used during the transition from matchmaking to match creation |
| Match ID | UUID identifying a match, used for score submission and settlement |
| API key | Developer credential (prefix sk_live_ for production, sk_test_ for sandbox) sent as X-API-Key header |
| Sandbox | Test environment using sk_test_ API keys, with bot opponents and no real money |
Documentation Map
| Document | Purpose | Audience |
|---|---|---|
| Quickstart | Add scripts, init widget | New developers |
| Integration Guide | Full integration guide (base + matchmaking + auto-settlement) | Developers |
| Challenge.js | Widget configuration, methods, callbacks | Reference |
| SDK | SDK methods, authentication, HMAC signing | Reference |
| API | Complete REST endpoint reference | Reference |
| WebSockets | WebSocket connection, events, payloads | Reference |
| Sandbox | Testing with sandbox keys and bot opponents | Reference |
Quickstart
Add peer-to-peer money matches to your game. You add one script, call Challenge.init(), and the platform handles accounts, payments, matchmaking, and settlement.
This works like Stripe or Klarna — you embed it, you don't run it.
Using an AI assistant to integrate? Point it to withchallenge.com/llms.txt — it has everything an AI needs in one file.
Prerequisites
- A Challenge developer account (register at the Developer Portal)
- A registered game — go to Developer Portal → Games → Register Game
- Your game ID (UUID) and test API key (
sk_test_...) from the Developer Portal → Sandbox page - A web-based game where you can add script tags
You must use real credentials from the Developer Portal. Do not make up game IDs or API keys. The game ID and test API key are generated when you register your game. Store them as environment variables (e.g.,
VITE_CHALLENGE_GAME_ID,VITE_CHALLENGE_API_KEY).
Step 1: Add the Script
<script src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>This is the only script tag most integrations need. It gives you window.Challenge.
If your game handles its own matchmaking and needs to create matches or report winners from your server, also add the SDK:
<script src="https://api.withchallenge.com/sdk/src/challenge-sdk.js"></script>See the Integration Guide to determine if you need it.
Step 2: Initialize and Place the Button
Call Challenge.init() on page load and place the Challenge button on your game's main page. The button is Challenge's branded UI — gradient background, wave logo, glow animation. Do not create your own button. Players must recognize the Challenge button across all games.
Note:
apiBaseis auto-detected from the script URL. You only need to set it explicitly for local development.
There are two placement options — pick one:
Option A: Inline button — place next to your existing play buttons:
<div class="your-menu-buttons">
<button onclick="startNormalGame()">Play</button>
<div id="challenge-button"></div>
</div>Challenge.init({
gameId: "your-game-id", // From Developer Portal — never make this up
apiKey: "sk_test_...", // Test API key from Developer Portal → Sandbox
entryFee: 2, // $2 or $5 — set per game at registration
variant: "default", // "default", "1v1", or "play" — see text options below
size: "lg", // "sm", "md", or "lg"
showButton: false, // disable floating button — we use inline
onReady: handlePlayerReady,
onError: (err) => console.error("Challenge error:", err),
});
Challenge.renderButton("#challenge-button", { fullWidth: true });Option B: Floating button — fixed position on the page:
Challenge.init({
gameId: "your-game-id", // From Developer Portal — never make this up
apiKey: "sk_test_...", // Test API key from Developer Portal → Sandbox
entryFee: 2, // $2 or $5 — set per game at registration
variant: "play", // "default", "1v1", or "play" — see text options below
size: "md", // "sm", "md", or "lg"
position: "bottom-right",
onReady: handlePlayerReady,
onError: (err) => console.error("Challenge error:", err),
});Button text variants:
| Variant | Text shown |
|---|---|
"default" | "Challenge for Money" |
"1v1" | "1v1 for Money" |
"play" | "Play for Money" |
When a player clicks the button, the widget opens and handles authentication, age verification, and depositing funds. When complete, onReady fires with the player's credentials.
Step 3: Receive Player Credentials
The onReady callback provides everything you need to interact with the Challenge API on behalf of this player.
function handlePlayerReady(data) {
const token = data.token; // JWT for API calls (Authorization: Bearer <token>)
const userId = data.userId; // Player's UUID
const email = data.email; // Player's email
const balance = data.balance; // Current wallet balance
const entryFee = data.entryFee; // Entry fee for the match
// From here, the flow depends on your integration method.
}Step 4: Choose Your Integration
The Integration Guide walks through two decisions:
Decision 1: How is the winner determined?
- By score -- players submit scores, platform compares (
mode: "score") - By gameplay -- your game reports the winner (
sdk.reportWinner())
Decision 2: Do you need Challenge to find opponents?
- Yes -- add
matchmaking: "skill"or"fifo"toChallenge.init() - No -- use the SDK to create matches when your system pairs players
| Settlement | Matchmaking | Scripts needed |
|---|---|---|
| By score | Challenge finds opponents | Widget only |
| By score | You find opponents | Widget + SDK |
| By gameplay | Challenge finds opponents | Widget + SDK (server) |
| By gameplay | You find opponents | Widget + SDK |
Reference Documentation
- Challenge.js reference — all configuration options, methods, and callbacks
- SDK reference — match creation, settlement, HMAC signing
- API reference — complete endpoint documentation
- WebSocket reference — real-time events
- Sandbox reference — testing with sandbox keys and bot opponents
Integration Guide
Challenge is a payment and matchmaking layer — it does not replace your game's multiplayer. If your game already has real-time gameplay (live boards, shared state, spectating), keep that infrastructure as-is. Challenge handles money, opponent pairing, and result settlement. Your game handles everything else.
Two decisions determine your integration: how the winner is determined, and who finds opponents.
If you want zero server code, use Challenge's matchmaking. Custom matchmaking always requires the server SDK.
Pick Your Path
Paths are ordered from simplest to most complex. Pick the first one that fits your game.
| Path | Winner decided by | Matchmaking | Server code? | Config | Your code |
|---|---|---|---|---|---|
| A | Score (highest wins) | Challenge finds opponents | No | mode: "score", matchmaking: "skill" | gameEnded({ matchId, score }) |
| B | Your game logic | Challenge finds opponents | No | mode: "versus", matchmaking: "skill" | showWin() / showLose() |
| C | Score (highest wins) | You find opponents | Yes | mode: "score" | SDK createPairedMatch() + gameEnded() |
| D | Your game logic | You find opponents | Yes | (defaults) | SDK createPairedMatch() + reportWinner() |
Path A — best for games with a numeric score (endless runners, puzzle games, time trials). Both players play independently; Challenge compares scores and settles automatically.
Path B — for games where the winner isn't determined by a numeric score (e.g., last-man-standing, capture-the-flag). Both clients independently call showWin()/showLose(). If your game has a numeric score, prefer Path A — it's more secure because Challenge's server picks the winner, not the client.
Path C / D — for games that need full control over matchmaking (e.g., your own lobby, ELO system, or friend invites). Requires a server with the Challenge SDK to create matches and (for Path D) report winners. More work, more control.
All paths support live score streaming (updateScore() + onOpponentScore), rematches, and $2 or $5 entry fees.
Setup
Script Tags
Add these to your game page's <head>:
<!-- Button stub (~2KB) — renders <challenge-button> instantly -->
<script src="https://api.withchallenge.com/widget/dist/challenge-button.js"></script>
<!-- Full SDK (~320KB) — loads async, hydrates buttons -->
<script async src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>The stub script defines the <challenge-button> custom element so the button appears immediately. The full SDK loads asynchronously and hydrates the button with click handlers. This gives you zero-flash button rendering.
You can also use just the full SDK script (without the stub) — the button will appear once the script loads, with a brief delay.
When do I need the SDK script? Only for Paths C and D — when you handle your own matchmaking. If Challenge finds opponents for you (Paths A and B), you only need the widget scripts above. See Pick Your Path.
The Button
Challenge provides a branded button — gradient background, wave logo, glow animation.
Required: Use only the official Challenge button rendered by
renderButton()or the floating button. The button must appear on the main page of your game, alongside your existing play options. These are policy requirements — violating them may result in your game being removed.Do not:
- Create your own button or wrapper that triggers Challenge
- Put the Challenge button behind an intermediate screen, menu, or extra click
- Restyle, recolor, or obscure the Challenge button
- Use custom text — use the
variantoption insteadPlayers must be able to find and recognize the Challenge button immediately from your game's main screen. One click from the main menu to Challenge — no extra steps.
There are three placement types — pick one:
Custom element (recommended) — place <challenge-button> in your HTML. Renders instantly via the stub script:
<div class="your-menu-buttons">
<button onclick="playNormal()">Play</button>
<challenge-button variant="play" size="lg" full-width></challenge-button>
</div>The button appears immediately — no JavaScript initialization needed. When the full SDK loads, it hydrates the element automatically. If clicked before the SDK loads, it shows a loading state and opens once ready.
Inline via JS — place via Challenge.renderButton(). Works before or after init():
<div id="challenge-button"></div>Challenge.renderButton("#challenge-button", { variant: "play", fullWidth: true });
Challenge.init({ gameId: "your-game-id", showButton: false });Floating — fixed position on the page. Created by Challenge.init(). Set position to control placement.
When using custom element or inline, pass showButton: false in Challenge.init() to disable the floating button.
Floating — fixed position on the page. Created automatically by Challenge.init(). Set position to control placement ("bottom-right", "bottom-left", "top-right", "top-left").
Button text variants — set variant in Challenge.init(). Applies to both inline and floating:
| Variant | Text shown |
|---|---|
"default" | "Challenge for Money" |
"1v1" | "1v1 for Money" |
"play" | "Play for Money" |
Button sizes — set size in Challenge.init(): "sm", "md", "lg". For inline, use fullWidth: true to match your game's button widths.
See the Challenge.js reference for the full button API.
Step 1: How Is the Winner Determined?
By Score
Both players submit a numeric score. The platform compares them — higher wins. No server code needed for settlement.
Set mode: "score" in Challenge.init() (or in sdk.createPairedMatch() if you handle matchmaking):
Challenge.init({
gameId: "your-game-id", // From Developer Portal — real UUID, not a placeholder
apiKey: "sk_test_...", // Test key for sandbox; omit or use sk_live_ for production
entryFee: 2, // $2 or $5 — set per game at registration
matchmaking: "skill",
mode: "score", // Platform compares scores, higher wins
onMatchStart: (match) => {
// Start gameplay immediately — no loading screens, lobbies, or countdowns.
// The widget already showed matchmaking UI, opponent info, and countdown
// before this callback fires. Go straight to your game.
startYourGame(match);
},
});Do not add intermediate screens between
onMatchStartand gameplay. No "Connecting...", no custom lobby, no countdown. The widget handles all pre-game UI. WhenonMatchStartfires, the player is ready — start the game immediately.
When the game ends, each player's client must call gameEnded with their own score. Both calls are required — Challenge waits for both scores before settling.
// Both clients call this independently with their own score
Challenge.gameEnded({
matchId: matchId,
score: finalScore,
opponent: opponentInfo,
gameData: { duration: 30 }, // Optional metadata
});The widget handles score submission, waiting for the opponent's score, comparing both scores, showing the result, and the rematch flow.
Settlement rules
| Outcome | Condition | Result |
|---|---|---|
| Win | Your score > opponent's score | Winner receives prize pool minus platform fee |
| Loss | Your score < opponent's score | Entry fee is lost |
| Draw | Scores are equal | Both players are refunded |
The total fee is 15% of the prize pool (10.5% platform + 4.5% developer revenue share). The winner receives the remaining 85%.
Live score streaming
Optionally stream the player's score to the opponent in real time during gameplay:
// Send score updates (throttled to 5 per second by the widget)
Challenge.updateScore(currentScore);To receive the opponent's live score:
Challenge.init({
// ...
onOpponentScore: (data) => {
// data.userId — opponent's ID
// data.score — opponent's current score
updateOpponentDisplay(data.score);
},
});Scores are sent over WebSocket. The widget handles the connection automatically.
By Gameplay
Your game knows who won (knockout, checkmate, finish line). This is the default mode — no mode option needed (defaults to "versus").
Settle the match using Challenge.settle():
// If a player won:
Challenge.settle({
matchId: match.matchId,
winnerId: winner.id,
gameData: { score: 250, moves: 15 }, // optional
});
// If the game was a draw:
Challenge.settle({
matchId: match.matchId,
});A draw refunds both players' entry fees. After settle(), Challenge.js automatically shows the win, loss, or draw screen and handles rematch flow.
Server-side alternative: If you prefer to settle from your server (for additional validation), use the SDK
reportWinner()/reportDraw()methods, then callChallenge.showWin()/Challenge.showLose()/Challenge.showDraw()on each client to display results.
| Method | When to call | What the widget shows |
|---|---|---|
Challenge.settle({ matchId, winnerId }) | Game over, winner known | Settles match + shows result + rematch flow |
Challenge.settle({ matchId }) | Game over, draw | Settles match + shows draw + rematch flow |
Challenge.showWin({ matchId, opponent, profit }) | Server already settled, show result | Victory screen with profit amount |
Challenge.showLose({ matchId, opponent, loss }) | Server already settled, show result | Loss screen with amount lost |
Challenge.showDraw({ matchId, opponent }) | Server already settled, show result | Draw screen, stakes refunded |
Step 2: Do You Need Challenge to Find Opponents?
Yes -- Platform Matchmaking
Add matchmaking: "skill" or "fifo" to Challenge.init(). The widget handles everything — queue join, opponent search, match creation, countdown, and launch synchronization.
Scripts needed: Widget only (no SDK on client). If settling by gameplay, you still need the SDK on your server.
// Place Challenge's branded button — renders instantly, no init needed first
Challenge.renderButton("#challenge-button", { variant: "default", size: "lg", fullWidth: true });
// Init runs in background
Challenge.init({
gameId: "your-game-id",
entryFee: 2, // $2 or $5 — set per game at registration
showButton: false, // we use renderButton() above instead
matchmaking: "skill", // "skill" (ELO-based) or "fifo" (first-come-first-served)
onMatchStart: (match) => {
// Widget found an opponent, created the match, synced both players.
// match.matchId — the Challenge match ID
// match.opponent — { username, email, skillRating }
// match.isRematch — true if this is a rematch
// match.roundNumber — match number in this series
startYourGame(match);
},
});The widget handles:
- Joining the queue after the player clicks the Challenge button
- Heartbeat polling and WebSocket notifications
- Match-found countdown screen
- P1/P2 match creation handshake
- Launch synchronization (both players start at the same time)
- "Find New Opponent" — re-enters the queue automatically
- Rematches — fires
onMatchStartagain with the new match ID
When matchmaking is set, you do not need:
- The SDK script tag on the client
- An API key on the client
onReady(the widget proceeds automatically after auth)setPostMatchHandlers(rematches fireonMatchStart)- Any
fetch()calls to queue endpoints
Skill vs FIFO
| Mode | Queue type | How players are matched | Seeding required? |
|---|---|---|---|
"skill" | ELO-based | Players matched by similar rating. Range expands over time. | Yes |
"fifo" | First-come-first-served | First two players in the queue are matched. | No |
"skill"— Best for competitive games where fair matchups matter (puzzle games, fighting games, strategy). Players wait longer but get opponents at their level."fifo"— Best for casual games or games with low player counts where fast matches matter more than fairness. Players match instantly if someone is in the queue.
Skill seeding (only for matchmaking: "skill")
The platform needs baseline data to assign initial ratings. When a new player joins and gets needsSeeding, provide their historical stats from your server.
Score distribution — call once (game-level, typically on server startup):
POST /api/developers/games/{gameId}/score-distribution
Header: X-API-Key: sk_live_your_api_key{
"percentile_5": 35,
"percentile_10": 45,
"percentile_25": 60,
"percentile_50": 78,
"percentile_75": 95,
"percentile_90": 115,
"percentile_95": 130,
"sample_size": 10000
}Player seeding — call per new player when the platform returns status: "needsSeeding":
POST /api/developers/players/seed-skill
Header: X-API-Key: sk_live_your_api_key{
"playerId": "player-uuid",
"gameId": "your-game-id",
"historicalStats": {
"totalGames": 50,
"wins": 25,
"avgScore": 78,
"bestScore": 120
}
}The platform returns the assigned skillRating and confidence level. After seeding, retry joining the queue.
If you don't have historical stats for a player, pass minimal data and the platform will assign a default rating. The rating adjusts as the player completes matches.
Bulk seeding — seed multiple players at once:
POST /api/developers/players/bulk-seed-skill
Header: X-API-Key: sk_live_your_api_key{
"gameId": "your-game-id",
"players": [
{
"playerId": "player-1-uuid",
"historicalStats": { "totalGames": 50, "wins": 25, "avgScore": 78, "bestScore": 120 }
},
{
"playerId": "player-2-uuid",
"historicalStats": { "totalGames": 30, "wins": 10, "avgScore": 65, "bestScore": 95 }
}
]
}No -- You Handle Matchmaking
Don't set the matchmaking option. The widget handles auth and deposits, then onReady fires with the player's credentials. Your system pairs players however you want.
Scripts needed: Widget + SDK (two script tags)
<script src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>
<script src="https://api.withchallenge.com/sdk/src/challenge-sdk.js"></script>// 1. Place Challenge's branded button — renders instantly
Challenge.renderButton("#challenge-button", { variant: "default", size: "lg", fullWidth: true });
// 2. Initialize the widget in background
Challenge.init({
gameId: "your-game-id",
entryFee: 2, // $2 or $5 — set per game at registration
showButton: false, // we use renderButton() above instead
onReady: (data) => {
// Player authenticated and has funds.
// data.userId — player's Challenge ID
// data.email — player's email
// data.balance — current wallet balance
// data.entryFee — entry fee amount
// data.token — JWT for API calls
// Send data.userId to your matchmaking system.
yourMatchmaking.register(data.userId);
},
});// 3. When your matchmaking pairs two players, create a match (server-side)
const sdk = new ChallengeSDK({
apiKey: "sk_live_your_api_key",
gameId: "your-game-id",
});
await sdk.init();
const match = await sdk.createPairedMatch(player1Id, player2Id);
// match.id — Challenge's match UUID (use this for settlement)
// match.status — "active"createPairedMatch verifies both players have sufficient balance, deducts the entry fee from each player's wallet, holds the funds in escrow, and returns a match object with match.id.
If either player has insufficient funds, the call fails with an error.
// 4. Show match found in the widget
Challenge.showMatchFound({
opponent: opponentInfo,
entryFee: 2, // $2 or $5 — set per game at registration
onGameStart: () => startYourGame(match.id),
});After the game, settle per Step 1 — either Challenge.gameEnded() for score mode or sdk.reportWinner() for gameplay mode.
For score mode with your own matchmaking, pass { mode: "score" } to createPairedMatch:
const match = await sdk.createPairedMatch(
player1Id,
player2Id,
"your-internal-match-id", // Optional reference
{ mode: "score" }
);Quick Reference
See Pick Your Path at the top of this guide for the decision table.
Server-Side Score Submission (HMAC)
For games where score integrity matters, submit scores from your server with HMAC signing instead of from the client:
const sdk = new ChallengeSDK({
apiKey: "sk_live_your_api_key",
gameId: "your-game-id",
hmacSecret: "your-hmac-secret", // From Developer Portal
});
await sdk.init();
await sdk.submitScore(matchId, userId, score, {
nonce: crypto.randomUUID(),
userToken: playerToken, // JWT from onReady callback
gameData: { duration: 30, level: 5 },
});The HMAC signature is computed as: HMAC-SHA256(secret, "${matchId}:${score}:${nonce}:${timestamp}")
The request includes signature and timestamp fields. The backend verifies the signature before accepting the score.
HMAC signing requires Node.js (
cryptomodule). It is intended for server-to-server calls, not browser-side code.
Live Score Streaming
During gameplay, stream the player's score to the opponent in real time:
Challenge.updateScore(currentScore);Receive the opponent's score via onOpponentScore in Challenge.init() (see By Score above).
Scores are sent over WebSocket. Challenge.updateScore() is throttled to 5 updates per second.
updateScore/onOpponentScorestreams a single score number — not game state. If your game has visual multiplayer (live boards, replays, spectating), use your own real-time channels for that. The score stream is for showing a numeric score overlay during gameplay.
Rematches
After the result screen, the widget offers "Rematch" and "Find New Opponent" buttons.
When matchmaking is set, rematches are fully automatic. onMatchStart fires again with isRematch: true and a new matchId. "Find New Opponent" re-enters the queue and onMatchStart fires when a new match is found.
When matchmaking is not set (you handle matchmaking), register handlers manually:
Challenge.setPostMatchHandlers({
onMatchStarting: (data) => {
// Rematch accepted. data contains:
// - matchId: new match ID
// - opponent: opponent info
// - isDraw: whether the previous match was a draw
// - isRematch: true
// - roundNumber: match number in this series
matchId = data.matchId;
startYourGame(matchId);
},
onNewOpponent: () => {
// Player wants a different opponent.
// Re-enter your matchmaking system.
},
});For rematches, the platform creates a new match automatically. You skip matchmaking and go straight to gameplay.
SDK Reference
| Method | Auth | Description |
|---|---|---|
new ChallengeSDK({ apiKey, gameId, apiBase?, hmacSecret? }) | -- | Create SDK instance |
sdk.init() | API key | Verify credentials. Returns true or false. |
sdk.createPairedMatch(p1Id, p2Id, ref?, { mode? }) | API key | Create match, escrow funds. Default mode: "versus". |
sdk.reportWinner(winnerId, gameData?) | API key | Settle match, pay winner. Clears matchId. |
sdk.reportDraw(gameData?) | API key | Settle as draw, refund both. Clears matchId. |
sdk.submitScore(matchId, userId, score, opts?) | User token | Submit score for score mode. Optional HMAC. |
sdk.isMoneyMatch() | -- | Returns true if a match is active. |
sdk.getMatchId() | -- | Returns the current match ID or null. |
sdk.clearMatch() | -- | Manually clear the match ID. |
API Key Format
| Prefix | Environment | Real money |
|---|---|---|
sk_live_ | Production | Yes |
sk_test_ | Sandbox | No -- see Sandbox reference |
The SDK auto-detects sandbox mode from the key prefix.
Testing
Use sandbox mode to test without real money. Go to Developer Portal > Sandbox to get:
- A test API key (
sk_test_...) -- works immediately, no approval needed - Your Game ID
- Two bot accounts with email and password that can log into the widget
Quick test: Open your game in two browser tabs. Log in as Bot 1 in one tab, Bot 2 in the other. Both click the Challenge button. They'll match and you can play through the full flow. The widget shows an orange "TEST MODE" banner when using a test key.
See the Sandbox reference for all sandbox endpoints, direct match creation, and details.
Full Example
See the Click Challenge test game for a complete Score Mode implementation using matchmaking: "skill":
- Game page:
/test-game/click-challenge/index.html - Dev server (skill seeding):
/test-game/click-challenge/dev-server.js
Challenge.js Reference
Challenge.js is the client-side JavaScript library that handles player onboarding, matchmaking UI, and result screens. It is loaded via a script tag and exposes a global Challenge object.
This is similar to Stripe.js — you load it, configure it, and it provides embeddable UI components and a JavaScript API.
Loading
Two scripts:
<!-- Button stub (~2KB) — loads synchronously, renders <challenge-button> instantly -->
<script src="https://api.withchallenge.com/widget/dist/challenge-button.js"></script>
<!-- Full SDK (~320KB) — loads async, hydrates buttons and enables modal -->
<script async src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>The stub defines the <challenge-button> custom element and renders the branded button shell in shadow DOM immediately. When the full SDK loads, it finds existing <challenge-button> elements and hydrates them with click handlers. The button appears with zero flash — no waiting for the full SDK.
The full SDK adds window.Challenge to the page and dispatches a challenge-ready event. The apiBase is auto-detected from the script URL unless overridden.
Visual Components
Challenge.js provides three visual components:
- Button (custom element) —
<challenge-button>placed in your HTML. Renders instantly via the stub script. This is the recommended placement. - Button (inline via JS) — Challenge's branded button placed via
Challenge.renderButton(). Works before or afterinit(). - Button (floating) — a fixed-position button in a corner of the page. Use only if you can't modify your page layout.
- Modal — a full-screen overlay that handles player onboarding, matchmaking screens, and result screens. Created lazily on first button click.
The button is Challenge's branded UI — gradient background, wave logo, glow animation. Do not create your own button. Players should recognize the Challenge button across all games. The modal content is managed by Challenge.js automatically.
Configuration
Challenge.init(options)
Initialize Challenge.js. Call once on page load. Returns the Challenge object.
Challenge.init({
gameId: "your-game-id",
entryFee: 2, // $2 or $5 — set per game at registration
onReady: (data) => { ... },
});Options
| Option | Type | Default | Description |
|---|---|---|---|
gameId | string | required | Your game's UUID, from the Developer Portal |
apiBase | string | Auto-detected from script URL | Challenge API base URL. Only set for local development. |
entryFee | number | 2 | Entry fee per player in USD. Set per game at registration. Allowed values: 2 or 5. |
variant | string | "default" | Button text: "default", "1v1", or "play". See Button. |
size | string | "md" | Button size: "sm", "md", or "lg". See Button. |
showButton | boolean | true | Set false to disable the floating button (use when placing inline with renderButton()). |
position | string | "bottom-right" | Floating button position: "bottom-right", "bottom-left", "top-right", "top-left". |
theme | string | "default" | "default" (dark background) or "dark" (light background) |
matchmaking | string | false | false | "skill" (ELO-based), "fifo" (first-come-first-served), or false (your game handles matchmaking). See Integration Guide. |
mode | string | "versus" | "versus" (your game determines the winner) or "score" (platform compares scores automatically). See Integration Guide. |
challengeDomain | string | null | Challenge platform domain for SSO (e.g., "https://challenge.io") |
ssoEnabled | boolean | true | Enable cross-domain single sign-on. Requires challengeDomain. |
ssoTimeout | number | 3000 | Timeout in ms for silent SSO check |
dashboardUrl | string | null | URL to the player dashboard (shown in account menu) |
Callbacks
| Option | Signature | Trigger |
|---|---|---|
onReady | (data) => void | Player is authenticated, funded, and ready to play |
onMatchStart | (match) => void | Match is ready to start (when matchmaking is set). Fires for initial matches and rematches. |
onCancel | (data) => void | Player cancelled matchmaking |
onClose | () => void | Modal was closed |
onError | (error) => void | An error occurred |
onOpponentScore | (data) => void | Opponent sent a live score update (mode: "score" only) |
Callback Payloads
onReady
{
userId: "player-uuid", // Player's Challenge user ID
email: "player@example.com",
balance: 25.00, // Current wallet balance in USD
entryFee: 2.00, // Entry fee for this match
token: "eyJhbG..." // JWT for API calls (Authorization: Bearer <token>)
}onMatchStart (when matchmaking is set)
{
matchId: "match-uuid", // Challenge match ID — use for settlement or score submission
opponent: {
email: "opponent@example.com",
username: "opponent",
skillRating: 1200 // null for FIFO matchmaking
},
isRematch: false, // true if this is a rematch with the same opponent
roundNumber: 1 // Match number in this series (increments on rematch)
}onCancel
{
userId: "player-uuid"
}onError
"Human-readable error message" // String, not an objectonOpponentScore
{
userId: "opponent-uuid",
score: 47 // Opponent's current score
}Methods
Widget Control
Challenge.open()
Open the modal. If the player is authenticated, shows the "ready" screen. If not, shows the auth screen.
Challenge.close()
Close the modal.
Challenge.logout()
Log out the current player. Clears stored tokens and session data.
Player Info
Challenge.getUser() → Promise<object | null>
Returns the current player's data, or null if not authenticated.
Challenge.getBalance() → Promise<number>
Returns the current wallet balance in USD.
Challenge.isAuthenticated() → Promise<boolean>
Returns true if a player is currently logged in with a valid token.
Challenge.checkReadyStatus() → Promise<object>
Check whether the player can enter a match.
// When ready:
{
ready: true,
userId: "player-uuid",
balance: 25.00
}
// When not ready:
{
ready: false,
reason: "insufficient_balance", // or "verification_required", etc.
userId: "player-uuid",
balance: 0.50,
required: 2.00 // Minimum balance needed (only present when not ready)
}Match Flow
Challenge.showMatchFound(data)
Show the match-found countdown screen. Call this when your matchmaking has paired two players.
Challenge.showMatchFound({
matchId: "match-uuid", // Optional at this point
pairingId: "pairing-uuid", // Optional
opponent: { // Required
userId: "opponent-uuid",
email: "opponent@example.com",
skillRating: 1500 // Optional, shown if present
},
entryFee: 2, // $2 or $5 — set per game at registration
playerRating: 1480, // Optional
opponentRating: 1500, // Optional
isRematch: false, // Optional
roundNumber: 1, // Optional
onGameStart: () => { ... } // Called when countdown finishes
});The onGameStart callback fires when the countdown ends. Start your game from this callback.
Challenge.gameEnded(data)
Submit the player's score and let Challenge.js handle settlement and result display. Score Mode only.
Challenge.gameEnded({
matchId: "match-uuid", // Required
score: 47, // Required — must be a number
opponent: { ... }, // Optional — for display on result screen
gameData: { ... }, // Optional — stored with the match record
});After this call, Challenge.js:
- Submits the score to the backend
- Shows "waiting for opponent" if the opponent hasn't submitted
- Shows the result screen when both scores are in
- Handles rematch flow
Warning:
gameEnded()only works for matches created withmode: "score". For versus mode, useChallenge.settle()instead.
Challenge.settle(data)
Settle a versus-mode match directly from the client. Requires apiKey in Challenge.init(). After calling, Challenge.js shows the result screen and handles rematch flow automatically.
// Declare a winner
Challenge.settle({
matchId: "match-uuid", // Required
winnerId: "winner-uuid", // Required — the winning player's ID
gameData: { ... }, // Optional — stored with the match record
});
// Declare a draw
Challenge.settle({
matchId: "match-uuid", // Required
gameData: { ... }, // Optional
});After this call, Challenge.js:
- Sends the settlement to the backend (authenticated by your API key)
- Shows the win, loss, or draw result screen
- Handles rematch flow
This is the recommended approach for versus mode. No server endpoint needed — the widget handles settlement directly. For server-side settlement, see the SDK reference.
Challenge.updateScore(score)
Send a live score update to the opponent during gameplay. Throttled to 5 updates per second.
Challenge.updateScore(currentScore);The opponent receives this via the onOpponentScore callback. Requires an active match (the widget tracks this internally). Sends data over WebSocket.
Result Screens
Call these after your game or server has determined the outcome. These are display-only — they do not trigger settlement.
Challenge.showWin(data)
Challenge.showWin({
matchId: "match-uuid",
opponent: { userId: "...", email: "..." },
profit: 1.40 // Display amount
});Challenge.showLose(data)
Challenge.showLose({
matchId: "match-uuid",
opponent: { userId: "...", email: "..." },
loss: 2.00 // Display amount
});Challenge.showDraw(data)
Challenge.showDraw({
matchId: "match-uuid",
opponent: { userId: "...", email: "..." }
});Post-Match Handlers
Challenge.setPostMatchHandlers(handlers)
Register callbacks for rematch and new opponent flows. Call once after Challenge.init().
Challenge.setPostMatchHandlers({
onMatchStarting: (data) => { ... },
onNewOpponent: () => { ... },
});onMatchStarting payload:
{
matchId: "new-match-uuid", // The new match ID for the rematch
opponent: { ... }, // Opponent info
isDraw: false, // Whether the previous match was a draw
isRematch: true,
roundNumber: 2 // Match number in this series (1-indexed)
}onNewOpponent — no payload. The player wants a different opponent. Re-enter your matchmaking flow.
Button
The Challenge button is a branded UI element — gradient background, wave logo, pulsing glow animation. Do not create your own button. Players must recognize the Challenge button across all games.
The button must be on the main page of your game. There are three placement types:
- Custom element (recommended) —
<challenge-button>placed directly in your HTML. Renders instantly via the stub script. Zero flash. - Inline via JS — placed via
Challenge.renderButton(). Works before or afterinit(). - Floating — fixed-position button on the page, created automatically by
Challenge.init()
Pick one. If you use custom element or inline, disable the floating button with showButton: false.
Custom Element (recommended)
Load the stub script in your <head>, then place <challenge-button> wherever you want the button:
<script src="https://api.withchallenge.com/widget/dist/challenge-button.js"></script>
<challenge-button variant="play" size="lg" full-width></challenge-button>
<script async src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>
<script>
window.addEventListener('challenge-ready', () => {
Challenge.init({ gameId: "your-game-id", showButton: false });
});
</script>The button renders instantly — before the full SDK loads. When the SDK loads, it hydrates the element with click handlers automatically.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
variant | "default", "1v1", "play" | "default" | Button text |
size | "sm", "md", "lg" | "md" | Button size |
full-width | (boolean attribute) | — | Make button 100% width |
The custom element uses shadow DOM — its styles are fully isolated from your page CSS.
Text Variants
Set variant in Challenge.init(). It applies to both inline and floating buttons.
| Variant | Text shown |
|---|---|
"default" | "Challenge for Money" |
"1v1" | "1v1 for Money" |
"play" | "Play for Money" |
Sizes
Set size in Challenge.init(). It applies to both inline and floating buttons. Can also be overridden per-button in renderButton().
| Size | Use case |
|---|---|
"sm" | Compact menus, sidebars |
"md" | Default — fits most layouts |
"lg" | Main menu, landing pages |
Inline Placement
Place an empty container on your main page next to your play buttons. renderButton() can be called before Challenge.init() — the button renders instantly as pure HTML/CSS with no async dependency.
Challenge.renderButton(selector, options) → HTMLElement | null
<div class="menu-buttons">
<button onclick="playNormal()">Play</button>
<div id="challenge-button"></div>
</div>// Button renders instantly — no init required first
Challenge.renderButton("#challenge-button", {
variant: "1v1",
size: "lg",
fullWidth: true, // true = 100% width (match your other buttons)
});
// Init runs in background
Challenge.init({
gameId: "your-game-id",
entryFee: 2, // $2 or $5 — set per game at registration
showButton: false, // disable floating — we use inline
onReady: handlePlayerReady,
});If the button is clicked before init() completes, it shows a brief loading state and opens the modal once ready.
renderButton() inherits variant and size from Challenge.init() when called after init. When called before init, pass them directly:
Challenge.renderButton("#challenge-button", {
variant: "play",
size: "sm",
fullWidth: true,
});Returns the button element, or null if the container was not found.
Backward compatible: Calling
init()thenrenderButton()still works identically.
Floating Placement
Challenge.init() creates a floating button by default. Set position to control where it appears.
Challenge.init({
gameId: "your-game-id",
entryFee: 2, // $2 or $5 — set per game at registration
variant: "play",
size: "md",
position: "bottom-right",
onReady: handlePlayerReady,
});| Position | Location |
|---|---|
"bottom-right" | Bottom-right corner (default) |
"bottom-left" | Bottom-left corner |
"top-right" | Top-right corner |
"top-left" | Top-left corner |
Show / Hide (floating button only)
These methods only control the floating button. They do not affect inline buttons.
Challenge.showButton();
Challenge.hideButton();Player Onboarding Flow
When a player clicks the button, Challenge.js opens a modal and walks them through onboarding. The developer does not control these screens — they are managed automatically. Returning players skip completed steps.
Steps (in order):
- Auth — login or start registration
- Email — enter email address
- Verify Email — enter OTP code sent to email
- Password — create password
- Birthday — date of birth (age verification)
- Name — first and last name
- Profile — username selection
- Location — country and state (checks for restricted states)
- Deposit — add funds via credit card or bank account
- Deposit Success — confirmation screen after successful deposit
- Verify — identity verification via Veriff
- Wallet — ensure sufficient balance for entry fee
- Ready — player is funded and waiting for a match
After reaching "Ready", the onReady callback fires with the player's credentials.
Additional screens (shown contextually, not part of the linear flow):
- Match Found — countdown screen (triggered by
showMatchFound()) - Match Result — win/loss/draw (triggered by
showWin()/showLose()/showDraw()orgameEnded()) - Rematch — accept/decline rematch offer
- Stats — player match statistics
- History — match history for this game
- Account — account settings
- Support — help and support
postMessage Events
For games embedded in iframes or needing cross-frame communication, Challenge.js sends and receives postMessage events.
Events Sent (Challenge.js → Parent Window)
| Event | When |
|---|---|
CHALLENGE_READY | Challenge.js initialized |
CHALLENGE_AUTHENTICATED | Player authenticated |
CHALLENGE_PLAYER_READY | Player is ready to play (same as onReady) |
CHALLENGE_CLOSED | Modal closed |
CHALLENGE_ERROR | Error occurred |
CHALLENGE_DEPOSIT_COMPLETE | Deposit succeeded |
CHALLENGE_MATCH_READY | Match found (lobby mode) |
CHALLENGE_GAME_START | Match countdown finished, game should start |
Events Received (Parent Window → Challenge.js)
| Event | When to send |
|---|---|
CHALLENGE_MATCH_FOUND | Game-controlled matchmaking found an opponent |
Session Management
Challenge.js manages player sessions automatically. Developers do not need to handle tokens directly (the token in onReady is for your API calls to Challenge, not for session management).
Behavior:
- Access token, refresh token, and expiry are stored in
localStorageunder keys prefixed withchallenge_ - Tokens auto-refresh when expiring within 5 minutes
- Background refresh runs every 45 minutes during long game sessions
- A 401 response from the API triggers a single automatic retry after refreshing the token
Challenge.logout()clears all stored session data
localStorage keys:
| Key | Contents |
|---|---|
challenge_token | JWT access token |
challenge_refresh_token | Refresh token |
challenge_token_expiry | Token expiry timestamp |
challenge_user | User object (JSON) |
SDK Reference
The Challenge SDK is a JavaScript library for match creation and settlement. It is separate from Challenge.js — the SDK handles money operations (escrow, settlement, payouts), while Challenge.js handles the player-facing UI.
The SDK runs in the browser or Node.js. HMAC signing for server-to-server score submission requires Node.js.
Loading
Browser:
<script src="https://api.withchallenge.com/sdk/src/challenge-sdk.js"></script>This adds window.ChallengeSDK to the page.
Node.js:
const ChallengeSDK = require("./path/to/challenge-sdk.js");Initialization
Constructor
const sdk = new ChallengeSDK({
apiKey: "sk_live_your_api_key", // Required
gameId: "your-game-id", // Required
apiBase: "https://api.withchallenge.com", // Optional, defaults to "http://localhost:4837"
hmacSecret: "your-hmac-secret", // Optional, for server-to-server score signing
});| Parameter | Type | Required | Description |
|---|---|---|---|
apiKey | string | Yes | Your API key from the Developer Portal |
gameId | string | Yes | Your game's UUID from the Developer Portal |
apiBase | string | No | Challenge API base URL |
hmacSecret | string | No | Secret for HMAC-SHA256 score signing (server-to-server only) |
sdk.init() → Promise<boolean>
Verifies the API key and game ID with the Challenge backend. Must be called before any other method.
const success = await sdk.init();
if (!success) {
// Invalid API key or game ID
}- Calls
POST /api/games/verifywith the API key and game ID - Returns
trueif valid,falseif invalid - Does not throw — catches errors internally and logs to console
- Sets
sdk.isActive = trueon success - Auto-detects sandbox mode if the API key starts with
sk_test_
Match Creation
sdk.createPairedMatch(player1Id, player2Id, gameMatchId?, options?) → Promise<object>
Create a match between two players. Verifies both players have sufficient funds, deducts the entry fee from each, and holds the funds in escrow.
const match = await sdk.createPairedMatch(
"player-1-uuid",
"player-2-uuid",
"your-internal-match-ref", // Optional: your own match ID for tracking
{ mode: "versus" } // Optional: "versus" (default) or "score"
);| Parameter | Type | Required | Description |
|---|---|---|---|
player1Id | string | Yes | Challenge user ID of player 1 |
player2Id | string | Yes | Challenge user ID of player 2 |
gameMatchId | string | No | Your internal match reference (stored with the match) |
options.mode | string | No | "versus" (default) — your game determines the winner. "score" — platform auto-settles by comparing scores. |
Returns the match object:
{
id: "match-uuid", // Use this for settlement and API calls
player1_id: "...",
player2_id: "...",
status: "active",
// ... other fields
}The SDK stores match.id internally as sdk.matchId. This is used by reportWinner and reportDraw.
Errors:
- Throws if SDK is not initialized
- Throws if either player has insufficient funds
- Throws if the API key or game ID is invalid
Note: Only call
createPairedMatchonce per match. In integrations using platform matchmaking, Player 1 creates the match and shares the ID with Player 2 via the queue API. See the integration guides.
Settlement (By Gameplay)
For matches created with mode: "versus" (the default). Your game determines the winner and reports it.
sdk.reportWinner(winnerId, gameData?) → Promise<object>
Settle the match with a winner. The winner receives the prize pool minus the platform fee.
const result = await sdk.reportWinner("winner-uuid", {
score: 250,
moves: 15,
duration: 180,
});| Parameter | Type | Required | Description |
|---|---|---|---|
winnerId | string | Yes | Challenge user ID of the winner |
gameData | object | No | Game-specific metadata, stored with the match record. Cannot exceed 10KB. |
Returns:
{
message: "Match settled successfully",
winner: "winner-uuid", // The winner's user ID
payout: 3.40, // Amount transferred to winner
developerEarnings: 0.20, // Your revenue share
}- Calls
POST /api/matches/settlewith the API key - Clears
sdk.matchIdafter settlement - Throws if no active match (
sdk.matchIdis null) - Throws if the match is already settled
sdk.reportDraw(gameData?) → Promise<object>
Settle the match as a draw. Both players are refunded their entry fees.
const result = await sdk.reportDraw({
board: finalBoard,
moves: moveCount,
});| Parameter | Type | Required | Description |
|---|---|---|---|
gameData | object | No | Game-specific metadata, stored with the match record. Cannot exceed 10KB. |
- Calls
POST /api/matches/settlewithisDraw: true - Clears
sdk.matchIdafter settlement - Throws if no active match
Warning:
reportWinnerandreportDraware formode: "versus"only. Formode: "score", useChallenge.gameEnded()on the client orsdk.submitScore()from your server. CallingreportWinneron a score-mode match will produce unexpected results.
Score Submission (By Score)
For matches created with mode: "score". Each player submits their score, and the platform settles automatically.
There are two ways to submit scores:
| Method | Runs on | Auth | Integrity |
|---|---|---|---|
Challenge.gameEnded() | Browser (Challenge.js) | Player's JWT token | Client-side — player could tamper |
sdk.submitScore() | Server (Node.js) | Player's JWT + optional HMAC | Server-side — tamper-proof with HMAC |
Use Challenge.gameEnded() for simplicity. Use sdk.submitScore() when score integrity matters.
sdk.submitScore(matchId, userId, score, options?) → Promise<object>
Submit a score for a player in a score-mode match.
await sdk.submitScore("match-uuid", "player-uuid", 47, {
nonce: "unique-request-id",
gameData: { duration: 30, level: 5 },
userToken: "player-jwt-token",
hmacSecret: "override-secret", // Optional: override the SDK's hmacSecret
});| Parameter | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | The match ID |
userId | string | Yes | The player's Challenge user ID |
score | number | Yes | The player's final score |
options.nonce | string | No | Arbitrary string for request uniqueness (defaults to "") |
options.gameData | object | No | Game-specific metadata |
options.userToken | string | No | Player's JWT token (sent as Authorization: Bearer) |
options.hmacSecret | string | No | Override the SDK-level hmacSecret |
Returns:
{
success: true,
message: "Score submitted",
bothScoresIn: false, // true if both players have submitted
settlementResult: null // Present when bothScoresIn is true — contains payout details
}Score submission is idempotent — submitting the same score twice returns success with the existing state.
- Calls
POST /api/matches/submit-score - Authenticated with the player's token (
Authorization: Bearer), not the API key - Does not clear
sdk.matchId
HMAC Signing
When hmacSecret is configured (either in the constructor or in options), the SDK signs the score submission to prevent tampering.
Signature computation:
message = "${matchId}:${score}:${nonce}:${timestamp}"
signature = HMAC-SHA256(secret, message) // hex-encodedThe request body includes the additional fields timestamp (Unix ms) and signature (hex string). The backend verifies the signature before accepting the score.
// Server-side example (Node.js)
const sdk = new ChallengeSDK({
apiKey: "sk_live_your_api_key",
gameId: "your-game-id",
hmacSecret: "your-hmac-secret",
});
await sdk.init();
await sdk.submitScore(matchId, userId, score, {
nonce: crypto.randomUUID(),
userToken: playerToken,
});
// SDK automatically computes and attaches the HMAC signatureNote: HMAC signing requires Node.js (
cryptomodule). In the browser, the SDK throws "HMAC signing requires Node.js crypto module". For browser-side score submission, useChallenge.gameEnded()instead.
Utility Methods
sdk.isMoneyMatch() → boolean
Returns true if a match is currently active (i.e., sdk.matchId is not null).
sdk.getMatchId() → string | null
Returns the current match ID, or null if no match is active.
sdk.clearMatch()
Manually clear the stored match ID. Use this if you need to abandon a match without settling. Under normal operation, reportWinner and reportDraw clear the match ID automatically.
Authentication
The SDK uses two authentication methods depending on the operation:
| Operation | Header | Value |
|---|---|---|
init, createPairedMatch, reportWinner, reportDraw | X-API-Key | Your API key |
submitScore | Authorization | Bearer <player-jwt-token> |
API Key Format
| Prefix | Environment | Real money |
|---|---|---|
sk_live_ | Production | Yes |
sk_test_ | Sandbox | No — uses bot opponents and test funds |
The SDK auto-detects sandbox mode from the key prefix and logs a message on init.
Error Handling
| Method | On failure |
|---|---|
init() | Returns false. Does not throw. Logs to console. |
createPairedMatch() | Throws Error with message from backend (e.g., "Insufficient funds") |
reportWinner() | Throws Error with message from backend |
reportDraw() | Throws Error with message from backend |
submitScore() | Throws Error with message from backend |
Precondition errors (thrown before making an API call):
"SDK not initialized. Call init() first."— calledcreatePairedMatchorsubmitScorebeforeinit()"No active match"— calledreportWinnerorreportDrawwith nomatchId
Method Reference
| Method | Auth | Endpoint | Returns |
|---|---|---|---|
init() | API key | POST /api/games/verify | boolean |
createPairedMatch(p1, p2, ref?, opts?) | API key | POST /api/matches/create-paired | Match object |
reportWinner(winnerId, gameData?) | API key | POST /api/matches/settle | Settlement result |
reportDraw(gameData?) | API key | POST /api/matches/settle | Settlement result |
submitScore(matchId, userId, score, opts?) | Bearer token | POST /api/matches/submit-score | Submission result |
isMoneyMatch() | — | — | boolean |
getMatchId() | — | — | string | null |
clearMatch() | — | — | void |
API Reference
Complete reference for endpoints that game developers call directly. Endpoints handled internally by Challenge.js (auth, onboarding, wallet, payments, verification) are not listed here — Challenge.js manages those automatically.
Base URL
The base URL is the Challenge API host. In local development: http://localhost:4837/api. In production, the URL is provided in the Developer Portal.
Authentication
Two authentication methods are used depending on the endpoint:
| Method | Header | Used by |
|---|---|---|
| Player token | Authorization: Bearer <jwt> | Queue, score submission, rematch |
| API key | X-API-Key: <key> | SDK operations (verify, create match, settle), skill seeding |
Player tokens come from the onReady callback in Challenge.js. API keys come from the Developer Portal.
Response Format
Success:
{ "field": "value", "message": "Optional message" }Error:
{ "error": "Human-readable error message" }HTTP status codes: 200 success, 400 bad request, 401 unauthorized, 403 forbidden, 404 not found, 409 conflict, 500 server error.
Game Verification
POST /api/games/verify
Verify API key and game ownership. Called by sdk.init().
Auth: API key
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
Response:
{ "verified": true, "game": { "id": "...", "name": "...", "entry_fee": 2 } }The entry_fee field is the per-player entry fee in USD ($2 or $5), configured when the game is registered in the Developer Portal.
GET /api/games/:gameId/info
Get public game info. No authentication required.
Response:
{ "name": "Click Challenge", "icon_url": "...", "banner_url": "...", "entry_fee": 2 }Queue — FIFO Lobby
FIFO matchmaking. First two players to join are paired.
POST /api/queue/lobby/join
Join the lobby for a game.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
mode | string | No | "versus" (default) or "score" |
Response (waiting):
{ "status": "waiting", "playersInLobby": 1 }Response (matched):
{
"status": "matched",
"ready": true,
"playersInLobby": 2,
"players": [
{ "userId": "uuid", "email": "player1@example.com" },
{ "userId": "uuid", "email": "player2@example.com" }
],
"pairingId": "pairing-uuid"
}POST /api/queue/lobby/heartbeat
Keep-alive and check for match. Call every 5 seconds while waiting.
Auth: Bearer token
Response: Same format as /lobby/join. Status is "matched", "alive" (still waiting), or "cancelled".
When matched, may also include matchId if Player 1 has already created the match via /lobby/setMatch.
POST /api/queue/lobby/leave
Leave the lobby.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
POST /api/queue/lobby/setMatch
Share a match ID with the paired opponent. Called by Player 1 after creating the match via SDK.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID from the match response |
matchId | string | Yes | Match ID from sdk.createPairedMatch() |
POST /api/queue/lobby/clear
Clear the pairing after a match starts or when leaving.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | No | Specific pairing to clear |
gameId | string | No | Clear all pairings for this game |
POST /api/queue/lobby/create-match
Create a match from a lobby pairing. Called by Player 1's widget after being paired.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID from the match response |
mode | string | No | Match mode override (prefers mode stored on pairing) |
Response:
{ "matchId": "match-uuid", "mode": "score" }POST /api/queue/lobby/launch-ready
Signal that this player is ready to start. Both players must signal before the game starts.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID |
Response:
{ "success": true, "bothReady": false }When bothReady is true, both players have signaled and the game can start.
GET /api/queue/lobby/launch-status/:pairingId
Check if both players are ready. Poll this if launch-ready returned bothReady: false.
Auth: Bearer token
Response:
{ "bothReady": true }GET /api/queue/lobby/stats/:gameId
Get lobby statistics. No authentication required.
Response:
{ "stats": { "playersInLobby": 3 } }Queue — Skill
Skill-based matchmaking using ELO ratings. Players are matched with opponents of similar skill.
POST /api/queue/skill/join
Join the skill-based queue.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
skillData | object | No | Additional skill context |
mode | string | No | Match mode override |
Response (queued):
{
"status": "queued",
"skillRating": 1500,
"searchRange": { "min": 1450, "max": 1550 }
}Response (matched):
{
"status": "matched",
"pairing": {
"pairingId": "pairing-uuid",
"player1": { "id": "uuid", "email": "...", "skillRating": 1500 },
"player2": { "id": "uuid", "email": "...", "skillRating": 1480 },
"skillDifference": 20,
"matchId": null
}
}Response (needs seeding):
{ "status": "needsSeeding" }When needsSeeding is returned, seed the player via /api/developers/players/seed-skill and retry.
POST /api/queue/skill/heartbeat
Keep-alive and check for match. Call every 5 seconds while waiting. The search range expands over time.
Auth: Bearer token
Response: Same statuses as /skill/join: "matched", "waiting", "expired".
When waiting, includes searchRange and waitTime. When matched, includes pairing (which may include matchId if already created).
POST /api/queue/skill/leave
Leave the skill queue.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
POST /api/queue/skill/create-match
Create a match from a skill pairing (auto-match). Alternative to using the SDK's createPairedMatch.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID from the match response |
Response:
{ "matchId": "match-uuid", "mode": "score" }POST /api/queue/skill/setMatch
Share a match ID with the paired opponent. Same as lobby equivalent.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID |
matchId | string | Yes | Match ID |
POST /api/queue/skill/launch-ready
Signal that this player is ready to start. Both players must signal before the game starts.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | Yes | Pairing ID |
Response:
{ "success": true, "bothReady": false }When bothReady is true, both players have signaled and the game can start.
GET /api/queue/skill/launch-status/:pairingId
Check if both players are ready. Poll this if launch-ready returned bothReady: false.
Auth: Bearer token
Response:
{ "bothReady": true }POST /api/queue/skill/clear
Clear the skill queue pairing.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
pairingId | string | No | Specific pairing to clear |
gameId | string | No | Clear all pairings for this game |
GET /api/queue/skill/rating/:gameId
Get the current player's skill rating for a game.
Auth: Bearer token
Response:
{
"skillRating": 1500,
"tier": "silver",
"tierName": "Silver",
"tierColor": "#C0C0C0",
"confidence": 0.85,
"totalMatches": 42,
"wins": 25,
"losses": 17,
"winRate": "59.5"
}Matches
POST /api/matches/create-paired
Create a match between two pre-paired players. Deducts entry fees and holds funds in escrow. Called by sdk.createPairedMatch().
Auth: API key
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | No | Your game ID (falls back to SDK's configured game ID) |
player1Id | string | Yes | Challenge user ID of player 1 |
player2Id | string | Yes | Challenge user ID of player 2 |
gameMatchId | string | No | Your internal match reference |
mode | string | No | "versus" (default) or "score" |
Response:
{
"message": "Match created successfully",
"match": {
"id": "match-uuid",
"player1_id": "...",
"player2_id": "...",
"status": "active",
"mode": "versus"
}
}POST /api/matches/settle
Settle a versus-mode match. Called by sdk.reportWinner() or sdk.reportDraw().
Auth: API key
Request (winner):
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | Match ID |
winnerId | string | Yes | Challenge user ID of the winner |
gameData | object | No | Game-specific metadata |
Request (draw):
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | Match ID |
isDraw | boolean | Yes | Must be true |
gameData | object | No | Game-specific metadata |
Response:
{
"message": "Match settled successfully",
"winner": "winner-uuid",
"payout": 3.40,
"developerEarnings": 0.20
}POST /api/matches/submit-score
Submit a player's score for a score-mode match. Called by Challenge.js (gameEnded()) or sdk.submitScore().
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | Match ID |
score | number | Yes | The player's final score |
gameData | object | No | Game-specific metadata |
nonce | string | No | For HMAC-signed requests |
signature | string | No | HMAC-SHA256 signature |
timestamp | number | No | Unix timestamp in ms (for HMAC) |
When both players have submitted, the platform compares scores and settles automatically. Score submission is idempotent — resubmitting the same score returns success. Rate limited to 30 requests per 5 minutes per user.
GET /api/matches/score-status/:matchId
Check score submission status for a match.
Auth: Bearer token
Response:
{ "status": "waiting", "scores": { "player1": 47, "player2": null } }GET /api/matches/:matchId
Get match details.
Auth: Bearer token
Response: Full match object with players, status, scores, settlement info.
GET /api/matches/history
Get the player's match history.
Auth: Bearer token
Query parameters:
| Param | Type | Description |
|---|---|---|
gameId | string | Filter by game (optional) |
Response:
{ "matches": [ { ... }, { ... } ] }Returns up to 50 matches, most recent first.
Rematch
GET /api/matches/rematch/eligibility/:matchId
Check if a rematch is possible (both players have sufficient funds, etc.).
Auth: Bearer token
Response:
{ "eligible": true, "player1Balance": 25.00, "player2Balance": 18.00 }POST /api/matches/rematch/ready
Signal that this player is ready for a rematch.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | The completed match ID |
POST /api/matches/rematch/adding-funds
Signal that the player is adding funds (prevents rematch timeout while depositing).
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | The completed match ID |
isAdding | boolean | Yes | true when starting deposit, false when done |
GET /api/matches/rematch/status/:matchId
Check rematch status.
Auth: Bearer token
Response:
{
"hasRequest": true,
"status": "pending",
"isRequester": true,
"iAmReady": true,
"opponentReady": false
}POST /api/matches/rematch/respond
Accept or decline a rematch.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | The completed match ID |
accept | boolean | Yes | true to accept, false to decline |
POST /api/matches/rematch/cancel
Cancel a pending rematch request.
Auth: Bearer token
Request:
| Field | Type | Required | Description |
|---|---|---|---|
matchId | string | Yes | The completed match ID |
Skill Seeding
Endpoints for seeding player skill data and score distributions. Called from your server with an API key.
POST /api/developers/players/seed-skill
Seed a single player's skill data.
Auth: API key
Request:
| Field | Type | Required | Description |
|---|---|---|---|
playerId | string | Yes | Challenge user ID |
gameId | string | Yes | Your game ID |
historicalStats.totalGames | number | Yes | Total games played |
historicalStats.wins | number | Yes | Total wins |
historicalStats.avgScore | number | No | Average score |
historicalStats.bestScore | number | No | Best score |
Response:
{ "success": true, "skillRating": 1500, "confidence": 0.85 }POST /api/developers/players/bulk-seed-skill
Seed multiple players at once.
Auth: API key
Request:
{
"gameId": "your-game-id",
"players": [
{ "playerId": "uuid", "historicalStats": { "totalGames": 50, "wins": 25 } },
{ "playerId": "uuid", "historicalStats": { "totalGames": 30, "wins": 10 } }
]
}Response:
{ "success": true, "processed": 2, "failed": 0, "errors": [] }POST /api/developers/games/:gameId/score-distribution
Seed the game's score distribution. Tells the platform what scores look like in your game so it can calibrate skill ratings.
Auth: API key
Request:
{
"percentile_5": 35,
"percentile_10": 45,
"percentile_25": 60,
"percentile_50": 78,
"percentile_75": 95,
"percentile_90": 115,
"percentile_95": 130,
"sample_size": 10000
}Response:
{ "success": true, "message": "Score distribution updated", "challengeWeight": 0.7, "blendedDistribution": { ... } }GET /api/developers/games/:gameId/skill-distribution
Get the current skill distribution for your game.
Auth: API key
Sandbox
Test endpoints for sandbox mode. Requires a test API key (sk_test_ prefix). See Sandbox for full details.
| Endpoint | Description |
|---|---|
POST /api/sandbox/matches | Create a test match with a bot opponent |
POST /api/sandbox/matches/:id/bot-score | Trigger bot to submit a score |
GET /api/sandbox/activity | Get sandbox activity log |
POST /api/sandbox/trigger-webhook | Send a test webhook event |
POST /api/sandbox/reset/:gameId | Delete all test data for a game |
WebSocket Events Reference
Challenge uses WebSockets for real-time communication: live score streaming, match settlement notifications, rematch flow, and admin alerts.
Connection
wss://<apiBase>/ws?token=<jwt>The JWT is the player token from the onReady callback. The server validates the token and auto-subscribes the connection to the user's personal channel (user:<userId>).
Connection confirmation (server → client):
{
"type": "connected",
"data": { "userId": "uuid", "role": "player" },
"ts": 1234567890
}Challenge.js manages the WebSocket connection automatically. Developers do not need to connect manually unless building a custom integration.
Channels
Messages are routed through named channels. Clients subscribe to channels to receive relevant events.
| Channel | Format | Who can subscribe | Purpose |
|---|---|---|---|
user:<userId> | Auto-subscribed on connect | The user themselves | Personal notifications |
match:<matchId> | Subscribe manually | Players in that match | Match-specific real-time data |
admin:all | Auto-subscribed for admins | Admin users only | Platform alerts |
developer:<developerId> | Auto-subscribed for developers | The developer themselves | Developer notifications |
Subscribing to Channels (client → server)
{ "type": "subscribe", "channels": ["match:match-uuid"] }Unsubscribing (client → server)
{ "type": "unsubscribe", "channels": ["match:match-uuid"] }Client → Server Events
score.update
Send a live score update during gameplay. Challenge.updateScore() throttles to 5 updates per second on the client. The server allows up to 10 per second as a safety margin.
{
"type": "score.update",
"matchId": "match-uuid",
"score": 127
}The server relays this to the opponent as a score.live event on the match channel.
ping
Keep-alive. Server responds with pong.
{ "type": "ping" }auth.refresh
Refresh the WebSocket session token without reconnecting.
{
"type": "auth.refresh",
"token": "new-jwt-token"
}Server responds with { "type": "auth.refreshed" }.
Server → Client Events
Match Events (channel: match:<matchId>)
score.live
Opponent's live score during gameplay. Relayed from their score.update message.
{
"type": "score.live",
"data": { "userId": "opponent-uuid", "score": 127 }
}In Challenge.js, this triggers the onOpponentScore callback.
score.submitted
One player has submitted their final score.
{
"type": "score.submitted",
"data": { "matchId": "match-uuid", "playerId": "player-uuid" }
}match.settled
Match has been settled. Contains the result.
{
"type": "match.settled",
"data": {
"matchId": "match-uuid",
"winnerId": "player-uuid",
"payout": 3.40
}
}isDraw is only present when the match is a draw. winnerId is null when isDraw is true.
opponent.left
Opponent disconnected or abandoned the match.
{
"type": "opponent.left",
"data": { "matchId": "match-uuid", "userId": "opponent-uuid" }
}rematch.status_changed
Rematch request was accepted or declined.
{
"type": "rematch.status_changed",
"data": {
"status": "accepted",
"matchId": "original-match-uuid",
"newMatchId": "new-match-uuid",
"roundNumber": 2
}
}matchId (the original match) is always present. newMatchId and roundNumber are only present when status is "accepted".
User Events (channel: user:<userId>)
balance.updated
Wallet balance changed (deposit, withdrawal, match payout).
{
"type": "balance.updated",
"data": { "balance": 25.50, "change": 5.00, "reason": "deposit" }
}match.found
A match has been found. Payload varies by matchmaking type:
Skill queue — sent when the ELO pairing algorithm finds a suitable opponent:
{
"type": "match.found",
"data": { "pairingId": "pairing-uuid", "opponentId": "opponent-uuid" }
}Lobby queue (FIFO) — sent when the server creates a match for the waiting player:
{
"type": "match.found",
"data": { "pairingId": "pairing-uuid", "matchId": "match-uuid" }
}verification.completed
Fired when identity verification is approved. Not emitted on decline.
{
"type": "verification.completed",
"data": { "status": "approved" }
}Admin Events (channel: admin:all)
| Event | Trigger | Data |
|---|---|---|
fraud.alert_created | Suspicious activity detected | userId, type, severity |
dispute.created | Player filed a dispute | disputeId, matchId |
withdrawal.created | Withdrawal requested | withdrawalId, userId, amount |
withdrawal.status_changed | Admin approved/rejected withdrawal | withdrawalId, status |
game.review_completed | AI game review finished | gameId, decision, score |
Developer Events (channel: developer:<developerId>)
| Event | Trigger | Data |
|---|---|---|
game.validation_changed | Game approval status updated | gameId, status |
webhook.delivery_updated | Webhook delivered | deliveryId, status |
Message Format
All server messages follow this format:
{
"type": "event.name",
"channel": "channel:id",
"data": { ... },
"ts": 1234567890
}| Field | Type | Description |
|---|---|---|
type | string | Event name, dot-separated |
channel | string | Channel the event was sent on |
data | object | Event-specific payload |
ts | number | Unix timestamp in milliseconds |
Server-Side Broadcasting
Internal services emit events through the event bus:
wsEventBus.toUser(userId, "balance.updated", { balance, change, reason });
wsEventBus.toMatch(matchId, "score.live", { userId, score });
wsEventBus.toAdmins("fraud.alert_created", { userId, type, severity });
wsEventBus.toDeveloper(developerId, "game.validation_changed", { gameId, status });Source files:
| Service | Events emitted |
|---|---|
settlementService.ts | score.submitted, match.settled |
skillMatchmaking.ts | match.found |
matches.ts | rematch.status_changed, opponent.left |
wallet.ts | withdrawal.created, balance.updated |
integrations/stripe.ts | balance.updated |
integrations/veriff.ts | verification.completed |
gameValidation.ts | game.validation_changed |
gameReviewAgent.ts | game.review_completed |
fraudDetection.ts | fraud.alert_created |
disputes.ts | dispute.created |
webhookDelivery.ts | webhook.delivery_updated |
routes/admin.ts | withdrawal.status_changed |
Implementation Details
Heartbeat: Server pings every 30 seconds. Dead connections are terminated after timeout.
Score throttling: Maximum 10 live score updates per second per user per match (100ms minimum interval).
Reconnection: Challenge.js uses exponential backoff (1s → 30s max) and auto-resubscribes to match channels on reconnect.
Channel authorization: The server verifies channel access — users can only subscribe to their own user: channel, and match channels are verified against the database. Unauthorized subscribe requests are silently ignored.
Sandbox Reference
The sandbox is an isolated testing environment for game developers. It uses test API keys, bot opponents, and zero-dollar matches so developers can integrate and test without real money.
This follows the same model as Stripe's test mode: test keys only interact with test objects, and live keys only interact with live objects.
Testing Your Integration
Get Your Credentials
All credentials are on the Developer Portal → Sandbox page:
- Test API Key:
sk_test_...prefix (works immediately, no approval needed) - Game ID: UUID for your game
- Bot accounts: 2 accounts with email and password that can log into the widget
Script Tags
Add these to your game page:
<script src="https://api.withchallenge.com/widget/dist/challenge-widget.js"></script>
<script src="https://api.withchallenge.com/sdk/src/challenge-sdk.js"></script>Set apiBase in your init call:
Challenge.init({
gameId: "your-game-id", // From Developer Portal → Sandbox page
apiKey: "sk_test_...", // Test API key — activates sandbox mode
entryFee: 2, // $2 or $5 — set per game at registration
matchmaking: "skill",
mode: "score",
onMatchStart: (match) => { /* start your game */ },
});The
apiKeywith ansk_test_prefix is what activates sandbox mode. Without it, the widget runs in production mode with real money. Always use your test API key during development.
Test with Matchmaking (two browser tabs)
- Open Tab 1 → your game page
- Open Tab 2 → your game page
- Tab 1: click the Challenge button, log in as Bot 1 (email + password from Sandbox page)
- Tab 2: click the Challenge button, log in as Bot 2
- Both tabs: click "Play Now" → both join the queue → matched → game starts
- Play the game in both tabs, see results
The widget shows an orange "TEST MODE — No real money" banner when using a test API key.
Test without Matchmaking (API shortcut)
Skip the queue entirely — create a match directly with bot opponents:
curl -X POST https://api.withchallenge.com/api/sandbox/matches \
-H "X-API-Key: sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{"mode": "score"}'Returns a match with two bot players. For score mode, the bot auto-submits a random score after 2 seconds. You can also force a specific bot score:
curl -X POST https://api.withchallenge.com/api/sandbox/matches/MATCH_ID/bot-score \
-H "X-API-Key: sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{"score": 500}'Activating Sandbox Mode
Sandbox mode is activated by using a test API key. No configuration flag or environment switch is needed.
| API Key Prefix | Mode | Real Money |
|---|---|---|
sk_test_ | Sandbox | No |
sk_live_ | Production | Yes |
The SDK and Challenge.js auto-detect sandbox mode from the key prefix:
// SDK
const sdk = new ChallengeSDK({
apiKey: "sk_test_abc123...", // Sandbox mode activated automatically
gameId: "your-game-id",
});
// Challenge.js — same behavior
Challenge.init({
gameId: "your-game-id",
// apiKey detected from game config
});Test API keys are generated automatically when a game is registered. They can be regenerated at any time without approval.
What Changes in Sandbox
| Aspect | Sandbox | Production |
|---|---|---|
| Entry fee | Always $0 | Configured amount |
| Balance checks | Skipped | Enforced |
| Responsible gaming limits | Skipped | Enforced |
| Game approval check | Skipped | Required |
| Opponents | Bot accounts | Real players |
| Match data | Flagged is_test=true | Normal |
| Analytics | Excluded | Included |
| Webhooks | Payload includes test: true | Normal |
Bot Opponents
Each game gets two bot accounts, created automatically on first use. Bots are real accounts in the database with sandbox-flagged profiles.
Bot credentials:
| Field | Format |
|---|---|
bot1_<gameId>@challenge.test, bot2_<gameId>@challenge.test | |
| Password | Auto-generated 12-character string |
| Balance | 999,999 test tokens |
| Verification | sandbox (bypasses KYC) |
Retrieve bot credentials from the Developer Portal, or via API:
# Using your test API key (no auth token needed)
curl -H "X-API-Key: sk_test_your_key" \
https://api.withchallenge.com/api/sandbox/botsIn test mode, the widget login screen shows a prompt to use bot credentials. Bot balances and skill ratings reset automatically after each sandbox match.
Sandbox Endpoints
All sandbox endpoints require a test API key (sk_test_ prefix).
POST /api/sandbox/matches
Create a test match with a bot opponent.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
mode | string | No | "versus" (default) or "score" |
Response:
{
"message": "Test match created",
"match": {
"id": "match-uuid",
"player1_id": "bot1-uuid",
"player2_id": "bot2-uuid",
"status": "active",
"is_test": true
},
"bot": {
"profileId": "uuid",
"displayName": "Sandbox Bot 2"
}
}For score mode matches, the bot automatically submits a random score (100–1099) after a 2-second delay.
POST /api/sandbox/matches/:id/bot-score
Manually trigger a bot score submission instead of waiting for the auto-score.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
score | number | Yes | Score for the bot to submit |
The match auto-settles when both scores are submitted. Higher score wins; equal scores result in a draw.
GET /api/sandbox/activity
Get sandbox activity for the developer's games.
Response:
{
"matches": [ ... ],
"webhooks": [ ... ],
"apiLogs": [ ... ]
}Returns the 20 most recent test matches, 20 most recent test webhook deliveries, and 50 most recent API log entries.
POST /api/sandbox/trigger-webhook
Send a test webhook event to your registered webhook URL.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
gameId | string | Yes | Your game ID |
eventType | string | No | Event type (default: match.completed) |
payload | object | No | Custom payload data |
The webhook delivery includes test: true in the payload. Retry behavior (1m, 5m, 15m) is the same as production.
POST /api/sandbox/reset/:gameId
Delete all test data for a game. This removes test matches, transactions, revenue records, webhook deliveries, and API logs.
Response:
{
"deleted": {
"matches": 15,
"transactions": 30,
"revenue": 15,
"webhooks": 8
}
}Ownership is verified — you can only reset your own games.
Sandbox Guard
A middleware prevents sandbox bot accounts from accessing live endpoints. If a sandbox account tries to use a live API key or access live queue/wallet/match endpoints, the request is rejected:
{
"error": "Sandbox accounts cannot be used in live mode",
"code": "SANDBOX_ACCOUNT_BLOCKED"
}This is a hard boundary — test and live data never mix.
Testing Workflow
Testing Settlement by Score:
- Create a test match →
POST /api/sandbox/matches - Bot auto-submits a random score after 2 seconds
- Submit your player's score →
POST /api/matches/submit-score - Match auto-settles, result available via WebSocket or polling
Testing Settlement by Gameplay:
- Create a test match with
mode: "versus"→POST /api/sandbox/matches - Run your game logic
- Settle via SDK →
sdk.reportWinner()orsdk.reportDraw()
Webhook Testing:
- Register a webhook URL in the Developer Portal
- Trigger a test event →
POST /api/sandbox/trigger-webhook - Check delivery status in sandbox activity
Data Isolation
All sandbox data is flagged with is_test = true in the database. This flag exists on:
matchestransactionsplatform_revenuewebhook_deliveries
Analytics queries exclude test data automatically. The Developer Portal's analytics dashboard only shows production metrics.
Ready to integrate?
Add real-money competition to your game in minutes.
Challenge for Players
Create an account, deposit funds, and compete for real money in games you already play.
Challenge for Developers
Add Challenge to your game and start earning from every match. Integration takes minutes.