Challenge
Docs>Overview

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

  1. You add a script tag and call Challenge.init() in your game page
  2. You place Challenge's branded button — either inline next to your play buttons with Challenge.renderButton("#container"), or as a floating button (created automatically by Challenge.init()). The button includes the Challenge wave logo, gradient styling, and glow animation. You don't build this button yourself.
  3. A player clicks it → Challenge's modal opens and walks them through account creation, age verification, and depositing funds
  4. 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
  5. Your game gets a callback (onMatchStart) → you start the game
  6. 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:

DecisionOptions
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 using matchmaking mode, 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

TermDefinition
Entry feeThe amount each player stakes on a match ($2 or $5, configured per game at registration)
MatchA single contest between two players with money at stake
SettlementThe process of determining the winner and distributing funds
WidgetThe embeddable UI component (Challenge global object) that handles player onboarding, matchmaking UI, and result screens
SDKThe JavaScript library (ChallengeSDK class) that handles match creation and settlement via API calls
PairingA temporary link between two matched players before a match is created
Pairing IDUUID identifying a pairing, used during the transition from matchmaking to match creation
Match IDUUID identifying a match, used for score submission and settlement
API keyDeveloper credential (prefix sk_live_ for production, sk_test_ for sandbox) sent as X-API-Key header
SandboxTest environment using sk_test_ API keys, with bot opponents and no real money

Documentation Map

DocumentPurposeAudience
QuickstartAdd scripts, init widgetNew developers
Integration GuideFull integration guide (base + matchmaking + auto-settlement)Developers
Challenge.jsWidget configuration, methods, callbacksReference
SDKSDK methods, authentication, HMAC signingReference
APIComplete REST endpoint referenceReference
WebSocketsWebSocket connection, events, payloadsReference
SandboxTesting with sandbox keys and bot opponentsReference

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: apiBase is 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:

VariantText 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" to Challenge.init()
  • No -- use the SDK to create matches when your system pairs players
SettlementMatchmakingScripts needed
By scoreChallenge finds opponentsWidget only
By scoreYou find opponentsWidget + SDK
By gameplayChallenge finds opponentsWidget + SDK (server)
By gameplayYou find opponentsWidget + SDK

Reference Documentation

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.

PathWinner decided byMatchmakingServer code?ConfigYour code
AScore (highest wins)Challenge finds opponentsNomode: "score", matchmaking: "skill"gameEnded({ matchId, score })
BYour game logicChallenge finds opponentsNomode: "versus", matchmaking: "skill"showWin() / showLose()
CScore (highest wins)You find opponentsYesmode: "score"SDK createPairedMatch() + gameEnded()
DYour game logicYou find opponentsYes(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 variant option instead

Players 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:

VariantText 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 onMatchStart and gameplay. No "Connecting...", no custom lobby, no countdown. The widget handles all pre-game UI. When onMatchStart fires, 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

OutcomeConditionResult
WinYour score > opponent's scoreWinner receives prize pool minus platform fee
LossYour score < opponent's scoreEntry fee is lost
DrawScores are equalBoth 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 call Challenge.showWin() / Challenge.showLose() / Challenge.showDraw() on each client to display results.

MethodWhen to callWhat the widget shows
Challenge.settle({ matchId, winnerId })Game over, winner knownSettles match + shows result + rematch flow
Challenge.settle({ matchId })Game over, drawSettles match + shows draw + rematch flow
Challenge.showWin({ matchId, opponent, profit })Server already settled, show resultVictory screen with profit amount
Challenge.showLose({ matchId, opponent, loss })Server already settled, show resultLoss screen with amount lost
Challenge.showDraw({ matchId, opponent })Server already settled, show resultDraw 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 onMatchStart again 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 fire onMatchStart)
  • Any fetch() calls to queue endpoints

Skill vs FIFO

ModeQueue typeHow players are matchedSeeding required?
"skill"ELO-basedPlayers matched by similar rating. Range expands over time.Yes
"fifo"First-come-first-servedFirst 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 (crypto module). 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/onOpponentScore streams 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

MethodAuthDescription
new ChallengeSDK({ apiKey, gameId, apiBase?, hmacSecret? })--Create SDK instance
sdk.init()API keyVerify credentials. Returns true or false.
sdk.createPairedMatch(p1Id, p2Id, ref?, { mode? })API keyCreate match, escrow funds. Default mode: "versus".
sdk.reportWinner(winnerId, gameData?)API keySettle match, pay winner. Clears matchId.
sdk.reportDraw(gameData?)API keySettle as draw, refund both. Clears matchId.
sdk.submitScore(matchId, userId, score, opts?)User tokenSubmit 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

PrefixEnvironmentReal money
sk_live_ProductionYes
sk_test_SandboxNo -- 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 after init().
  • 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

OptionTypeDefaultDescription
gameIdstringrequiredYour game's UUID, from the Developer Portal
apiBasestringAuto-detected from script URLChallenge API base URL. Only set for local development.
entryFeenumber2Entry fee per player in USD. Set per game at registration. Allowed values: 2 or 5.
variantstring"default"Button text: "default", "1v1", or "play". See Button.
sizestring"md"Button size: "sm", "md", or "lg". See Button.
showButtonbooleantrueSet false to disable the floating button (use when placing inline with renderButton()).
positionstring"bottom-right"Floating button position: "bottom-right", "bottom-left", "top-right", "top-left".
themestring"default""default" (dark background) or "dark" (light background)
matchmakingstring | falsefalse"skill" (ELO-based), "fifo" (first-come-first-served), or false (your game handles matchmaking). See Integration Guide.
modestring"versus""versus" (your game determines the winner) or "score" (platform compares scores automatically). See Integration Guide.
challengeDomainstringnullChallenge platform domain for SSO (e.g., "https://challenge.io")
ssoEnabledbooleantrueEnable cross-domain single sign-on. Requires challengeDomain.
ssoTimeoutnumber3000Timeout in ms for silent SSO check
dashboardUrlstringnullURL to the player dashboard (shown in account menu)

Callbacks

OptionSignatureTrigger
onReady(data) => voidPlayer is authenticated, funded, and ready to play
onMatchStart(match) => voidMatch is ready to start (when matchmaking is set). Fires for initial matches and rematches.
onCancel(data) => voidPlayer cancelled matchmaking
onClose() => voidModal was closed
onError(error) => voidAn error occurred
onOpponentScore(data) => voidOpponent 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 object

onOpponentScore

{ 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:

  1. Submits the score to the backend
  2. Shows "waiting for opponent" if the opponent hasn't submitted
  3. Shows the result screen when both scores are in
  4. Handles rematch flow

Warning: gameEnded() only works for matches created with mode: "score". For versus mode, use Challenge.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:

  1. Sends the settlement to the backend (authenticated by your API key)
  2. Shows the win, loss, or draw result screen
  3. 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 after init().
  • 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

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

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

SizeUse 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() then renderButton() 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, });
PositionLocation
"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):

  1. Auth — login or start registration
  2. Email — enter email address
  3. Verify Email — enter OTP code sent to email
  4. Password — create password
  5. Birthday — date of birth (age verification)
  6. Name — first and last name
  7. Profile — username selection
  8. Location — country and state (checks for restricted states)
  9. Deposit — add funds via credit card or bank account
  10. Deposit Success — confirmation screen after successful deposit
  11. Verify — identity verification via Veriff
  12. Wallet — ensure sufficient balance for entry fee
  13. 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() or gameEnded())
  • 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)

EventWhen
CHALLENGE_READYChallenge.js initialized
CHALLENGE_AUTHENTICATEDPlayer authenticated
CHALLENGE_PLAYER_READYPlayer is ready to play (same as onReady)
CHALLENGE_CLOSEDModal closed
CHALLENGE_ERRORError occurred
CHALLENGE_DEPOSIT_COMPLETEDeposit succeeded
CHALLENGE_MATCH_READYMatch found (lobby mode)
CHALLENGE_GAME_STARTMatch countdown finished, game should start

Events Received (Parent Window → Challenge.js)

EventWhen to send
CHALLENGE_MATCH_FOUNDGame-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 localStorage under keys prefixed with challenge_
  • 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:

KeyContents
challenge_tokenJWT access token
challenge_refresh_tokenRefresh token
challenge_token_expiryToken expiry timestamp
challenge_userUser 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 });
ParameterTypeRequiredDescription
apiKeystringYesYour API key from the Developer Portal
gameIdstringYesYour game's UUID from the Developer Portal
apiBasestringNoChallenge API base URL
hmacSecretstringNoSecret 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/verify with the API key and game ID
  • Returns true if valid, false if invalid
  • Does not throw — catches errors internally and logs to console
  • Sets sdk.isActive = true on 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" );
ParameterTypeRequiredDescription
player1IdstringYesChallenge user ID of player 1
player2IdstringYesChallenge user ID of player 2
gameMatchIdstringNoYour internal match reference (stored with the match)
options.modestringNo"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 createPairedMatch once 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, });
ParameterTypeRequiredDescription
winnerIdstringYesChallenge user ID of the winner
gameDataobjectNoGame-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/settle with the API key
  • Clears sdk.matchId after settlement
  • Throws if no active match (sdk.matchId is 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, });
ParameterTypeRequiredDescription
gameDataobjectNoGame-specific metadata, stored with the match record. Cannot exceed 10KB.
  • Calls POST /api/matches/settle with isDraw: true
  • Clears sdk.matchId after settlement
  • Throws if no active match

Warning: reportWinner and reportDraw are for mode: "versus" only. For mode: "score", use Challenge.gameEnded() on the client or sdk.submitScore() from your server. Calling reportWinner on 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:

MethodRuns onAuthIntegrity
Challenge.gameEnded()Browser (Challenge.js)Player's JWT tokenClient-side — player could tamper
sdk.submitScore()Server (Node.js)Player's JWT + optional HMACServer-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 });
ParameterTypeRequiredDescription
matchIdstringYesThe match ID
userIdstringYesThe player's Challenge user ID
scorenumberYesThe player's final score
options.noncestringNoArbitrary string for request uniqueness (defaults to "")
options.gameDataobjectNoGame-specific metadata
options.userTokenstringNoPlayer's JWT token (sent as Authorization: Bearer)
options.hmacSecretstringNoOverride 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-encoded

The 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 signature

Note: HMAC signing requires Node.js (crypto module). In the browser, the SDK throws "HMAC signing requires Node.js crypto module". For browser-side score submission, use Challenge.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:

OperationHeaderValue
init, createPairedMatch, reportWinner, reportDrawX-API-KeyYour API key
submitScoreAuthorizationBearer <player-jwt-token>

API Key Format

PrefixEnvironmentReal money
sk_live_ProductionYes
sk_test_SandboxNo — uses bot opponents and test funds

The SDK auto-detects sandbox mode from the key prefix and logs a message on init.

Error Handling

MethodOn 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." — called createPairedMatch or submitScore before init()
  • "No active match" — called reportWinner or reportDraw with no matchId

Method Reference

MethodAuthEndpointReturns
init()API keyPOST /api/games/verifyboolean
createPairedMatch(p1, p2, ref?, opts?)API keyPOST /api/matches/create-pairedMatch object
reportWinner(winnerId, gameData?)API keyPOST /api/matches/settleSettlement result
reportDraw(gameData?)API keyPOST /api/matches/settleSettlement result
submitScore(matchId, userId, score, opts?)Bearer tokenPOST /api/matches/submit-scoreSubmission 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:

MethodHeaderUsed by
Player tokenAuthorization: Bearer <jwt>Queue, score submission, rematch
API keyX-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:

FieldTypeRequiredDescription
gameIdstringYesYour 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:

FieldTypeRequiredDescription
gameIdstringYesYour game ID
modestringNo"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:

FieldTypeRequiredDescription
gameIdstringYesYour 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing ID from the match response
matchIdstringYesMatch ID from sdk.createPairedMatch()

POST /api/queue/lobby/clear

Clear the pairing after a match starts or when leaving.

Auth: Bearer token

Request:

FieldTypeRequiredDescription
pairingIdstringNoSpecific pairing to clear
gameIdstringNoClear 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing ID from the match response
modestringNoMatch 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing 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:

FieldTypeRequiredDescription
gameIdstringYesYour game ID
skillDataobjectNoAdditional skill context
modestringNoMatch 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:

FieldTypeRequiredDescription
gameIdstringYesYour 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing ID
matchIdstringYesMatch 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:

FieldTypeRequiredDescription
pairingIdstringYesPairing 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:

FieldTypeRequiredDescription
pairingIdstringNoSpecific pairing to clear
gameIdstringNoClear 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:

FieldTypeRequiredDescription
gameIdstringNoYour game ID (falls back to SDK's configured game ID)
player1IdstringYesChallenge user ID of player 1
player2IdstringYesChallenge user ID of player 2
gameMatchIdstringNoYour internal match reference
modestringNo"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):

FieldTypeRequiredDescription
matchIdstringYesMatch ID
winnerIdstringYesChallenge user ID of the winner
gameDataobjectNoGame-specific metadata

Request (draw):

FieldTypeRequiredDescription
matchIdstringYesMatch ID
isDrawbooleanYesMust be true
gameDataobjectNoGame-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:

FieldTypeRequiredDescription
matchIdstringYesMatch ID
scorenumberYesThe player's final score
gameDataobjectNoGame-specific metadata
noncestringNoFor HMAC-signed requests
signaturestringNoHMAC-SHA256 signature
timestampnumberNoUnix 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:

ParamTypeDescription
gameIdstringFilter 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:

FieldTypeRequiredDescription
matchIdstringYesThe 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:

FieldTypeRequiredDescription
matchIdstringYesThe completed match ID
isAddingbooleanYestrue 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:

FieldTypeRequiredDescription
matchIdstringYesThe completed match ID
acceptbooleanYestrue to accept, false to decline

POST /api/matches/rematch/cancel

Cancel a pending rematch request.

Auth: Bearer token

Request:

FieldTypeRequiredDescription
matchIdstringYesThe 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:

FieldTypeRequiredDescription
playerIdstringYesChallenge user ID
gameIdstringYesYour game ID
historicalStats.totalGamesnumberYesTotal games played
historicalStats.winsnumberYesTotal wins
historicalStats.avgScorenumberNoAverage score
historicalStats.bestScorenumberNoBest 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.

EndpointDescription
POST /api/sandbox/matchesCreate a test match with a bot opponent
POST /api/sandbox/matches/:id/bot-scoreTrigger bot to submit a score
GET /api/sandbox/activityGet sandbox activity log
POST /api/sandbox/trigger-webhookSend a test webhook event
POST /api/sandbox/reset/:gameIdDelete 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.

ChannelFormatWho can subscribePurpose
user:<userId>Auto-subscribed on connectThe user themselvesPersonal notifications
match:<matchId>Subscribe manuallyPlayers in that matchMatch-specific real-time data
admin:allAuto-subscribed for adminsAdmin users onlyPlatform alerts
developer:<developerId>Auto-subscribed for developersThe developer themselvesDeveloper 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)

EventTriggerData
fraud.alert_createdSuspicious activity detecteduserId, type, severity
dispute.createdPlayer filed a disputedisputeId, matchId
withdrawal.createdWithdrawal requestedwithdrawalId, userId, amount
withdrawal.status_changedAdmin approved/rejected withdrawalwithdrawalId, status
game.review_completedAI game review finishedgameId, decision, score

Developer Events (channel: developer:<developerId>)

EventTriggerData
game.validation_changedGame approval status updatedgameId, status
webhook.delivery_updatedWebhook delivereddeliveryId, status

Message Format

All server messages follow this format:

{ "type": "event.name", "channel": "channel:id", "data": { ... }, "ts": 1234567890 }
FieldTypeDescription
typestringEvent name, dot-separated
channelstringChannel the event was sent on
dataobjectEvent-specific payload
tsnumberUnix 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:

ServiceEvents emitted
settlementService.tsscore.submitted, match.settled
skillMatchmaking.tsmatch.found
matches.tsrematch.status_changed, opponent.left
wallet.tswithdrawal.created, balance.updated
integrations/stripe.tsbalance.updated
integrations/veriff.tsverification.completed
gameValidation.tsgame.validation_changed
gameReviewAgent.tsgame.review_completed
fraudDetection.tsfraud.alert_created
disputes.tsdispute.created
webhookDelivery.tswebhook.delivery_updated
routes/admin.tswithdrawal.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 apiKey with an sk_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)

  1. Open Tab 1 → your game page
  2. Open Tab 2 → your game page
  3. Tab 1: click the Challenge button, log in as Bot 1 (email + password from Sandbox page)
  4. Tab 2: click the Challenge button, log in as Bot 2
  5. Both tabs: click "Play Now" → both join the queue → matched → game starts
  6. 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 PrefixModeReal Money
sk_test_SandboxNo
sk_live_ProductionYes

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

AspectSandboxProduction
Entry feeAlways $0Configured amount
Balance checksSkippedEnforced
Responsible gaming limitsSkippedEnforced
Game approval checkSkippedRequired
OpponentsBot accountsReal players
Match dataFlagged is_test=trueNormal
AnalyticsExcludedIncluded
WebhooksPayload includes test: trueNormal

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:

FieldFormat
Emailbot1_<gameId>@challenge.test, bot2_<gameId>@challenge.test
PasswordAuto-generated 12-character string
Balance999,999 test tokens
Verificationsandbox (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/bots

In 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:

FieldTypeRequiredDescription
modestringNo"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:

FieldTypeRequiredDescription
scorenumberYesScore 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:

FieldTypeRequiredDescription
gameIdstringYesYour game ID
eventTypestringNoEvent type (default: match.completed)
payloadobjectNoCustom 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:

  1. Create a test match → POST /api/sandbox/matches
  2. Bot auto-submits a random score after 2 seconds
  3. Submit your player's score → POST /api/matches/submit-score
  4. Match auto-settles, result available via WebSocket or polling

Testing Settlement by Gameplay:

  1. Create a test match with mode: "versus"POST /api/sandbox/matches
  2. Run your game logic
  3. Settle via SDK → sdk.reportWinner() or sdk.reportDraw()

Webhook Testing:

  1. Register a webhook URL in the Developer Portal
  2. Trigger a test event → POST /api/sandbox/trigger-webhook
  3. Check delivery status in sandbox activity

Data Isolation

All sandbox data is flagged with is_test = true in the database. This flag exists on:

  • matches
  • transactions
  • platform_revenue
  • webhook_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.