Challenge
Docs>For AI agents>Integrate Challenge in one pass

Integrate Challenge in one pass

The whole integration in one page, written to be executed rather than browsed. Follow it top to bottom and you need no other page.

This page is self-contained. Every value, file path, request and response you need is inline. The links at the end are depth, not prerequisites.

Preflight

You cannot start without four things, plus one more that depends on which path the developer wants. Two of the four are values only the developer has, and neither can be derived, guessed or looked up.

WhatFormWhere it goes
PROGRAM_IDAn opaque string from the Developer PortalThe browser. It is public
CHALLENGE_API_KEYsk_test_... in sandbox, sk_live_... in productionA server environment variable, and nowhere else, ever
Metric nameThe game's own word for what it measures, for example moves, solve time, combo length. 40 characters or fewerBoth halves of the integration
Directionhigher_is_better or lower_is_betterThe Challenge, in the portal

If you do not have these, STOP and ask the user for them. Do not invent placeholder values. Do not proceed with your-program-id, a plausible-looking UUID, or an sk_test_ string you made up. A program ID that is not the developer's mounts an island that offers nothing, and a fabricated key answers 401 on every request. Both failures look like a broken integration rather than a missing value, which is why guessing costs more than asking.

The program ID is on the developer dashboard in the portal, in the integration snippet, one per game. The key is on the Games page, per game, behind a reveal control.

GET /v1/challenges does not return the direction on a calibrating Challenge, so it cannot be read back from the API before a bar exists. It comes from the developer or from the Challenge's row in the portal, and it appears on the wire as thresholdDirection only once an attempt has started.

Then one more thing, depending on the path

There are two ways to reach a settled attempt, and they need different things. Ask the developer which one they want before you start. The choice is theirs, not yours: one of them spends real money.

PathWhat it additionally needs
Paid path. Take a Challenge live and run a real charged attemptFive percentiles of real player scores, p5, p25, p50, p75 and p95, ascending, plus a sample_size of at least 500. Seeding is one way, cannot be undone, and takes that Challenge live for real players, so it needs the developer's explicit go-ahead first
Money-free loop. Settle a free attempt on a calibrating ChallengeAn email address that already has a Challenge player account. POST /v1/players links an existing account and never creates one, so an unregistered email answers 404 not_found. The developer signs up once as a player at app.withchallenge.com

Neither value can be guessed. If the developer has neither, the integration still ships: steps 1 and 2 are safe to land on their own, and the paid loop is turned on later.

Set the key where the server will read it:

# The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_...

Throughout this page, <<YOUR_PROGRAM_ID>> and <<YOUR_CHALLENGE_ID>> are placeholders. Replace them with the real values. The key is never written literally anywhere: it is read from the environment.

What you are building

Challenge sells a Challenge: a player pays a fee to attempt a fixed standard in the game, and Challenge pays a fixed prize when they beat it. Challenge funds the prize, announces it before the player pays, and never pools one player's fee into another player's winnings.

Your half is small and it is entirely this:

  1. Two script tags and one Challenge.init({ program }) call.
  2. One Challenge.record(metric, score) line at the end of every round.
  3. One await Challenge.startAttempt() at the start of a paid round.
  4. One POST from your server reporting the measured score.

Challenge's half is everything else: accounts, sign-in, age and identity checks, deposits, balances, the fee confirmation, the prize, the verdict, the result screen, refunds, and the ledger. You render none of it.

Two rules decide the shape of everything below.

  • The key never reaches the browser. A key can settle an attempt, and settling an attempt pays a prize.
  • You report a measurement, never a verdict. The outcome endpoint accepts score. It has no won field. Challenge applies the bar it froze onto the attempt when it charged the fee.

Step 1. Add Challenge.js and initialise it

Put both tags in the page that runs the game, in this order.

<script src="https://api.withchallenge.com/widget/dist/island-stub.js"></script> <script async src="https://api.withchallenge.com/widget/dist/island.js"></script>

The first is a small synchronous stub that paints the island immediately. The second loads the rest of the SDK asynchronously and takes over. Calls made before it arrives are queued, so nothing is lost.

There is no npm package, for the browser or for the server. Script tags are the whole distribution. Do not add a bundler dependency, and do not import anything from Challenge.

Then, once, wherever the game boots:

Challenge.init({ program: '<<YOUR_PROGRAM_ID>>' })

program is the only required option. The API base is detected from the script origin, so nothing else is configured in production.

Until the program has a live Challenge, the island still draws: a small dormant pill reading Coming soon, in the anchor corner, visible to every player on the page. It offers nothing and charges nothing, but it is not invisible. Decide deliberately whether to ship it before a live Challenge exists, or hold the two script tags until there is one.

The options worth knowing:

OptionTypeDefaultWhat it does
programstringrequiredThe program ID
anchorstring'bottom-right'bottom-right, bottom-left, bottom-center, top-right, top-left, top-center, or a CSS selector for an inline mount
size'regular' or 'compact''regular'How large the pill draws
theme'light' or 'dark''light'The palette for every island surface
draggablebooleanfalseLets the player move the island and remembers where
challengestringnullPin the island to one Challenge id. The key is literally challenge, not challenge
apiBasestringscript originLocal development only

Local and self-hosted sandboxes

If the sandbox is a Challenge backend running somewhere other than api.withchallenge.com, a local one on port 4837 for instance, then both script tags load from that origin and apiBase names it too:

<script src="http://localhost:4837/widget/dist/island-stub.js"></script> <script async src="http://localhost:4837/widget/dist/island.js"></script>
Challenge.init({ program: '<<YOUR_PROGRAM_ID>>', apiBase: 'http://localhost:4837', })

Change both together. A page that loads the bundle from one origin and talks to another is the one configuration the island cannot detect its way out of. Swap https://api.withchallenge.com for that same base in every curl on this page.

The same backend also serves these docs: every page is at {your API base}/api/docs/<slug>.md, for example http://localhost:4837/api/docs/integrate.md. The agent index sits beside them under the same /api/docs path, named llms.txt.

In a single-page app, call init() on every route change: it is safe, the element and the player's armed session are reused, and a round in flight is kept — a route change mid-round keeps the live pill live and its score still settles, so you never guard init() against a round in progress. init() never calls destroy(). Call Challenge.destroy() only when the game leaves the page for good.

import { useEffect } from 'react' export function ChallengeProvider({ programId, children }) { useEffect(() => { // Safe on every route change. The element and the armed session are reused. window.Challenge.init({ program: programId }) return () => { // Only on real unmount. destroy() also forgets the player's armed session. window.Challenge.destroy() } }, [programId]) return children }

Step 2. Record every round

One line at the end of every round the game already has. Paid or free, signed in or not, practice included.

Challenge.record('moves', 34)

Use the developer's actual metric name and their actual number, not the example's. The string must match the metric_name on the Challenge exactly, because it is the key the sample is stored under. Names are trimmed, lowercased and internally whitespace-collapsed, so 'Moves' and 'moves' are one metric.

A mismatched metric name is a silent failure, and it is the easiest way to lose a day here. Nothing rejects it. record() still resolves { recorded: true }, the server still answers 202, the network tab still looks correct, and the samples pile up under a metric no Challenge is reading. Nothing anywhere surfaces the discrepancy.

The only defence is the check: compare the string you pass to record() against the metric_name field returned by GET /v1/challenges?status=all, character for character, after trimming and lowercasing both. Do that before you believe any recorded round counted. Step 3 is where you read that field.

In place:

async function playRound() { const result = await runTheRound() // Every round. Not awaited, not branched on, not conditional on a Challenge // existing or a player being signed in. Challenge.record('moves', result.moves) showRoundSummary(result) }

record() never rejects and never blocks a round. It resolves { recorded: true } or { recorded: false }, throttles to one request per second per metric without dropping rounds, queues anything reported before init() has finished, and drops with a console.warn only past 50 queued rounds on one metric. It charges nothing and renders nothing.

What it does and does not feed. Recorded rounds build a listening sample for a metric. When a Challenge is later created on a metric that already has 5,000 recorded rounds from at least 200 distinct player refs, its bar is computed and the Challenge goes live in the same request.

Recorded rounds never move an existing Challenge's calibration counter. That counter is adjudicated attempts only, and it is reported as calibration.collected. Recorded rounds are also never merged into a later recalibration and never feed the drift monitor. They place a bar once, at Challenge creation, and only there. Any code or copy that treats record() as a way to advance a calibrating Challenge is wrong.

Keep the line in forever regardless. It is what prices every future Challenge on that metric.

Step 3. Get a Challenge with a bar

Do not assume one exists. List them first, and read the answer:

# The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/challenges?player_ref=integration-check&status=all" \ -H "X-API-Key: $CHALLENGE_API_KEY"

status=all is the partner view. Without it the answer is live Challenges only, and a new Challenge is calibrating.

If data comes back empty, STOP and ask the developer to create a Challenge in the Developer Portal. There is no POST /v1/challenges. A Challenge cannot be created from the API on any key, so there is nothing to retry and nothing to work around. On the Challenges page, Add challenge asks for a name, the metric name, the metric unit and the direction. It does not ask for a fee or a prize, because Challenge prices every Challenge. A bar the developer chose would set the pass rate, which would set what Challenge expects to pay.

From a non-empty answer, take two fields off the row:

  • id. That is <<YOUR_CHALLENGE_ID>> below.
  • metric_name. Record under exactly that string. Copy it out of the response and pass that value to Challenge.record() in step 2. Do not retype it from the developer's description of it, and do not use the example's 'moves'.

A calibrating Challenge has no bar, no fee and no prize, and the island will not offer it. It leaves calibration after 1,000 adjudicated attempts from at least 200 distinct players. You are not filling that by hand today.

The fastest path to a live Challenge is to seed the distribution. If the developer already knows how their players score, hand Challenge five percentiles and the Challenge goes live in the same request:

# The key lives on your server. It never reaches the browser. curl -s -X POST \ "https://api.withchallenge.com/v1/challenges/<<YOUR_CHALLENGE_ID>>/distribution" \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4a7d2c10-6e83-4f19-9b52-c0d81e37a664" \ -d '{ "distribution": { "p5": 22, "p25": 30, "p50": 38, "p75": 47, "p95": 61 }, "sample_size": 5000 }'
{ "challenge_id": "...", "threshold": 57.5, "target_pass_rate": 0.1, "sample_size": 5000, "calibration_source": "partner_seeded", "provisional": true, "note": "Live now, but capped at 20% of your tier ceiling until observed attempts confirm this distribution. The cap lifts automatically." }

The rules it holds to: the percentiles must ascend from p5 to p95, sample_size must be a whole number of at least 500, the Challenge must be calibrating, and a rung of a ladder cannot be seeded at all. Seeding is one way. It cannot be undone by seeding again, and it takes that Challenge live for real players, so seed the Challenge you meant to seed with numbers the developer stands behind.

A bar placed this way is provisional: the prize is capped at 20% of the program's integrity tier ceiling until observed attempts confirm the claim. The cap lifts on its own.

If the developer has no distribution to hand, the money-free loop runs through the server door instead, and the island is not involved at all. Two calls, on a calibrating Challenge:

# The key lives on your server. It never reaches the browser. curl -s -X POST https://api.withchallenge.com/v1/players \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "player_ref": "sandbox-player-1", "email": "player@example.com" }' curl -s -X POST https://api.withchallenge.com/v1/attempts \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 2f6f1a0c-9d5b-4a2e-8c31-7b0f4d9a1e55" \ -d '{ "challenge_id": "<<YOUR_CHALLENGE_ID>>", "player_ref": "sandbox-player-1" }'

The email must belong to a Challenge account that already exists: POST /v1/players links, it never creates. Have the developer sign up once as a player at app.withchallenge.com. A calibrating attempt is free by database constraint, settles met_threshold: false with threshold_applied: null, and exercises the outcome call, the idempotency key and the settlement webhook with no money anywhere near it.

Sandbox is a key, not a copy. sk_test_ and sk_live_ are two values on one program record. They resolve the same program, the same Challenges, the same linked players and the same attempts. There is no separate sandbox data set and nothing gets reset. What differs is that a live key requires an approved program and a test key does not, and that no Challenge is priced yet, so fee_charged and prize_offered settle as 0.00 and award comes back null even on a met threshold.

Step 4. Start the paid attempt in the browser

The player arms first, and your code is not in that step. They tap the island, see the fee, the prize and the bar they would have to beat, and confirm. That confirmation is arming. Sign-up, the age check and a deposit, if one is needed, all happen inside the same flow on Challenge's surface.

There is no API that arms on a player's behalf, on any key, and there will not be one. Do not look for one.

Once a player is armed, start the paid round:

// The absolute URL of your own round-finished handler. A statically served // game cannot reach a server on another origin with a relative path, so name // the origin here. Replace it with the deployed URL before you ship: // Express template: http://localhost:4310/round-finished // Supabase template: https://<project-ref>.supabase.co/functions/v1/round-finished // Only if your own server serves this page too, use '/round-finished'. const ROUND_FINISHED_URL = 'http://localhost:4310/round-finished' async function playPaidRound() { let attempt try { attempt = await Challenge.startAttempt() } catch (error) { handleStartFailure(error) return } // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } const moves = await runTheRound({ target: attempt.threshold, direction: attempt.thresholdDirection, }) // Send the server whatever it needs to derive the score. Not the score. await fetch(ROUND_FINISHED_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attemptId: attempt.attemptId, moves }), }) }

startAttempt() charges the fee, copies the bar, its direction and the prize onto the attempt, gives the attempt a one-hour expiry, and resolves only once the money has moved.

FieldTypeMeaning
attemptIdstringThe id the server reports the outcome against
feeChargednumberWhat this player actually paid, frozen
prizeOfferednumberWhat this attempt pays on a win, frozen. 0 on a calibrating Challenge
thresholdnumber or nullThe bar this attempt is judged against
thresholdDirectionstring or null'higher_is_better' or 'lower_is_better'

These five are this attempt's terms. If the game shows a target during the round, read attempt.threshold and attempt.thresholdDirection and nothing else. Never read a threshold from a Challenge list fetched earlier: the two can differ, and only the frozen pair decides the outcome.

Branch on error.code, never on the message. The island has already told the player whatever they need to know, so most branches are a log line and a return:

function handleStartFailure(error) { switch (error.code) { case 'not_armed': // The player has not agreed to a Challenge. Run the round for free. playFreeRound() return case 'insufficient_funds': // Nothing was charged. The island is already offering a deposit. return case 'blocked': // A responsible gaming limit the player set for themselves. Never work // around this and never offer an alternative paid path. return case 'challenge_not_available': // Suspended, closed, or the island and the server disagree about what is // armed. The island resyncs itself. Offer a free round instead. playFreeRound() return case 'attempt_in_progress': console.warn('Challenge: an attempt is already running') return case 'not_initialised': console.error('Challenge: init() must run before startAttempt()') return case 'unauthorized': // The player's session expired. The island puts a sign-in in front of them. return case 'network': // The request never reached Challenge. Nothing was charged. return default: // 'unknown', and any newer code the server adds. Always keep this branch. console.warn('Challenge: attempt not started:', error.code) playFreeRound() } }

Step 5. Report the outcome from your server

One HTTPS request, from a server holding the key.

POST https://api.withchallenge.com/v1/attempts/{attempt_id}/outcome
HeaderValue
X-API-Keysk_test_... in sandbox, sk_live_... in production. Server side only, always
Idempotency-KeyRequired. Use the attempt id
Content-Typeapplication/json
{ "score": 34 }

An attempt has exactly one outcome, forever, so the attempt id is the natural idempotency key and there is nothing to generate or store. That is what makes a blind retry the correct response to a timeout.

Where the handler runs decides two details below. If the game is served statically, by npx serve, a CDN or any host that only returns files, then the handler runs on its own origin and a different port. The browser must post to its absolute URL, and the handler must answer CORS preflight, which is why both templates carry it. If the game's own server hosts the handler on the same origin, the relative path /round-finished works and the CORS middleware is unnecessary: delete it.

Pick one of the two templates below. Both are complete and runnable as written.

Supabase edge function

The fastest server is one you do not run. Create supabase/functions/round-finished/index.ts with exactly this content:

// The key lives on your server. It never reaches the browser. const CHALLENGE_API = Deno.env.get('CHALLENGE_API_BASE') ?? 'https://api.withchallenge.com' // Your game's origin. A browser calls this function directly, so it needs one. // Replace '*' with your own origin before you go live. const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, content-type', 'Content-Type': 'application/json', } Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') return new Response('ok', { headers: CORS }) const { attemptId, moves } = await req.json() if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) { return new Response(JSON.stringify({ error: 'invalid_request' }), { status: 400, headers: CORS, }) } // Derive the score from what only this server knows: the seed you dealt, the // session it belongs to, the player who is signed in. Never take a verdict. const score = moves const settled = await fetch( `${CHALLENGE_API}/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': Deno.env.get('CHALLENGE_API_KEY')!, // An attempt has exactly one outcome, so the attempt id is the key. 'Idempotency-Key': attemptId, }, body: JSON.stringify({ score }), } ) // Challenge applies the bar it froze onto the attempt when it took the fee. // Pass its answer through, or keep it: the island in the player's browser // hears the settlement on its own either way. return new Response(await settled.text(), { status: settled.status, headers: CORS }) })

Deploy it:

# The key lives on your server. It never reaches the browser. supabase secrets set CHALLENGE_API_KEY=sk_test_... supabase functions deploy round-finished

The deployed URL is https://<project-ref>.supabase.co/functions/v1/round-finished. That is the value of ROUND_FINISHED_URL in step 4, unless you are proxying the function from your own origin.

Express

If the developer already runs a backend, create server.js with exactly this content:

// The key lives on your server. It never reaches the browser. const express = require('express') const CHALLENGE_API = process.env.CHALLENGE_API_BASE || 'https://api.withchallenge.com' // 4310 stays clear of the ports a game's own dev server takes: 3000 for npx // serve and Next, 5173 for Vite, 8080 for a static server. const PORT = Number(process.env.PORT || 4310) // The origin the game is served from. A statically served game calls this // handler cross-origin, so it needs CORS. Set GAME_ORIGIN to that exact origin // before you go live. The '*' default is for local development only. const GAME_ORIGIN = process.env.GAME_ORIGIN || '*' const app = express() app.use(express.json()) // Drop this middleware if your own server serves the game on this same origin. app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', GAME_ORIGIN) res.setHeader('Access-Control-Allow-Headers', 'content-type') res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') if (req.method === 'OPTIONS') return res.sendStatus(204) next() }) app.post('/round-finished', async (req, res) => { const { attemptId, moves } = req.body ?? {} // Validate the round before it is worth anything. This is where a real game // checks it against what it knows: the seed it dealt, the session it belongs // to, the player it belongs to. A move count is a count. if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) { return res.status(400).json({ error: 'invalid_request' }) } // THE SCORE IS DERIVED HERE, never taken from the client as a verdict. const score = moves const settled = await fetch( `${CHALLENGE_API}/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.CHALLENGE_API_KEY, // One outcome per attempt, so the attempt id is the natural key. A // retry replays the original answer instead of settling twice. 'Idempotency-Key': attemptId, }, body: JSON.stringify({ score }), } ) return res.status(settled.status).json(await settled.json()) }) app.listen(PORT, () => console.log(`Partner server on http://localhost:${PORT}`))

Run it with the key in the environment and nowhere else:

# The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_... export GAME_ORIGIN=http://localhost:3000 # where the game itself is served node server.js

The handler is then at http://localhost:4310/round-finished. That absolute URL is what the browser in step 4 posts to.

The response

200, with the settlement itself:

{ "attempt_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "score": 34, "met_threshold": true, "threshold_applied": 36, "recorded_at": "2026-08-24T12:00:31.000Z", "receipt_id": null, "award": { "id": "b1d8e3a0-5c72-4f19-9e3b-0a7c4d2f8e11", "amount": "9.00", "paid_at": "2026-08-24T12:00:31.000Z" } }

Money in /v1 is always a decimal string, never a float. In sandbox the fee and the prize are 0.00 and award is null even when met_threshold is true, because Challenge has not priced the Challenge yet.

Replay semantics, which is what makes retrying safe:

SituationAnswer
Same key, same body, first call completedThe original result, with Idempotency-Replayed: true. Semantically identical, though JSON key order may differ, so never assert on the raw body
Same key, same body, first call still in flight409 idempotency_in_progress. Retry shortly
Same key, different body409 idempotency_key_reused. Use a new key for a new operation
No Idempotency-Key header400 invalid_request
A different key on an attempt that already settled409 attempt_already_settled. Treat as success

Records are retained for 24 hours. Only 2xx responses are stored, so a failed call is simply retried under the same key. Retry on a timeout or a 5xx under the same key. That is what the key is for.

There is no step 6. Nothing is relayed back to the game and the game is never asked to display a verdict. The island hears the settlement on its own, over a WebSocket on the attempt:{attemptId} channel raced against a three-second poll, and renders the win or the miss itself.

If nothing is ever reported, the attempt expires one hour after creation, the fee is refunded in full, and the island tells the player.

Before this handles real money

The template above is the integration. Challenge bounds what a cheat can cost but does not yet catch a fabricated score for you, so the two checks below are yours and neither is optional. What Challenge guarantees is a ceiling: the tier caps every prize (100 dollars at the entry tier), a player wins a given Challenge only once, a young bar pays half prize for its first 200 attempts, and withdrawals hold for review. Those bound a single cheat; they do not stop one.

You do not need your own player login. Challenge owns the player. The player authenticated to Challenge through the island, not to your game, and the attemptId belongs to that player: it is an opaque value only they ever hold. Do NOT gate reporting behind a game session or a per-player "does this caller own this attempt" check. Requiring a player to sign in to your game to settle defeats the point of Challenge, and it is not what keeps a report honest anyway. Two things you already control do that. First, only your SERVER holds the secret key, so only your server can report an outcome. Keep sk_... server-side, never in the browser; a client cannot forge a report. Second, the score you report is one you can stand behind — the next two checks. (The attack this paragraph used to chase — one player posting a round for another — gains the attacker nothing: the prize accrues to the attempt's owner, not the caller, and the caller would need the victim's opaque attempt id, which is never exposed. The score you actually need to defend against is a fabricated one on the caller's OWN attempt, and that is checks two and three, not a login.)

Always: bound the number. Reject a score your game cannot produce: above the maximum a round can reach, below zero, from a round shorter than a round can be. A client that can post { "score": 1 } on a lower-is-better Challenge has already won. One line of arithmetic, and it is what turns an unbounded fabricated score into at worst a plausible one.

As prizes grow: derive rather than receive. The two checks above are the floor. Challenge's caps make a single cheat survivable at the entry tier but do not detect one, so as the prize on the line grows, move toward a number your server derived: the round length you observed, the moves you accepted, and, if your engine is deterministic, re-deriving the score from a seed you dealt. At tier A this leaves your hands entirely: those programs settle only on independently verified sources, never on partner reports.

Log the attempt_id, the Idempotency-Key, the HTTP status and the receipt_id, on success and on failure. Never log the key.

Optional: webhooks

Register an endpoint in the Developer Portal if the developer's own systems need to hear about settlement without polling. Events: attempt.settled, attempt.refunded, challenge.calibrated, challenge.suspended, settlement.paid, tournament.settled, tournament.cancelled.

  • Verify X-Webhook-Signature: sha256=<hmac> over the raw bytes of the body, using the whsec_... secret, before parsing or trusting anything.
  • Deduplicate on X-Webhook-Delivery-Id, which is stable across retries.
  • Answer 2xx within 5 seconds and queue the work. Delivery is 3 attempts in total, with retries due 1 minute and then 5 minutes after a failure. After the third the delivery is marked failed and is not retried.
  • On attempt.settled, re-fetch, do not reconstruct: call GET /v1/attempts/{id} and trust that. Webhook amounts are JSON numbers while /v1 money is decimal strings, and deliveries carry no ordering guarantee.

Verify the integration

Run these in order. Each fails in a different place, so a sequence tells you where you are and a batch does not.

1. The key works and the program answers

# The key lives on your server. It never reaches the browser. curl -i "https://api.withchallenge.com/v1/challenges?player_ref=integration-check&status=all" \ -H "X-API-Key: $CHALLENGE_API_KEY"

Expected: HTTP/1.1 200 OK and a JSON body with a data array. Each row carries id, name, fee, prize, status, metric_name and either a threshold (live) or a calibration object (calibrating).

401 with { "error": { "code": "unauthorized", ... } } means the key is wrong, malformed, for the other environment, or on a program not approved for live use. All of those answer identically on purpose. Stop here and get the right key from the user. Nothing downstream can work.

2. Challenge.js is loaded and initialised

Open the game in a browser and run this in the console:

typeof window.Challenge // 'object' window.Challenge.version // a version string typeof window.Challenge.startAttempt // 'function' document.querySelector('challenge-island') // an element, not null

Expected: all four answer as annotated, and the console has no errors from Challenge. undefined for window.Challenge means the script tags did not load: check the URLs and any content-security-policy on the page. A console error naming program means init() ran with a missing or non-string program ID.

The island pill is visible in the anchor corner. If the program has no live Challenge yet, the pill is there but dormant: a small Coming soon pill that offers nothing. That is correct, not a failure. It does mean players see it, so the developer should decide deliberately whether the script tags ship before a live Challenge exists.

3. A recorded round lands

Still in the console, with the developer's real metric name, the one you read off metric_name in step 3:

await Challenge.record('moves', 34)

Expected: { recorded: true }. In the network tab it is one POST /api/island/record answered 202. That route is the SDK's own internal endpoint, not part of /v1, so it takes no API key and appears in no reference table. Seeing a 202 on it is the expected signal, not a sign that something is calling the wrong API.

{ recorded: false } means the metric name was over 40 characters, the score was not a finite number, or the network call failed. All three also write a console line saying which.

A 202 proves the round was stored, not that it was stored where a Challenge will read it. The metric string is not validated against your Challenges. Re-run the check from step 2: trim and lowercase both the string you passed and the metric_name from GET /v1/challenges?status=all, and confirm they are the same characters.

4. A Challenge is live

Seed the distribution (step 3), then re-run the curl from check 1.

Expected: the Challenge's status is "live" and it carries a numeric threshold instead of a calibration object. Reload the game: the island now shows the Challenge with its fee, prize and bar.

5. A full paid round settles

Tap the island, confirm the Challenge to arm it, then play one round through the real game loop.

Expected: startAttempt() resolves { attemptId, feeCharged: 0, prizeOffered: 0, threshold: <number>, thresholdDirection: '...' } in sandbox. Your server's POST .../outcome answers 200 with attempt_id, score, met_threshold and threshold_applied set to the frozen bar. Within a few seconds the island renders the result on its own, with no further call from your game.

met_threshold can be true in sandbox once a real bar is frozen. award stays null, because a zero prize pays nothing.

6. The settlement is durable

# The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/attempts/<<YOUR_ATTEMPT_ID>>" \ -H "X-API-Key: $CHALLENGE_API_KEY"

Expected:

{ "id": "...", "challenge_id": "...", "tournament_round_id": null, "player_ref": "...", "status": "settled", "fee_charged": "0.00", "created_at": "2026-08-24T11:59:12.000Z", "metadata": {}, "outcome": { "attempt_id": "...", "score": 34, "met_threshold": false, "threshold_applied": 57.5, "recorded_at": "2026-08-24T12:00:00.000Z", "receipt_id": null, "award": null } }

"status": "settled" with a non-null outcome is the integration working end to end. "status": "open" with "outcome": null means the outcome call never landed: check the server logs and re-run check 5 watching the network tab.

This is also the call to make whenever you want the truth about an attempt.

7. Send the outcome call twice

Re-run your server's report for the same attempt, with the same Idempotency-Key.

Expected: the same settlement, semantically identical, with an Idempotency-Replayed: true response header. Compare the parsed fields, never the raw body: key order is not part of the contract. A 409 attempt_already_settled instead means a different key was sent the second time, which is a bug in how you derive the key: it must be the attempt id.

Error catalog

Codes are a closed set. Branch on the code, never on the message. The wording is not part of the contract.

Challenge.js

Rejections from startAttempt() are an Error with name: 'ChallengeAttemptError' and a .code. Challenge.record() never rejects at all.

not_initialised

init() has not run, or the island was destroyed mid-call. Call Challenge.init({ program }) before any attempt, and move destroy() out of per-route teardown.

not_armed

Nothing is armed for this player. Not an error: run the round for free and still call record() at the end.

attempt_in_progress

A paid round is already open. Wait for settlement or the one-hour expiry. If it happens without a second round, your server is not reporting outcomes.

insufficient_funds

The balance does not cover the fee. Nothing was charged. Do nothing: the island offers a deposit.

challenge_not_available

The Challenge is suspended, closed, over the tier ceiling, or the server and island disagree about what is armed. Do not run a paid round. The island resyncs itself.

blocked

A responsible gaming break or self-exclusion the player set for themselves, or a state where this player may not play this Challenge for money. Do nothing, ever. Never offer an alternative paid path.

unauthorized

The player's own session is invalid or expired. Do nothing: the island puts sign-in up itself.

network

The request never reached Challenge, after its retries. Nothing was charged. Let the round run unpaid and log it.

client_reporting_removed

Something called Challenge.reportScore(), which was removed. Delete the call and report from your server instead.

unknown

A code newer than the bundle. Log .message and fall back to a free round. Always keep this branch.

Server-side conflict codes pass through with their own names: challenge_already_claimed, rung_locked, attempt_cap_reached, eligibility_incomplete, entries_closed.

API

Every /v1 failure is { error: { code, message } } with a fixed HTTP status.

invalid_request

400. A malformed request: a missing or over-long Idempotency-Key, both or neither of challenge_id and tournament_round_id, a missing player_ref, a score that is not a finite number, or a distribution whose percentiles do not ascend or whose sample_size is under 500. Read message, which names the field.

unauthorized

401. One code for every key failure: missing header, wrong prefix, unknown key, right key for the wrong environment, a live key on an unapproved program, a suspended program. They answer identically so a guess cannot be narrowed. Check the header, the prefix and the program's approval, in that order.

insufficient_funds

402. The player cannot fund the fee. Nothing was charged and no attempt was created. On the browser path the island offers a deposit; on the server path send the player to their Challenge account.

not_found

404. No such resource, or it belongs to another program. A 403 is deliberately never returned, because it would confirm the resource exists. On POST /v1/players it means the email has no Challenge account: there is no auto-provisioning.

challenge_not_available

409. The Challenge is suspended, closed, or its prize is above your integrity tier ceiling. Read its status from GET /v1/challenges?status=all. A suspended Challenge is reinstated by review, not by another call.

challenge_already_claimed

409. This player has already been paid this Challenge. Permanent for the pair. Build the menu from GET /v1/challenges?player_ref=..., which already omits claimed Challenges.

rung_locked

409. A ladder rung whose parent Challenge this player has not claimed. It clears when they claim the parent. GET /v1/challenges already omits locked rungs.

attempt_cap_reached

409. Ten paid attempts on this Challenge today, or a tournament round's own per-player cap. It clears as the day or the round rolls forward. Free calibration attempts never count.

entries_closed

409. The tournament round is closed or inside the ten minutes before close when re-buys stop. Read entries_close_at and stop offering entry before it.

round_closed

409. A tournament outcome reported after the round stopped accepting scores. The entry is refunded by the sweep, so the player pays nothing, but the score does not count. Report at the end of each round, not in a batch.

attempt_already_settled

409. This attempt already has an outcome. Almost always a retry sent with a fresh Idempotency-Key. Send the same key and get the replay instead. If unsure whether the first call landed, GET /v1/attempts/{id}.

idempotency_key_reused

409. The same key on this endpoint with a different body. Use a new key for a new operation, and derive it from the attempt rather than from a player or round id.

idempotency_in_progress

409. A request with this key is still running. Retry shortly with the same key. A record stuck over 60 seconds is treated as dead.

internal_error

500. Something failed on Challenge's side. Retry with the same key: nothing durable is stored for a non-2xx.

client_reporting_removed

410, on POST /api/attempts/{id}/outcome, which is not a /v1 route. Something is still calling the retired browser-side reporting endpoint, usually a cached old bundle. Report through /v1 from your server.

Rate limiting

429. /v1 allows 300 requests per minute per API key. The body is the limiter's own shape rather than the error envelope, so a parser assuming error.code on every failure breaks here. Back off for retry_after seconds. If you hit this in normal operation you are polling GET /v1/challenges per render: cache it and use webhooks for settlement.

Webhook delivery

What Challenge sawCauseFix
Connection refused or DNS failureThe URL is wrong or not publiclocalhost and private ranges are unreachable. Use a tunnel in development
3xxYour endpoint redirectsRedirects are not followed. Register the final URL
401 or 403Your own auth middleware is in front of the handlerExempt the webhook path. The signature is the authentication
404The path or method does not existThe request is a POST
Other 4xxSignature verification rejected it, or the body failed to parseVerify over the raw bytes, before any parsing or re-serialising
5xxYour handler threwAnswer 2xx first, do the work afterwards
TLS handshake failureExpired, self-signed or incomplete chainChallenge does not skip verification. Serve intermediates
TimeoutThe handler took longer than 5 secondsAcknowledge immediately and queue the work
Delivered but duplicatedA retry after a response Challenge never sawDeduplicate on X-Webhook-Delivery-Id

Do not build any of this

Agents over-build here, and every item below is already built, rendered by Challenge, and will conflict with the real surface if you duplicate it.

  • No sign-in, sign-up or account screens. The island runs the whole flow.
  • No deposits, no payment forms, no Stripe or card fields. Challenge holds the money.
  • No age check, identity check or KYC flow. Challenge runs verification.
  • No balance display, no wallet UI, no transaction list. The island's account panel is that.
  • No fee, prize or bar of your own drawing. Challenge quotes them on the arming card, and the numbers a player agreed to are Challenge's.
  • No win screen, lose screen, prize animation or result toast. The island renders the outcome by itself within seconds of settlement.
  • No won, passed, result or winner field anywhere in a request to Challenge. You report a measurement. Challenge decides.
  • No client-side settle call of any kind, and no API key in browser code. Not in NEXT_PUBLIC_*, not in a VITE_* variable, not in a mobile binary, not in a committed file.
  • No arming endpoint. There is no API that arms a Challenge for a player. Arming is the player's own act on Challenge's surface.
  • No Challenge.reportScore(). It was removed and throws client_reporting_removed.
  • No Challenge.gameEnded(), sdk.settleMatch(), sdk.settleDraw(), sdk.reportWinner(), Challenge.settle(), mode: "versus", settlement: "consensus", entryFee, matchmaking or an 85/10.5/4.5 split. Those belong to a retired model, were removed, and any material describing them is stale. Ignore it.
  • No npm package. npm install challenge installs somebody else's package. The distribution is script tags.
  • No polling loop for settlement. Use the webhook, or nothing at all: the island already handles the player's side.

When the browser path does not apply

Read this section only if the game has no browser client at all. A native mobile game with no web view, a console title, a headless simulation. If the game runs in a browser, use step 4 and skip this entirely. Almost every integration should.

Without Challenge.js there is no island, so nothing renders accounts, verification, deposits, balances or results inside the game. The player does all of that in the Player Portal, and getting them there is your problem. There is still no arming endpoint: the server door charges a fee against an account the player already funded.

Two calls replace startAttempt().

# The key lives on your server. It never reaches the browser. curl -i -X POST "https://api.withchallenge.com/v1/players" \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "player_ref": "user_8134", "email": "player@example.com" }'

player_ref is your own identifier for the player, up to 64 characters, and it is the only identity that ever crosses back to you. This links an account that already exists; it never creates one. An email with no Challenge account answers 404 not_found, worded so nobody can enumerate who has an account. Repeating the call with the same pair is safe and needs no idempotency key.

# The key lives on your server. It never reaches the browser. curl -i -X POST "https://api.withchallenge.com/v1/attempts" \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Idempotency-Key: 3f0b7c21-9d84-4a6e-bb15-72c0e9d84a11" \ -H "Content-Type: application/json" \ -d '{ "challenge_id": "<<YOUR_CHALLENGE_ID>>", "player_ref": "user_8134" }'
{ "id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "challenge_id": "a4e1c8b7-6f20-4d93-8c15-3b7e0a2f9d64", "tournament_round_id": null, "player_ref": "user_8134", "status": "open", "fee_charged": "2.00", "created_at": "2026-08-24T11:59:12.000Z", "metadata": {}, "outcome": null }

Exactly one of challenge_id and tournament_round_id. Both, or neither, answers 400 invalid_request. Idempotency-Key is required, and a replay returns the same attempt rather than creating a second one.

Reporting the outcome is identical to step 5. There is one outcome endpoint and it does not care which door the attempt came through.

This door also accepts a calibrating Challenge, which the island will not arm. That is the money-free loop from step 3.

For depth

Each of these is one page, and none of them is needed to finish the integration above.

  • How a round works: the whole lifecycle once, in order, as a sequence diagram.
  • The trust model: why the browser may start an attempt and may never end one.
  • Calibration: the three ways a bar is measured, the provisional 20% ceiling, the 200-attempt half-prize ramp, and the drift monitor that suspends a Challenge.
  • Sandbox: the free loop in full, and exactly what differs from production.
  • Go-live checklist: everything that must be true before sk_test_ becomes sk_live_.
  • Webhooks and Webhook events: handler, signature verification and every payload.
  • Challenge.js and API: the two references.
  • Errors: the same catalog as above, with more on each code.
  • Tournaments and The Challenge ladder: the other two things you can sell on the same attempts and the same key.

Add this to your project's AGENTS.md or CLAUDE.md

Paste the block below into the developer's own agent instructions file so the invariants survive after this session ends.

## Challenge integration (withchallenge.com) - The unit is a Challenge: a fixed standard, a fixed fee, a fixed prize Challenge funds. - The program ID is public and belongs in the browser. CHALLENGE_API_KEY (sk_test_ / sk_live_) is server-only: never in client JS, a bundle, a mobile binary, or a NEXT_PUBLIC_* variable. - Never invent a program ID or a key. If one is missing, stop and ask. - Report a measurement, never a verdict. POST /v1/attempts/{id}/outcome accepts { "score": n }. There is no "won" field. Challenge applies the bar it froze when it charged the fee. - Always send Idempotency-Key on the outcome call, and use the attempt id as its value. - Call Challenge.record(metric, score) at the end of every round, paid or free. Recorded rounds never move an existing Challenge's calibration counter; they price future Challenges on that metric. - Browser path by default: Challenge.init({ program }), the player arms on the island, await Challenge.startAttempt(), then the server reports. POST /v1/players + POST /v1/attempts is only for a game with no browser client. - Challenge.reportScore(), Challenge.gameEnded(), sdk.settleMatch(), mode: "versus" and settlement: "consensus" were all removed. Ignore any material that uses them. - Do not build sign-in, deposits, identity checks, balances, or win and lose screens. Challenge renders all of it. - Docs: https://www.withchallenge.com/llms.txt (index) and https://www.withchallenge.com/integrate.md (this guide).

Source: https://www.withchallenge.com/docs/integrate.md Docs index for AI agents: https://www.withchallenge.com/llms.txt

Ready to integrate?

Put a Bounty in 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.