# Challenge documentation > Challenge lets a player pay a fee to attempt a fixed standard in your game, called a Challenge, and pays them a fixed prize when they beat it. Challenge funds every prize, announces it before the player pays, and never pools one player's fee into another player's winnings. You add two script tags and one server handler; Challenge runs the accounts, the money, the identity checks, the verdict and every screen the player sees. Docs last updated: 2026-09-22T16:56:18.000Z API version: v1 Index: https://www.withchallenge.com/llms.txt # Overview Source: https://www.withchallenge.com/docs/overview.md Challenge lets a player pay a fee to attempt a fixed standard in your game, and pays them 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. There is no opponent, no wager and no pot. There is a bar, a fee to try it, and a prize for clearing it. You add two script tags and one server handler. Challenge does the rest. ## What you build, what Challenge runs The integration has two halves and one hard line between them. | Side | Holds | Does | |---|---|---| | Your game, in the browser | The program ID, which is public | Mounts Challenge.js, records ordinary rounds, starts paid rounds | | Your server | The secret key (`sk_test_`, `sk_live_`) | Receives finished rounds and reports the measurement to `/v1` | | Challenge | Everything else | Accounts, money, the bar, the verdict, every player-facing screen | Challenge runs all of the following, and none of it is yours to build: - Accounts, sign-in and single sign-on across games - Age and identity verification - Deposits, balances, withdrawals and the ledger behind them - Responsible gaming limits and self-exclusion - The arming flow, the fee confirmation and every result screen a player sees - Measuring a Challenge's bar from real rounds, then monitoring it for drift - Publishing the next rung when a player claims one, and pricing it - Funding and paying the prize What you build is three calls in the browser and one HTTPS request from a server you control. ## The integration at a glance The browser half. Two script tags and one call, then one line at the end of every round, then one line to start a paid round. ```html ``` ```javascript Challenge.init({ program: '<>' }) // At the end of every round, paid or free, signed in or not. Challenge.record('moves', 34) // When the player has armed a Challenge on the island and a paid round begins. const attempt = await Challenge.startAttempt() // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } ``` The server half. One request, with your key, once the round is finished. ```javascript // The key lives on your server. It never reaches the browser. const settled = await fetch( `https://api.withchallenge.com/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.CHALLENGE_API_KEY, 'Idempotency-Key': attemptId, }, body: JSON.stringify({ score: 34 }), } ) ``` Nothing is relayed back to your game. Challenge applies the bar it froze onto the attempt, pays the prize if it was met, and the island renders the result on its own. Three rules decide where money can be created, and every other part of the API follows from them: scores enter the money system only from an authenticated partner server, fees are charged before scores are heard, and the bar and the prize freeze at the start of the attempt. [The trust model](https://www.withchallenge.com/docs/trust-model.md) states each one and what it forbids. ## Key vocabulary | Term | Meaning | |---|---| | Program | Your game, as Challenge knows it. The program ID is public and goes in the browser. | | Challenge | The product unit. One standard a player can attempt: a metric, a bar, a fee and a prize. Never called a challenge. | | Metric | What is measured, in your game's own units: moves, seconds, points. | | The bar | The threshold a score is judged against, and its direction. Measured from real rounds by Challenge, never chosen by hand, because a bar you picked would set the pass rate, which would set what Challenge expects to pay. | | The island | The Challenge surface that sits over your game. It shows what is on offer, takes the fee confirmation, and renders the result. It is Challenge's, not yours: you never draw a fee, a balance or a verdict. | | Attempt | One paid try. Created with the fee charged and the bar frozen, settled by an outcome, expired and refunded if no outcome arrives within an hour. | | Arm | A player agreeing to a Challenge and its fee, on the island, by their own tap. There is no API to arm for them, because agreeing to a price is the player's act. | | Outcome | The score your server reports, plus Challenge's determination of whether it met the frozen bar. | | Award | The prize paid when it did. | | Calibration | The free period a new Challenge spends collecting rounds before it can charge. | | Rung | A harder Challenge, worth twice the one below it, unlocked by claiming that one. Spawned and priced by Challenge. | | Integrity tier | The prize ceiling and verification level for your program. It does not decide who reports a score. | | Program key | `sk_test_` for sandbox, `sk_live_` for production. Server side only, always. | | player_ref | Your own identifier for a player, used when your server creates attempts through `/v1`. | ## The documentation set Start with the [Quickstart](https://www.withchallenge.com/docs/quickstart.md) if you want a settled attempt in front of you within the hour. Read [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) first if you want to understand the loop before you write any of it. ### Get started | Page | What it answers | |---|---| | [Overview](https://www.withchallenge.com/docs/overview.md) | What Challenge is, what you build versus what Challenge runs, where everything else is | | [Quickstart](https://www.withchallenge.com/docs/quickstart.md) | The shortest path from an unmodified game to a settled attempt in sandbox | | [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) | The full lifecycle of one paid round, as a sequence, actor by actor | | [The trust model](https://www.withchallenge.com/docs/trust-model.md) | The three rules, what each forbids, and what each means for your code | ### Concepts | Page | What it answers | |---|---| | [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md) | Program ID versus `sk_test_` and `sk_live_`, which surface each may touch, approval and rotation | | [Challenges](https://www.withchallenge.com/docs/challenges.md) | Metric, threshold, direction, fee, prize, and the status machine behind them | | [Calibration](https://www.withchallenge.com/docs/calibration.md) | How a bar is measured, the three paths to one, the provisional cap and the prize ramp | | [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md) | Rungs, claim to unlock, prize doubling, and where locked rungs are listed | | [Tournaments](https://www.withchallenge.com/docs/tournaments.md) | House-funded fixed purses, rounds, entry rules, minimum fill and rank settlement | ### Build These pages are being written. Until they land, the [Quickstart](https://www.withchallenge.com/docs/quickstart.md) and the [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md) carry the browser half and the `/v1` [API reference](https://www.withchallenge.com/docs/api.md) carries the server half. | Page | What it will answer | |---|---| | Install Challenge.js | The two scripts, `init({ program })`, anchors, theme, SPA mount and `destroy()` | | Record rounds | `Challenge.record(metric, score)`, why it ships before anything chargeable, limits, metric naming | | Start a paid attempt | Arming, `startAttempt()`, the frozen fields it returns, branching on `.code` | | Report the score from your server | `POST /v1/attempts/{id}/outcome`, both server templates in full | | Server-created attempts | `POST /v1/players` and `POST /v1/attempts`, only for games with no browser client | | Webhooks | Handler, registration, signature verification, retries, debugging | | Real-time settlement | What Challenge.js already does, and when you need none of it | ### Test and go live | Page | What it answers | |---|---| | [Sandbox](https://www.withchallenge.com/docs/sandbox.md) | Test keys, the money-free loop a calibrating Challenge gives you, sandbox versus production | | Simulate a Challenge's life | The portal batch simulator, its controls, and a worked drift suspension | | Go-live checklist | Approval, key swap, ceilings, ramp, webhook verification, error branching | ### Reference | Page | What it answers | |---|---| | [Challenge.js](https://www.withchallenge.com/docs/challenge-js.md) | Every option, method, error code and catalog element in the browser half | | [API](https://www.withchallenge.com/docs/api.md) | All of `/v1`: authentication, decimal-string money, idempotency, the error contract | | Webhook events | Every event with its payload, trigger condition and delivery semantics | | Errors | The full catalog, grouped by surface, with cause and fix for each code | ### For AI coding agents An agent integrating Challenge in one pass should fetch `/integrate.md`, which is self-contained and executable with no further fetches, and `/llms.txt` for the index and the standing instructions. Both are generated from these pages, so neither can drift from them. # Quickstart Source: https://www.withchallenge.com/docs/quickstart.md The shortest path from a web game you have not touched to a paid attempt settling against a bar, in sandbox, with nothing to undo afterwards. You will add two script tags, one line at the end of your round loop, one line at the start of a paid round, and one HTTPS request from a server. Everything else, accounts, deposits, identity checks, the fee confirmation and the result screen, is Challenge's and is already built. ## Before you start You need four things. Two of them only you can supply, and neither can be guessed or invented. - **A developer account** in the [Developer Portal](https://app.withchallenge.com), with a program registered. - **Your program ID.** It is public and goes in the browser. It appears in the integration snippet on the portal's developer dashboard, one snippet per game. - **Your test key**, an `sk_test_...` string. It goes on your server and nowhere else. It is on the Games page in the portal, per game, behind a reveal control. - **A web game you can edit**, with a round that ends and a number that describes how well it went. Moves, seconds, points, anything, as long as more or less of it is unambiguously better. Set the key where your server code will read it, and never anywhere else: ```bash # The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_... ``` Throughout this guide, `<>` is the program ID from the portal. Replace it. Do not substitute a plausible-looking UUID. ## 1. Add Challenge.js and initialise it Put both script tags in the page that runs your game. ```html ``` The first is a small synchronous stub that paints the island immediately. The second loads the rest and takes over. Calls made before it arrives are queued, so nothing is lost. Then, once, wherever your game boots: ```javascript Challenge.init({ program: '<>' }) ``` That is the whole browser configuration. The API base is detected from the script origin, so you set `apiBase` only for local development. Until your program has a live Challenge, the island draws a small dormant pill reading **Coming soon**: it offers nothing and charges nothing, but players do see it, so decide deliberately whether to ship it before a live Challenge exists. ## 2. Record every round Add one line at the end of every round your game already has. Paid or free, signed in or not, practice included. ```javascript Challenge.record('moves', 34) ``` Use your own metric name and your own number. This is not part of the paid loop and it never charges anything: it is how Challenge learns what an ordinary round in your game looks like, which is what a bar is later measured from. `record()` never rejects and never blocks a round. It resolves `{ recorded }`, throttles itself to one request per second per metric without dropping rounds, and queues anything reported before `init()` has finished. ## 3. Confirm you have a Challenge Registering a program creates its first Challenge in the same request, so you probably already have one. Check the Challenges page in the portal, or ask the API: ```bash # The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/challenges?player_ref=quickstart&status=all" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` `status=all` is the partner view. Without it you get only live Challenges, and a new one is `calibrating`. To add another, use **Add challenge** on the Challenges page. It asks for four things: a name, the metric name, the metric unit, and the direction (`higher_is_better` or `lower_is_better`). It does not ask for a fee or a prize, because you do not set those. Challenge prices every Challenge, since a bar you chose would set the pass rate, which would set what Challenge expects to pay. Note the Challenge's `id` from the response. The rest of this guide calls it the challenge id. ## 4. Give the Challenge a bar A Challenge arrives `calibrating`: no bar, no fee, no prize, and not offered on the island. It leaves calibration when it has seen 1,000 adjudicated attempts from at least 200 distinct players, with no one player contributing more than 1% of them. Recorded rounds are a different sample: they price future Challenges on the metric, not this one. You are not going to wait for that today. If you already know how your players score, hand Challenge the distribution and the Challenge goes live at once: ```bash # The key lives on your server. It never reaches the browser. curl -s -X POST \ "https://api.withchallenge.com/v1/challenges/CHALLENGE_ID/distribution" \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-seed-1" \ -d '{ "distribution": { "p5": 22, "p25": 30, "p50": 38, "p75": 47, "p95": 61 }, "sample_size": 5000 }' ``` Five percentiles and a sample size, which is all a threshold needs and which comes out of one query on your side. The percentiles must rise from `p5` to `p95`, and `sample_size` must be a whole number of at least 500. Challenge interpolates the bar from those five anchors at the percentile its target pass rate implies: a 10% target on a higher-is-better metric wants the 90th percentile, which sits three quarters of the way from `p75` to `p95`. ```json { "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." } ``` A bar placed this way is provisional: supplying the distribution means setting the pass rate, which sets what Challenge expects to pay, so the prize is capped at 20% of your tier ceiling until observed attempts confirm the claim. The cap lifts on its own. The alternative, and the better one for production, is to record ordinary rounds from step 2 for a while and let the bar be measured from real play. A Challenge created for a metric you have already been recording can be given its bar in the same request that creates it, so no player ever meets it calibrating. Seeding works only on a `calibrating` Challenge, and only on one that is not a rung of a ladder. ## 5. Arm the Challenge, then start the attempt Reload your game and tap the island. It now shows the Challenge, with the fee, the prize and the bar. Confirm, and the Challenge is armed. Arming is the player's act. There is no API that does it for them, and your code is not in this step at all. Sign-up, the age check and a deposit, if one is needed, all happen inside that same flow. Now start the paid round from your game: ```javascript async function playPaidRound() { let attempt try { attempt = await Challenge.startAttempt() } catch (error) { // The island already told the player whatever they need to know. console.warn('Attempt not started:', error.code) return } // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } const moves = await runTheRound(attempt) await fetch('/round-finished', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attemptId: attempt.attemptId, moves }) }) } ``` `startAttempt()` charges the fee, freezes the bar and the prize onto the attempt, and resolves only once the money has moved. Judge the round against `attempt.threshold` and `attempt.thresholdDirection` if you show a target, not against anything you read from the Challenge, because those two fields are this attempt's terms. Rejections carry a `.code`: `not_armed`, `insufficient_funds`, `attempt_in_progress`, `challenge_not_available`, `blocked`, `not_initialised`, `unauthorized`, `network`, `unknown`. In sandbox your Challenge is priced at zero until Challenge prices it, so `feeCharged` and `prizeOffered` come back as `0`. Every other part of the loop, the arming flow, the freeze, the reporting call, the idempotency key, the settlement event, behaves exactly as it will with money on it. ## 6. Report the outcome from your server The fastest server is one you do not run. Create `supabase/functions/round-finished/index.ts` with exactly this content: ```typescript // The key lives on your server. It never reaches the browser. Deno.serve(async (req: Request) => { 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: { 'Content-Type': 'application/json' }, }) } // 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( `https://api.withchallenge.com/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 }), } ) return new Response(await settled.text(), { status: settled.status, headers: { 'Content-Type': 'application/json' }, }) }) ``` Deploy it: ```bash # The key lives on your server. It never reaches the browser. supabase secrets set CHALLENGE_API_KEY=sk_test_... supabase functions deploy round-finished ``` If you already run a backend, the same handler in Express: ```javascript // The key lives on your server. It never reaches the browser. const express = require('express') const app = express() app.use(express.json()) app.post('/round-finished', async (req, res) => { const { attemptId, moves } = req.body if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) { return res.status(400).json({ error: 'invalid_request' }) } const score = moves const settled = await fetch( `https://api.withchallenge.com/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.CHALLENGE_API_KEY, 'Idempotency-Key': attemptId, }, body: JSON.stringify({ score }), } ) return res.status(settled.status).json(await settled.json()) }) app.listen(3000) ``` Notice what the endpoint does not accept: whether the player won. You supply the measurement, Challenge applies the bar it froze onto the attempt, so a generous report cannot inflate a payout. There is no step 7. Nothing is relayed back to your game, and your game is never asked to display a verdict. ## Try it out Three checks, in order. Each one fails in a different place, so run them in sequence rather than all at once. ### Your key works and your Challenge is live ```bash # The key lives on your server. It never reaches the browser. curl -i "https://api.withchallenge.com/v1/challenges?player_ref=quickstart&status=all" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` Expect `HTTP/1.1 200 OK` and a body shaped like this: ```json { "data": [ { "id": "...", "name": "Fewest moves", "description": null, "fee": "0.00", "prize": "0.00", "currency": "USD", "status": "live", "prize_structure": "threshold", "threshold": 57.5, "min_calibration_n": 1000, "calibration_source": "partner_seeded", "metric_name": "moves", "metric_unit": "moves", "rung": 1, "parent_challenge_id": null } ] } ``` A `401` with `{ "error": { "code": "unauthorized", ... } }` means the key is wrong for the environment. A key resolves to exactly one program, and the answer never hints at which half was wrong. A Challenge still showing `"status": "calibrating"` carries a `calibration` object instead of a threshold, and step 4 has not taken effect. ### Your reporting call settles the attempt Play one paid round. The response your server relays back from Challenge is the settlement itself: ```json { "attempt_id": "...", "score": 34, "met_threshold": false, "threshold_applied": 57.5, "recorded_at": "2026-08-24T12:00:00.000Z", "receipt_id": null, "award": null } ``` `met_threshold` is Challenge's determination against `threshold_applied`, which is the bar frozen at step 5. `award` is `null` when the bar was missed and when the frozen prize was zero, and an object with an `amount` when a prize was paid. Fetch it back to confirm it is durable, not just returned: ```bash # The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/attempts/ATTEMPT_ID" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "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 } } ``` This is the call to make whenever you want the truth about an attempt. When a webhook later tells you one settled, re-fetch this rather than reconstructing state from the payload. ### The player sees the result without your help Look at the island in the browser you played in. It renders the win or the miss on its own, within a few seconds, because Challenge broadcast the settled attempt on the `attempt:{attemptId}` channel to that player's client and Challenge.js polls the attempt every three seconds behind that. Whichever answers first draws it. If you report nothing at all, the attempt expires an hour after it was created, the fee is refunded in full, and the island tells the player that too. ## You have a working loop Everything from here is depth on a loop that already runs. - **[Calibration](https://www.withchallenge.com/docs/calibration.md)** is what to read before your bar is real money's problem: the three ways a bar gets measured, the provisional 20% ceiling your seeded Challenge is under right now, the half-prize ramp over the first 200 paid attempts, and the drift monitor that suspends a Challenge whose realised pass rate leaves its band. - **[Challenges](https://www.withchallenge.com/docs/challenges.md)** covers the status machine you just walked through, and **[The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md)** covers what happens after a player claims one: a harder rung at twice the prize, spawned and priced by Challenge, with nothing for you to configure. - **[Tournaments](https://www.withchallenge.com/docs/tournaments.md)** are the other thing you can sell on the same attempts, the same reporting endpoint and the same key: house-funded fixed purses over 24-hour rounds, settled by rank. - **Webhooks** are how your own systems hear about settlement. Register an endpoint, verify `X-Webhook-Signature: sha256=` over the raw body before trusting a payload, and expect 3 delivery attempts in total, the retries due 1 minute and then 5 minutes after each failure, with no ordering guarantee. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md). - **Going live** is a checklist, not a switch: an approved program, `sk_test_` swapped for `sk_live_` in your server environment, no key anywhere in a client bundle, a webhook endpoint that verifies signatures, and a server that authenticates the caller so one player cannot post a round for another. [Sandbox](https://www.withchallenge.com/docs/sandbox.md) has the current version of it. If you want the whole loop explained rather than built, [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) tells it once in order, and [the trust model](https://www.withchallenge.com/docs/trust-model.md) says why it is shaped that way. # How a round works Source: https://www.withchallenge.com/docs/how-a-round-works.md One paid round, from the tap that arms a Challenge to the prize landing in a wallet, told once in order. Four actors take part, and each of them can do exactly one thing the others cannot. The player agrees to the price. The browser starts the round. Your server reports what was measured. Challenge decides what that measurement was worth. Nothing in the sequence below crosses those lines, and the rest of the API is shaped by that. ## The sequence ```mermaid sequenceDiagram autonumber participant P as Player participant G as Game (browser) participant S as Your server participant C as Challenge P->>C: Taps the island C-->>P: Shows the fee, the prize and the bar P->>C: Confirms. The Challenge is armed. G->>C: await Challenge.startAttempt() C->>C: Charge the fee, freeze bar and prize onto the attempt C-->>G: { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } P->>G: Plays the round G->>S: POST /round-finished { attemptId, ...raw round } S->>S: Derive the score from what only the server knows S->>C: POST /v1/attempts/{attempt_id}/outcome { score } C->>C: Apply the FROZEN threshold. Decide met or missed. C-->>S: { met_threshold, threshold_applied, award } C-->>G: attempt.settled on attempt:{attemptId} G-->>P: The island renders the win or the miss C-->>S: attempt.settled webhook C->>P: Prize credited to the wallet, on a win ``` ## Step by step ### 1. The player arms the Challenge The player taps the island, which is Challenge's surface sitting over your game. It shows them the fee, the prize on offer and the bar they would have to beat, and they confirm. That confirmation is what arming means. Your code is not in this step and there is no API that would put it there. Agreeing to a price is the player's act, so it happens on a surface Challenge owns, in front of numbers Challenge is quoting. Sign-up, the age check and a deposit, if the player needs one, all happen inside the same flow. ### 2. Your game starts the paid round ```javascript const attempt = await Challenge.startAttempt() // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } ``` Three things happen on the server before this promise resolves: - The fee is charged. Starting an attempt is the money event. - The bar in force right now, and its direction, are written onto the attempt. Later recalibration cannot reach back and change what this player was playing for. - The prize on offer right now is written onto the attempt too. If the Challenge is inside its go-live ramp, that is the ramped prize, which is the number the player was shown. - The attempt is given an expiry one hour out. The call resolves only after the money has actually moved. Until then the island dims and stays armed, because showing "round live" for a charge that then failed would be the island lying about someone's balance. A rejection carries a `.code` you can branch on: `not_armed`, `insufficient_funds`, `attempt_in_progress`, `challenge_not_available`, `blocked`, `not_initialised`, `unauthorized`, `network`, `unknown`. The island already tells the player whatever they need to know, including a refusal, so your handler is usually a log line. ### 3. The round plays Nothing is different about the round. Challenge is not watching it, does not know your rules, and has no opinion about how it is played. Your game runs exactly as it does when nobody has paid. ### 4. The round ends and your game hands it to your server ```javascript const moves = await runTheRound() await fetch('/round-finished', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attemptId: attempt.attemptId, moves }) }) ``` Send whatever your server needs to work out the score. The point of this hop is that the number Challenge is paid against is derived somewhere the player cannot edit, so send the raw round where you can and derive the score on the far side. ### 5. Your server reports the measurement ```javascript // The key lives on your server. It never reaches the browser. const settled = await fetch( `https://api.withchallenge.com/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.CHALLENGE_API_KEY, 'Idempotency-Key': attemptId, }, body: JSON.stringify({ score }), } ) ``` The attempt id is the natural `Idempotency-Key`, because an attempt has exactly one outcome. A retry under the same key replays the original answer and sets `Idempotency-Replayed: true` rather than settling twice. A different key on an already settled attempt answers `attempt_already_settled`, which you can treat as success. The body carries `score`, and there is no field for whether the player won. That absence is the trust model, not an oversight. It is covered in full on [the trust model](https://www.withchallenge.com/docs/trust-model.md). ### 6. Challenge applies the frozen bar and settles The score is compared against `threshold_applied` in the direction `threshold_direction` recorded on the attempt at step 2, not against the Challenge's current threshold. If the bar was met and the frozen prize is greater than zero, an award is written and the player's wallet is credited. The response is the settlement: ```json { "attempt_id": "...", "score": 34, "met_threshold": true, "threshold_applied": 40, "recorded_at": "2026-08-24T12:00:00.000Z", "receipt_id": "...", "award": { "id": "...", "amount": "9.00", "paid_at": "2026-08-24T12:00:00.000Z" } } ``` `award` is `null` when the bar was missed, and also when the attempt was a calibrating one, which charges nothing and can pay nothing. ### 7. The result arrives on its own Nothing is relayed back through your game, and your game is never asked to display a verdict. Challenge broadcasts the settled attempt on the `attempt:{attemptId}` channel to the player's own client, and Challenge.js polls the attempt every three seconds alongside that. Whichever answers first renders the win, the miss or the refund. Your server hears the same news through the `attempt.settled` webhook, which carries the score, the threshold applied, the fee and the prize awarded, signed with `X-Webhook-Signature: sha256=` over the raw body. Verify the signature before trusting the payload. A failed delivery is attempted 3 times in total, the retries due 1 minute and then 5 minutes after each failure. When a webhook tells you an attempt settled, re-fetch `GET /v1/attempts/{attempt_id}` and trust that rather than reconstructing state from the payload. Deliveries carry no ordering guarantee. ## The unhappy paths ### Nothing is ever reported An attempt expires one hour after it was created. Challenge voids it, refunds the fee in full, tells the player's client, and fires `attempt.refunded` with `reason: "expired"`. The fee buys one adjudication against a fixed bar, so an adjudication that never happened is a fee that was never earned. The expiry is enforced on the clock rather than only by the sweep, so a report that arrives after the deadline settles nothing: the attempt is reaped and refunded on the spot and the call is refused. ### The report arrives twice Same `Idempotency-Key`, same answer, `Idempotency-Replayed: true`. Idempotency records are retained for 24 hours. Reusing a key with a different body is refused with `idempotency_key_reused`. ### The player cannot fund the fee `startAttempt()` rejects with `insufficient_funds` and nothing is charged. The island offers a deposit. There is nothing for your code to do. ### The socket is down when the round settles The three-second poll answers instead. There is nothing for your code to do. ## What each actor can never do | Actor | Cannot | How that is enforced | |---|---|---| | The player's browser | Report a score | `Challenge.reportScore()` throws `client_reporting_removed`, and the old player-side outcome endpoint answers `410` with the same code | | Your server | Declare a win | `POST /v1/attempts/{id}/outcome` accepts `score` and has no `won` field. A non-numeric score is `invalid_request` | | Your code | Arm a Challenge for a player | There is no arming endpoint on any key | | Anyone | Change the bar mid-attempt | The threshold and its direction are copied onto the attempt at creation and read from there at settlement | | Another program's key | See or settle your attempts | Tenancy is applied to the query, so a foreign attempt id answers `not_found` rather than a forbidden that would confirm it exists | ## Where to go next - [The trust model](https://www.withchallenge.com/docs/trust-model.md) states the three rules this sequence is built from. - [Quickstart](https://www.withchallenge.com/docs/quickstart.md) walks the same loop with a working server handler and a verification step. - [Sandbox](https://www.withchallenge.com/docs/sandbox.md) explains how to run this whole sequence with no money on it. # The trust model Source: https://www.withchallenge.com/docs/trust-model.md Three rules decide where money can be created in a Challenge integration, and every other part of the API is a consequence of one of them. They are worth reading before you write any code, because each one closes off a design you might otherwise reach for, and each one is enforced rather than requested. Nothing below is a convention you can opt out of on a small prize. ## Rule 1: scores enter the money system only from an authenticated partner server A score that can move money arrives at `POST /v1/attempts/{attempt_id}/outcome`, authenticated with `X-API-Key` carrying an `sk_test_` or `sk_live_` key, from a server you control. **What it forbids.** A player's own device never reports a score. Not for a small prize, not behind an obfuscated payload, not signed with a key you shipped in the bundle. There is no ceiling low enough to make a browser an acceptable second door, and no integrity tier that opens one: tiers set a prize ceiling and a verification level, and they have never decided who reports. **The attack it closes.** Anything running in a browser is editable by the person it is running for. If a client could report, a player could open the console and report the score that wins, on an attempt they paid a fee for, against a prize Challenge funds. The fee is a few dollars and the prize is up to the tier ceiling, so the trade is profitable at any ratio you would want to sell. Requiring a server does not make a score honest; it makes the score come from somewhere the player cannot edit, which is the only property that can be enforced from outside your game. **What it means for your code.** You need somewhere to run one HTTPS request. That can be an existing backend, or a Supabase edge function of about twenty lines if you have no backend at all. The browser's job is to hand the finished round to that server, and the server's job is to derive the score from what only it knows: the seed it dealt, the session it opened, the player it authenticated. Send the raw round where you can rather than a number the client computed, because a client-computed number handed straight through is the browser reporting with an extra hop in it. Your key belongs in `process.env.CHALLENGE_API_KEY`, or your platform's equivalent. It never belongs in client JavaScript, a bundled config, a `NEXT_PUBLIC_*` variable, or a repository. ## Rule 2: fees are charged before scores are heard Starting an attempt is the money event. `Challenge.startAttempt()` resolves only after the fee has actually moved, and `POST /v1/attempts` returns only after the same charge. **What it forbids.** There is no path in which a round is played first and settled up afterwards, and no way for an outcome to create a charge. An outcome can only ever end an attempt that was already paid for. **The attack it closes.** It closes the free roll. If the fee were taken at settlement, a player could play until a round went well and only then let it be reported, and every unreported round would be a free look at a funded prize. Charging first makes every attempt cost the same whether it is finished, abandoned or lost. It is also what makes the browser a safe place to start an attempt at all. `startAttempt()` is callable from a page a player controls precisely because the worst thing it can do is spend that same player's own money, on the terms they just agreed to. Abuse of it is self-harm, not theft, and the daily attempt cap and responsible gaming checks cover the rest. **What it means for your code.** Call `startAttempt()` at the moment the paid round actually begins, not when a menu opens, and `await` it before the round starts running. A rejection means nothing was charged and no attempt exists: on `insufficient_funds` in particular, the island offers the player a deposit and there is nothing for you to do. If your round ends without a report, the attempt expires after an hour and the fee is refunded automatically, with `attempt.refunded` sent to your webhook, so an abandoned round is not a fee you have to reverse. ## Rule 3: the bar and the prize freeze at attempt start At the moment the attempt is created, the Challenge's current threshold, its direction and the prize on offer are copied onto the attempt row. Settlement reads them from there. **What it forbids.** Nothing that happens after the attempt starts can change what the player was playing for. Not a recalibration, not a drift suspension, not the go-live ramp completing, not you editing anything. There is also no API that would let a report change the bar it is judged against. **The attack it closes.** Two, from opposite directions. Without the freeze, a platform could recalibrate a bar upward while a paid round was in flight and judge a player against a standard that did not exist when they paid, which would make every quoted prize provisional. And a partner could move the bar to decide, after seeing a score, whether it won. The freeze makes the quote at the moment of payment binding on everyone, Challenge included. **What it means for your code.** Use what `startAttempt()` hands you rather than re-reading the Challenge: ```javascript const attempt = await Challenge.startAttempt() // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } ``` `threshold` and `thresholdDirection` are this attempt's terms, and `prizeOffered` is this attempt's prize. The Challenge's own `prize` field can differ from a prize frozen a moment ago, which is exactly the case the freeze exists for. If you render a target during the round, render `attempt.threshold`. The same rule is why the outcome response tells you `threshold_applied` alongside `met_threshold`: you are being shown the bar that was actually used, not the one currently configured. ## A measurement, never a verdict `POST /v1/attempts/{attempt_id}/outcome` accepts `score`. It does not accept `won`, `passed`, `result`, or any other field that would let the caller state a conclusion. A body without a numeric `score` is refused with `invalid_request`. That absence is the load-bearing part of rule 1. If a server could declare a win, then holding the key would be the same as holding a payout button, and the security of every prize would rest on the partner's code never being wrong and never being compromised. Because the endpoint takes a measurement, the worst a compromised or buggy integration can do is report a number, and a number still has to clear a bar that was fixed before the round started. It is also why the split of responsibilities is stable. You know what happened in your game and Challenge does not. Challenge knows what the round was sold for and you do not need to. Each side reports only what it is the authority on. ## Removed APIs These exist only as refusals. If you find them in a tutorial, a cached bundle, a model's memory or an old integration, they are from the pooled 1v1 model Challenge no longer runs. | Symbol | What happens now | |---|---| | `Challenge.reportScore()` | Throws an `Error` with `code: 'client_reporting_removed'`, and logs to the console, because a game that keeps calling it is a game whose rounds are not settling at all | | `POST /api/attempts/{id}/outcome` | Answers `410` with `code: 'client_reporting_removed'`. Retired, not missing: a stale bundle deserves an explanation rather than a `404` it will read as an outage | | `Challenge.gameEnded()`, `sdk.settleMatch()`, `sdk.reportWinner()` | Removed with the versus model. There is no match, no opponent and no pot | | `mode: 'versus'`, `settlement: 'consensus'`, entry-fee splits | Removed. A Challenge is a fixed prize funded by Challenge, so there is nothing to split | The route at `/api/attempts/{id}/outcome` carries no authentication middleware and no idempotency middleware on purpose. Everyone gets the same answer, an expired token would turn the explanation into a `401`, and a spent `Idempotency-Key` would poison the retry of a request that no longer exists. ## Where to go next - [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) shows the three rules in sequence, actor by actor. - [Quickstart](https://www.withchallenge.com/docs/quickstart.md) builds the loop they describe. - [Sandbox](https://www.withchallenge.com/docs/sandbox.md) is how you exercise all of it with no money on it. # Programs and keys Source: https://www.withchallenge.com/docs/programs-and-keys.md A program is your game as Challenge knows it, and it is the boundary every request is scoped to: the program ID identifies it in public, the secret key authenticates it in private. ## The program is the tenancy unit One program is one integrated product. It owns the Challenges you create, the tournaments you run, the players you have linked, the attempts they buy, the webhook endpoints you register and the integrity tier that caps what you may pay. A key resolves directly to one program, so there is no "which of this developer's programs did they mean" question for a request to answer wrongly. Everything inside `/v1` reads that resolved program and nothing else. A Challenge id, attempt id or round id that belongs to another program answers `not_found` rather than `unauthorized`, because confirming that a resource exists somewhere else is itself a disclosure. If you run two products, run two programs. There is no limit on how many Challenges one program may hold, so a second Challenge is not a reason to open a second program: a racing game with a lap time and a drift score is one program with two Challenges. ## Two values, two audiences | Value | Example | Where it belongs | What it does | |---|---|---|---| | Program ID | `<>` | The browser. Public. | Names your program to Challenge.js so the island knows what to offer | | Test key | `sk_test_...` | Your server, sandbox | Authenticates `/v1` against sandbox. No approval needed | | Live key | `sk_live_...` | Your server, production | Authenticates `/v1` against production. Issued on approval | The program ID is not a credential. It appears in your page source, in your bundle and in every browser's network tab, and that is fine: it names a program, it cannot start an attempt, and it cannot move money. Starting an attempt requires the player's own session, which the island holds. An `sk_` key is the opposite. It can report a score, and a score is what pays a prize, so a key in a client bundle is a key anyone can read and use to settle attempts as you. ```javascript // The browser. The program ID is public and belongs here. Challenge.init({ program: '<>' }) ``` **Runs on your server.** ```javascript // The key lives on your server. It never reaches the browser. const response = await fetch( `https://api.withchallenge.com/v1/attempts/${attemptId}/outcome`, { method: 'POST', headers: { 'X-API-Key': process.env.CHALLENGE_API_KEY, 'Idempotency-Key': attemptId, 'Content-Type': 'application/json', }, body: JSON.stringify({ score: 34 }), } ) const settled = await response.json() // { attempt_id, score, met_threshold, threshold_applied, recorded_at, receipt_id, award } ``` ## What a key resolves to Every `/v1` request carries `X-API-Key`. The prefix decides which column the key is looked up in: `sk_test_` against the program's test key, `sk_live_` against its live key. One value of each per program, so a key is either the current one or it is nothing. The lookup also decides the environment. There is no `?mode=` parameter and no environment header: sandbox and production are the same routes reached with different keys, and the key you sent is the whole answer to which one you are in. Sandbox is a key, not a copy. Both keys are two values on one program record and 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 the test key changes is the approval gate, which it skips, and that nothing you do with it charges real money, because no Challenge is priced until Challenge prices one. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md). ## Every refusal answers 401 Six different things can stop a key working, and all six answer the same status with the same shape: ```json { "error": { "code": "unauthorized", "message": "Invalid API key." } } ``` | Cause | What to check | |---|---| | No `X-API-Key` header | The header is required on every `/v1` request | | The value does not start with `sk_test_` or `sk_live_` | A truncated or misquoted environment variable | | No program holds that key | The key was rotated, or copied from a different program | | The key is right but for the other environment | `sk_test_` reaches sandbox only, `sk_live_` production only | | An `sk_live_` key on a program that is not approved | Approval, below | | The program is suspended or closed, or the account is scheduled for deletion | The Developer Portal says which | The first four deliberately share one message. Distinguishing "no such key" from "right key, wrong environment" would tell someone guessing which half of the guess was correct. The last two say more, because they are facts about your own account rather than about a secret. The practical consequence: a 401 in production is far more often the wrong environment than a wrong key. Check which of the two keys your process actually loaded before you rotate anything. ## Live keys require approval A program is created with a test key, integrity tier C and `approved: false`. That is enough to build the whole integration: sandbox, calibrating Challenges, the island, the outcome endpoint, webhooks. It is not enough to move real money. An `sk_live_` key is issued when your program is approved, and a live key presented for an unapproved program answers 401 with "This program is not approved for live use." Approval is a review of the program, not a form, and the same review is what raises an integrity tier. Nothing else changes at the swap. The same endpoints, the same payloads, the same error codes. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md) for what differs between the two environments. ## Integrity tiers Your tier is Challenge's view of how well a result you report can be verified, and it is the dial that turns that view into money. Every program starts at C. A tier is raised by review, never granted at signup. | Tier | Prize ceiling | What backs a score | |---|---|---| | C | $100 | Partner reported, authenticated by an API key. The default. | | B | $1,000 | A reviewed partner, the same reporting path, a larger ceiling | | A | $10,000 | Verified independently of the partner: computed by Challenge, or pulled from an oracle you do not control. Arranged during review. | The tier caps four things: - **The prize on a Challenge.** Checked when the prize is paid, not only when the Challenge was priced, because a program can be demoted after its Challenges exist. Over the ceiling answers `challenge_not_available` and names the limit. - **The provisional ceiling.** A Challenge whose bar came from a claim rather than from observed play may offer at most 20% of your tier ceiling until real attempts confirm it. See [Calibration](https://www.withchallenge.com/docs/calibration.md). - **How high a ladder goes.** Rungs double the prize, so the tier ceiling is what stops the ladder. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). - **Which entry fees a tournament may charge.** Tier C: $1. Tier B: $1 or $3. Tier A: $1, $3 or $5. See [Tournaments](https://www.withchallenge.com/docs/tournaments.md). A tier is not a permission to report. Scores enter the money system from an authenticated partner server at every tier, and from nowhere else. What the tier decides is how much a payout may be worth on the strength of that report, and at tier A, that an independently verified source is required rather than optional. ## Rotation Rotating a test key is self-serve in the Developer Portal, on the program. The new value is returned once, and the old one stops working with the update: there is one test key per program, and the middleware resolves that one value. Anything still holding the old key starts answering 401 immediately, so roll your server's environment variable in the same change. Rotate whenever a key has been where a key should not be: a committed `.env`, a shared log, a screenshot, a departed contractor's machine. Nothing about an attempt survives rotation, because keys authenticate requests rather than own resources. The program ID is not rotatable and does not need to be. It is public by design. ## Where each value may appear This is the whole rule, and it is worth reading as a checklist before you ship. | Surface | Program ID | `sk_test_` | `sk_live_` | |---|---|---|---| | `Challenge.init({ program })` in page JavaScript | Yes | No | No | | A client bundle, minified or not | Yes | No | No | | `NEXT_PUBLIC_*`, `VITE_*` or any client-exposed variable | Yes | No | No | | A mobile app binary or a shipped game build | Yes | No | No | | Your server's environment (`process.env.CHALLENGE_API_KEY`) | Not needed | Sandbox | Production | | A serverless function or edge function environment | Not needed | Sandbox | Production | | A CI secret store | Not needed | Yes | Yes | | A committed file, anywhere | Yes | No | No | | A support ticket, an issue, a chat message | Yes | No | No | If a live key has ever been in a client bundle, treat it as public and rotate it. There is no prize ceiling small enough to make a browser an acceptable place to keep one. ## Related - [The trust model](https://www.withchallenge.com/docs/trust-model.md): why the browser may start an attempt and may not end one - [Sandbox](https://www.withchallenge.com/docs/sandbox.md): testing with a test key, and what production does differently - [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference, including the full error contract # Challenges Source: https://www.withchallenge.com/docs/challenges.md A Challenge is one standard a player can pay to attempt: a metric, a bar, a fee, and a fixed prize Challenge pays to anyone who beats it. ## What a Challenge is made of | Field | Meaning | |---|---| | `name` | What the player reads on the card | | `metric_name` | What is measured, in your game's vocabulary: `moves`, `lap time`, `combo length` | | `metric_unit` | What it is measured in: `moves`, `seconds`, `points` | | `threshold_direction` | `higher_is_better` or `lower_is_better`. Which end of the scale wins. | | `threshold` | The bar. Measured from real play, never chosen by hand. `null` while calibrating. | | `fee` | What one paid attempt costs, as a decimal string | | `prize` | What beating the bar pays, as a decimal string. The prize **on offer**, not the prize configured. | | `status` | `calibrating`, `live`, `suspended` or `closed` | | `calibration_source` | How the bar was placed: `observed`, `listened`, `partner_seeded` or `rung_derived` | | `rung` | How far up a ladder this sits. `1` for an ordinary Challenge. | | `parent_challenge_id` | The Challenge a player must have claimed to attempt this one. `null` on rung 1. | The metric is what identifies a distribution, not the unit. A racing game's lap time and a puzzle game's solve time are both measured in seconds and are not the same thing, so two Challenges that share a `metric_name` share a sample and two that share only a unit do not. Metric names are normalised to trim, lowercase and single spaces before anything is counted, so `Combo Length`, `combo length` and `combo length` are one metric. They are capped at 40 characters. The string your game passes to `Challenge.record()` and the string on the Challenge have to be the same string, or the Challenge waits forever for a sample that is accumulating somewhere else. ## What you choose and what Challenge chooses You choose the question. Challenge chooses what it costs and what it pays. | You | Challenge | |---|---| | Name, description, metric name, unit, direction | The threshold | | Which Challenges exist at all | The fee and the prize | | When to create them | The target pass rate, and when the bar is trustworthy enough to charge | This split is not a missing feature. The prize is fixed and comes off Challenge's balance sheet, so the bar sets the pass rate, which sets the expected cost of every attempt. A partner who set their own bar would be setting Challenge's cost per attempt, which is why there is no field for it anywhere in the API. ## The status machine ``` calibrating ──────► live ──────► suspended (measuring) (charging) (pulled by the drift monitor) │ └──────► closed (retired) ``` | Status | For the player | For your integration | |---|---|---| | `calibrating` | Free play. Nothing is charged and nothing can be won. | Attemptable. `fee` and `prize` are `0.00`, `threshold` is `null`. | | `live` | Charging and paying. | Attemptable. The bar and the prize are frozen onto each attempt at its start. | | `suspended` | Gone from the island. | Not attemptable. `POST /v1/attempts` answers `challenge_not_available`. | | `closed` | Retired. | Not attemptable. Same refusal. | Two of these are enforced by the database rather than by application code, which is why they hold under every path that can write a Challenge: - A `calibrating` Challenge has `fee = 0`. A standard that has not been measured cannot charge for itself. - A `live` Challenge has a `threshold`. Going live without a bar would mean nobody can be judged, or everybody wins. **Both `live` and `calibrating` are attemptable, and that is deliberate.** Calibration needs attempts to gather scores, so refusing them until the Challenge was live would deadlock the model: no scores, no bar, no live. A calibrating attempt takes no fee, can win nothing, and is exempt from the paid-attempt guards below for the same reason. Suspension is a circuit breaker, not a deletion. See [Calibration](https://www.withchallenge.com/docs/calibration.md) for what trips it. Attempts already open when it trips keep the threshold captured at their creation, so nobody's result changes retroactively. ## One prize per player A Challenge pays each player once, for life. Once a player has been paid, every further paid attempt on that Challenge answers `challenge_already_claimed` (409), and the refusal is permanent for that pair. A claim is an **award**, not a met threshold. Awards exist only for paid attempts that cleared the bar for a prize above zero, so a free calibration round that happened to beat a bar set later never locks anyone out of the Challenge they helped measure. What claiming does instead is unlock the next rung. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). There is also a rolling daily cap: **10 paid attempts per player per Challenge per day**, answering `attempt_cap_reached` (409). It is high enough that nobody practising a Challenge meets it, and low enough that one account cannot outrun the drift monitor's measuring window. Free calibration attempts do not count against it. ## Creating a Challenge In the Developer Portal, on the program. **There is no `POST /v1/challenges`**, and there will not be one: creating a Challenge is a pricing decision Challenge participates in, not a request a partner can make unattended. You supply the name, an optional description, the metric name, the unit and the direction. Names and metric names are screened at the moment you type them, so a Challenge that Challenge cannot host is refused before you build against it rather than at review. Every new Challenge is written the same way, wherever it was asked for: `calibrating`, fee `0`, prize `0`, no threshold, target pass rate 10%. Then one of two things happens in that same request: - If your program has already recorded enough rounds of that metric, the bar is computed from them and the Challenge is live before the response returns. - If it has not, the Challenge stays `calibrating` and collects free attempts until it has enough. That is why recording ordinary rounds is the first thing to ship and not the last. [Calibration](https://www.withchallenge.com/docs/calibration.md) covers both paths, and the third. ## Where Challenges surface ### The island Challenge.js renders the program's Challenges itself: the card, the fee, the prize, the bar, the arming flow, the account, the age check, the deposit and the result screen. A game that calls `Challenge.init({ program })` is already listing Challenges and needs no menu of its own. The island also lists **locked rungs**, marked as locked and unpriced, because a locked rung is the thing a ladder is selling. That is the one place they appear. ### Your own layout ```html ``` Tapping a card runs the same arming flow, so there is still exactly one place a player agrees to a price. See [Challenge.js](https://www.withchallenge.com/docs/challenge-js.md). ### The API **Runs on your server.** ```bash # The key lives on your server. It never reaches the browser. curl https://api.withchallenge.com/v1/challenges?player_ref=player_42 \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "data": [ { "id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "name": "Fewest Moves", "description": "Solve the board in as few moves as you can.", "fee": "1.00", "prize": "4.50", "currency": "USD", "status": "live", "prize_structure": "threshold", "threshold": 27, "min_calibration_n": 1000, "calibration_source": "observed", "metric_name": "moves", "metric_unit": "moves", "rung": 1, "parent_challenge_id": null, "ramp": { "active": true, "fullPrize": "9.00", "attemptsSeen": 132, "attemptsRequired": 200 } } ] } ``` `player_ref` is required, and it is what makes the list safe to render directly. Everything returned is something `POST /v1/attempts` will accept for that player: - **Only `live` Challenges**, by default. Add `?status=all` for the partner-facing view, which returns your Challenges in every status and reports calibration progress on the ones still measuring. A player-facing menu should never send it. - **Challenges this player has already claimed are absent**, because a claimed Challenge cannot be attempted again. - **Locked rungs are absent**, because a rung this player has not unlocked cannot be attempted yet. An unknown `player_ref` is not an error here. Someone who has never played has claimed nothing, which locks every rung and claims none, and both of those are the true answer. Money is a decimal string everywhere in `/v1`. Binary floats cannot represent cents exactly, and a partner parsing `25.00` as `24.999999` would be a bug Challenge owned. ### The prize on offer, not the prize configured `prize` is what this player would be offered right now. During a Challenge's go-live ramp that is half the configured prize, and the `ramp` object says so: `fullPrize` is what it becomes, `attemptsSeen` against `attemptsRequired` is how far along it is. Always render `prize`. It is the number the player is about to pay against, and it is the number `POST /v1/attempts` will freeze onto the attempt. On a `calibrating` Challenge the response carries a `calibration` object instead, with `collected` against `required` and `playersCollected` against `playersRequired`. ## Attempting one Both doors freeze the bar and the prize at creation, and neither of them is a place your code decides what a player agreed to pay. | Door | Who authenticates | Use it when | |---|---|---| | `await Challenge.startAttempt()` | The player's own session | The player is in your game with the island in front of them. This is the usual case. | | `POST /v1/attempts` with a `player_ref` | Your API key | Your server drives the whole flow and there is no browser client | Failures worth branching on, none of which charge anything: | Code | Meaning | |---|---| | `challenge_not_available` | Suspended, closed, or a prize over your tier ceiling | | `challenge_already_claimed` | This player has been paid this Challenge. Permanent for the pair. | | `rung_locked` | A rung whose parent this player has not claimed | | `attempt_cap_reached` | Ten paid attempts on this Challenge today. Clears as the day rolls forward. | | `insufficient_funds` | The island offers a deposit. Nothing was charged. | ## Related - [Calibration](https://www.withchallenge.com/docs/calibration.md): how the bar is placed and kept honest - [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md): what claiming a Challenge unlocks - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): tiers, ceilings and what a key may do - [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference # Calibration Source: https://www.withchallenge.com/docs/calibration.md How a Challenge's bar is placed from real play, and how Challenge keeps it honest once money is on it. ## Why the bar is measured A threshold is not a taste decision. The prize is fixed and Challenge funds it, so the bar sets the pass rate, the pass rate sets the expected payout per attempt, and a bar guessed a little too low turns every attempt into a loss that looks correctly configured. Break-even at the platform's standard fee ratio is a 12.5% pass rate. The default target is 10%. So a Challenge earns its threshold. It is created free, with no bar, and it cannot charge until one has been placed from a sample big enough to trust. There are three ways that sample can arrive. ## The three paths | | Observed | Listen-first | Partner-seeded | |---|---|---|---| | Evidence | Free attempts on this Challenge, adjudicated by Challenge | Ordinary rounds your game reported before the Challenge existed | Percentiles you computed on your own history | | Target | 1,000 attempts from 200 distinct players | 5,000 rounds from 200 distinct player refs | `sample_size` of at least 500 | | Goes live | On the next calibration sweep once both targets are met | In the same request that creates the Challenge | In the request that supplies the distribution | | `calibration_source` | `observed` | `listened` | `partner_seeded` | | Prize ceiling | Your full tier ceiling | 20% of your tier ceiling | 20% of your tier ceiling | | Go-live ramp | Half prize for 200 paid attempts | None | None | All three place the bar with the same arithmetic and the same hardening. What differs is who produced the numbers, and that difference is priced by the trust treatment rather than by the maths. ### Observed The cold-start path. The Challenge is `calibrating`, players attempt it for free, and when it has collected **1,000 attempts from 200 distinct players** the sweep places the bar and takes it live. Two minimums, because they are bought with different currencies. Attempts are what an attacker can manufacture; players are what they have to recruit. A percentile taken over three people is not a measurement of a population, whatever its sample size says. Three further rules make a free sample hard to poison: - **One score per player.** The bar sits on each player's *best* result, not on the pool of every attempt. Against a pooled sample, playing badly on purpose buys a softer bar. Against per-player bests it buys nothing: a single good run, even an accidental one, replaces every deliberate miss that player has. - **A 1% share cap.** No player may contribute more than 1% of the counted sample, with a floor of 3. Past the cap, extra bad attempts change nothing at all, so the effort curve goes flat. - **Determinism.** The kept subset is each player's most recent scores, in a fixed order, so two calibration runs over the same data produce the same bar. Placing the bar on bests deliberately errs hard rather than soft. A bar that is too hard costs players; a bar that is too soft costs money; only one of those is recoverable. Both minimums have per-Challenge overrides (`min_calibration_n`, `min_calibration_players`). They are platform and operations settings, not partner dials. ### Listen-first The primary path, and the reason `Challenge.record()` ships before anything chargeable. ```javascript // At the end of every round your game already has, paid or not, signed in or not. Challenge.record('moves', 34) ``` Every ordinary round is recorded against the metric, silently: nothing rendered, no session required, no Challenge involved. When a metric has **5,000 rounds from 200 distinct player refs**, a Challenge created on that metric is given its bar from them and is live in the same request that created it. The point is that no player ever meets a calibrating Challenge. The first person to open your game is otherwise shown something that cannot pay, and asked to keep playing so somebody else can win later. Details worth knowing: - 5,000 rather than 1,000 because the evidence is weaker. An observed attempt was paid for and adjudicated by Challenge; a listened round is a number your client sent. Five times the sample does not make an unverified number verified, but it does take the *distribution* out of doubt. - One player ref may write at most **300 scores per metric in a rolling 24 hours**. Over the cap the call still answers success and the score is dropped: a listener that can fail is a listener a partner eventually removes. - **Listened rounds place a bar once, at creation, and only at creation.** They are never merged into a later recalibration, because listened rows are keyed on your `player_ref` and cannot always be attributed to an account, and a recalibration that cannot remove a flagged player's contribution is worse than one with less data. - If the targets are not met, nothing happens. The Challenge is created `calibrating` and collects its sample the observed way. The Developer Portal reports listening progress per metric, and beside it the metric key each of your Challenges is waiting on. That mismatch, between the string your game sends and the string your Challenge listens for, is the one failure mode of this path and it is invisible without that view. ### Partner-seeded **Runs on your server.** You already have the history and would rather not wait for either sample. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/challenges/<>/distribution \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Idempotency-Key: 8f1c9d4e-3b7a-4f61-8a9c-2d0e6b1f4c33" \ -H "Content-Type: application/json" \ -d '{ "distribution": { "p5": 19, "p25": 24, "p50": 31, "p75": 40, "p95": 58 }, "sample_size": 12480 }' ``` ```json { "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "threshold": 20.25, "target_pass_rate": 0.1, "sample_size": 12480, "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." } ``` Five percentiles and a sample size. Not rows: that is all a threshold needs, it carries no personal data, and it comes out of one query on your side. The threshold above is the 10th percentile of a `lower_is_better` metric, interpolated between the `p5` and `p25` anchors, because letting 10% through a bar that rewards smaller numbers means sitting at the 10th percentile. On a `higher_is_better` metric the same 10% target sits at the 90th. The Challenge is live when this returns. The rules it holds to: - `sample_size` must be a whole number of at least **500**. A tail estimate from thirty scores is noise dressed as data, and the observed path demands 1,000 adjudicated attempts before it trusts a bar. - `p5` through `p95` must ascend. Percentiles that do not ascend are not percentiles. - The Challenge must be `calibrating`. A `live` one is refused because reseeding would move the bar under players mid-flight; a `suspended` one is refused because a new distribution is not a way to reset the breaker that pulled it. - A rung cannot be seeded at all. Its bar is measured from the players who claimed the Challenge below it. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). - The bar is interpolated between the anchors you gave, never extrapolated past them. A target pass rate that would need a percentile outside p5 to p95 is refused, with the achievable rate named, rather than silently clamped to the edge anchor. - `Idempotency-Key` is required. Seeding takes the Challenge live, so a partner whose request succeeded but whose response was lost has to be able to retry and learn what actually happened. ## Provisional, or ramped, never both Two mechanisms bound the cost of a bar that turns out to be wrong. Which one applies is decided by `calibration_source`, and no Challenge is ever subject to both. | Source | Treatment | |---|---| | `listened`, `partner_seeded` | **Provisional.** The prize may not exceed 20% of your tier ceiling. No ramp. | | `observed`, `rung_derived` | **Ramped.** Half the configured prize for the first 200 paid attempts, topped up once the bar is confirmed. No provisional cap. | **Provisional** answers a doubt about honesty. Neither a listened round nor a supplied percentile was paid for or adjudicated by Challenge, so neither says what the realised pass rate will be once real money is on it. Volume does not close that gap: a listened sample five times the observed floor is still five times as much unverified data. A prize over the provisional ceiling answers `challenge_not_available` and names the limit. **The ramp** answers a doubt about price, not honesty. An observed bar was placed on free play by people with no reason to try, and then met by people who paid a fee, which is a different population. So the first 200 paid attempts run at half the prize while the drift monitor gathers enough results to tell a true 10% pass rate from a 20% one. Attempt 201 is the first at the full prize, and the change is automatic. **The top-up.** A win during the ramp is not locked at the half. Once the ramp completes and the drift monitor confirms the bar held, every ramp-window winner is paid the other half of the configured prize automatically, through the same ledger their original prize moved through. If the bar fails instead, the Challenge is suspended and no top-up is paid: the halved window is exactly the exposure the ramp exists to cap. Player-facing surfaces may therefore state the full prize beside the ramped one, phrased as conditional on the bar holding. During a ramp, the `prize` field is the reduced amount and `ramp.fullPrize` is what it will become. Always show `prize` as the amount on offer now. Stacking the two mechanisms would price a Challenge at a tenth, which is why a source is on one list or the other. ## The drift monitor Calibration is not a one-time event, because populations change. A Challenge calibrated on early adopters meets a broader audience; you patch your game and everyone's scores jump. The realised pass rate moves, and because the prize is fixed, the expected cost per attempt moves with it. Every live Challenge is checked against its last 500 settled outcomes. Below **30 outcomes** the pass rate is noise and no judgement is made. Above it, two questions are asked separately: - **Fairness.** Is the realised pass rate within the band around the target? The band is 5 percentage points by default, so a 10% Challenge is judged against 5% to 15%. - **Solvency.** Does the expected payout, the realised pass rate times the prize, exceed the fee? A Challenge can sit inside its band and still lose money. ### Suspension Fail either and the Challenge is set to `suspended`, immediately, and `challenge.suspended` fires with the realised rate, the target and the reason. New attempts answer `challenge_not_available`. The response is suspension rather than an alert because an alert needs someone awake and a circuit breaker does not. It is deliberately not a silent threshold change: recalibrating on the spot would move the bar under players mid-flight. Attempts already open keep the threshold frozen at their creation, so no result changes retroactively. A bar that passes far more players than it was priced for is also the symptom of a sandbagged calibration, so suspension triggers one sandbag pass: players whose free scores sit far below their paid ones are flagged, dropped from the sample whole rather than trimmed, and the bar is recomputed without them. That recalibration does not reinstate anything. The Challenge stays suspended until a human disposes of the case, and no `challenge.calibrated` event fires, because none went live. ### Promotion Inside the band and solvent, an unconfirmed source is promoted to `observed`. That is the half that makes the provisional paths worth using: a partner who supplied an accurate distribution, or let Challenge listen to 5,000 honest rounds, would otherwise be permanently capped at a fifth of their tier for having been helpful. On promotion the provisional ceiling lifts on its own, with no request to make and no ticket to open. A `rung_derived` bar is promoted on the same evidence, though it had no ceiling to lift. What it gains is the record that its halved target survived contact with a real field. ## Events | Event | When | |---|---| | `challenge.calibrated` | A bar was placed and the Challenge went live, by any of the three paths or by a rung being priced. Carries the threshold, the direction, the sample size and the player count. | | `challenge.suspended` | The drift monitor pulled it. Carries the realised pass rate, the target and the reason. | ## Related - [Challenges](https://www.withchallenge.com/docs/challenges.md): the status machine and what each status means for your integration - [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md): how a rung's bar is measured and priced - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): tier ceilings, and what the provisional cap is a fraction of - [Sandbox](https://www.withchallenge.com/docs/sandbox.md): running a Challenge through calibration without money # The Challenge ladder Source: https://www.withchallenge.com/docs/challenge-ladder.md Claiming a Challenge unlocks a harder one worth twice as much, published for everyone who got that far and priced on the players who did. ## What it is for A Challenge pays one prize per player, for life. The players who reach that wall first are the best ones, and from that moment they are worth nothing on that Challenge ever again: a claimed-out player has money, skill and appetite, and nothing left to buy. The ladder is what they buy next. A harder bar, twice the prize, unlocked by the claim they already made. There is nothing to configure and nothing to build. Challenge spawns each rung, measures its bar from the players who claimed the one below, prices it, and stops the ladder when the next doubling would pass your integrity tier ceiling. ## The player rules 1. **Claiming rung n unlocks rung n+1 for that player.** Nothing else unlocks it. 2. **One claim per player per rung.** The ordinary win lock, unchanged. 3. **A score claims only the rung it was bought on.** A rung 1 winning run that would also have beaten rung 2's bar claims nothing on rung 2: the player did not buy a rung 2 attempt, and rung 2's bar may not have existed when they scored. No retroactive claims, no skipping rungs. 4. **Locked rungs are visible, unpriced.** The island shows "claim Fewest Moves to unlock" rather than a bar and a prize, because there is nothing yet to promise. The unlock is not stored anywhere. "Has this player unlocked rung n" is exactly "has this player claimed rung n-1", which is the same award lookup the win lock already reads. A second copy of that fact would be free to drift from the refusal a player actually meets. ## How a rung comes into existence A rung is an ordinary Challenge row with two extra fields: `parent_challenge_id`, and `rung` counting from 1. It inherits the metric, the unit, the direction, the drift band, the tier ceiling, the solvency breaker, the sandbag response, the daily cap and the win lock, because all of those already act on a Challenge row and none of them need to learn what a rung is. A single sweep, every 15 minutes, does both halves of the work. ### Spawn As soon as a Challenge is live and has no child, the sweep creates one: **locked, unpriced, fee 0, prize 0**, in status `calibrating`. There is no separate "locked" status, because `calibrating` already means "this exists, it has no bar, and it cannot charge", and the database enforces the last part. A Challenge has at most one child, guaranteed by a unique index rather than by the sweep's timing. Nothing spawns above a Challenge that is not live, above one that pays nothing (twice nothing is nothing, and an unclaimable parent unlocks nothing), or where the doubled prize would clear your tier ceiling. ### Pricing A locked rung opens when the population below it is big enough to measure: - **25 claimants** of the parent, and - **500 capped scores** contributed by them. Capped means after each player's share of the sample has been taken down to 1%, floor 3, so 500 capped scores implies on the order of 2,000 raw scores spread across those players. A rung is a promise to a small, strong population, and it should be slow. 25 is deliberately far below the 200 distinct players an ordinary Challenge needs, because the two numbers are bought with different currencies. Two hundred free-play players is a morning of traffic; 25 people who have each already claimed a paid Challenge in your game is a population that can take months to produce. The parent must still be live at pricing. Pricing a child off the players who beat a bar the drift monitor stopped trusting would launder a suspension into a new product. ## Where the numbers come from | Quantity | Rule | |---|---| | Prize | Twice the parent's prize, stopped by your tier ceiling | | Fee | 12.5% of the prize, the platform's standard ratio | | Target pass rate | Half the base rate, applied once at the step onto the ladder | | Bar | The target percentile of the claimants' own scores at this metric | **The prize doubles.** The ladder's height is therefore set by your integrity tier rather than by a constant. A $9 rung 1 on tier C gives $18, $36 and $72, and stops there, because $144 is over the $100 ceiling and a rung nobody could ever be paid for is worse than no rung. The ceiling is re-read at pricing rather than carried from spawn, because a program can be demoted in between and the ceiling that governs a payout is the one in force when Challenge pays. **The fee is 12.5% of the prize**, which is also the break-even pass rate. Fee ratio, break-even and the solvency breaker are one number said once. **The target pass rate is halved once, at the step onto the ladder, and inherited unchanged after that.** Rung 2 of a 10% Challenge targets 5%, and so does rung 3, and so does rung 4. The halving prices adverse selection: the players who come back to attempt a rung are not a random draw from its claimants, they skew hard toward the ones most likely to beat it. That fact is equally true at every rung rather than compounding up the ladder. Halving per rung would put rung 5 at 0.6%, which is not a Challenge, it is a lottery with a bar. **The bar** is placed by the same two functions every other calibration path uses, on a different population: every score at this metric by players who claimed the parent, capped at 1% per player, reduced to one best score per player, cut at the target percentile. There is no new statistic. Scores on sibling Challenges that measure the same metric count too, because a game measuring one distribution twice should not have half of it thrown away. A priced rung carries `calibration_source: "rung_derived"`, which puts it on the **go-live ramp**: half its prize for its first 200 paid attempts, then the full prize automatically. A win during the ramp is topped up to the full prize once the drift monitor confirms the bar held. Its bar was measured by Challenge from paid, adjudicated outcomes, so it is not subject to the provisional ceiling. See [Calibration](https://www.withchallenge.com/docs/calibration.md). The rung's player-facing name is built from the ladder's root name and its number, so rung 4 of "Fewest Moves" is "Fewest Moves Level 4", not a name with three suffixes on it. ## Where locked rungs are, and are not | Surface | Locked rungs | |---|---| | The island | **Listed**, marked locked, with the Challenge that unlocks them named | | `GET /v1/challenges` | **Absent** | Not a disagreement. `GET /v1/challenges` is a menu you render as offers, and everything it returns is something `POST /v1/attempts` will accept for that player: a Challenge they have claimed is finished, a rung they have not unlocked is not open yet, and one filter handles both. Your own page is not Challenge's to put a "you cannot have this" card on. The island is a Challenge surface, so it can draw a locked card and sell the ladder with it, which is the entire commercial point of the feature. **You do not have to read `rung` or `parent_challenge_id` at all.** They are on the Challenge object for partners who want to render a ladder themselves. If you build a menu some other way and offer a locked rung anyway, the attempt answers `rung_locked` and nothing is charged. The message names the Challenge that unlocks it. The refusal is temporary, unlike `challenge_already_claimed`: it clears the moment that player claims the parent. ## Exposure, stated plainly A player's lifetime take from a full tier C ladder is $9 + $18 + $36 + $72, which is $135, each amount behind its own fee and its own bar, each bar measured on a population that has already proved it can clear the one below. At the halved target the expected margin per attempt is the fee minus 5% of the prize, which is 60% of the fee. That surplus is not free money. It is the price of the selection skew, and the drift monitor still owns the runtime truth: a rung whose realised pass rate crosses its 12.5% break-even suspends itself exactly like any other Challenge. ## Related - [Challenges](https://www.withchallenge.com/docs/challenges.md): the win lock, the status machine, and the Challenge object - [Calibration](https://www.withchallenge.com/docs/calibration.md): the ramp, the drift monitor and the other three bar-placing paths - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): the tier ceiling that decides how tall a ladder can be # Tournaments Source: https://www.withchallenge.com/docs/tournaments.md A tournament is a recurring rank table: players pay an entry fee, Challenge puts up a fixed purse announced before anyone enters, and the purse is paid by rank when the round closes. ## Tournaments beside Challenges A [Challenge](https://www.withchallenge.com/docs/challenges.md) is a fixed standard. The player pays a fee and Challenge pays a fixed prize to anyone who beats the bar. A tournament is a fixed purse against a field. The player pays an entry fee and Challenge pays the announced amounts to whoever finishes on top. The two share everything underneath. The same attempts, the same `Challenge.startAttempt()`, the same outcome endpoint, the same key, the same webhooks, the same island. **A game that already offers Challenges offers tournaments without changing a line of code.** You are not integrating twice. | | Challenge | Tournament | |---|---|---| | What the player pays | A fee per attempt | An entry fee per attempt, flat for the first and every re-buy | | Who funds the prize | Challenge | Challenge | | When it is known | Before the player pays | Before the player pays | | What decides who wins | A fixed bar | The rank table at the close | | What Challenge earns | The fee | The entry fee | ## The house is never on the other side of a score Entry fees are Challenge's revenue, booked when an attempt is bought. They are not held, pooled, or earmarked for anyone's winnings. The purse is separate money Challenge commits before the round opens, out of its own funds. Nothing about a score changes what Challenge earns or what the purse pays. There is no pool that grows with the field and no rake taken out of one player's stake to fund another's win. A round with thirty players and a round with three hundred pay exactly the same three amounts. The one thing that can move is whether the round runs at all. ## The purse, and the one condition A tournament is one price. The entry fee is $20 per attempt, the purse is 50 times the fee, and the split is three ways at **51% / 31% / 18%**. | Entry fee | Purse | Scored attempts needed | Players needed | Paid | |---|---|---|---|---| | $20.00 | $1,000.00 | 70 | 12 | $510.00 / $310.00 / $180.00 | The fee is per attempt, not a buy-in, so a player who uses all ten attempts spends $200 in a round. Three positions and three dollar amounts, decided at spawn and true at any field size. A fixed purse that scaled its positions with turnout would make each winner's amount depend on how many people paid, which is the contingency the whole model exists to avoid. The card a player reads before paying shows the numbers settlement will pay. Rank one is $510, and no top position may sit above your integrity tier ceiling. That is why tournaments need a **tier B or A program**: tier C's ceiling is $100, so no round at this purse fits under it, and a tier C program is refused a tournament rather than offered a smaller one. ### The two conditions a round has to meet **A round runs only if it collected at least 70 scored attempts AND at least 12 distinct players posted a score. Short of either, it is cancelled and every entry fee is refunded in full.** The two answer different questions and neither substitutes for the other: - **70 scored attempts** is what pays for the purse. Fifty attempts at $20 is exactly the $1,000 purse, so 70 is break-even plus a 40% margin. It is a count of attempts rather than of players because attempts are what carry a fee: ten players buying seven attempts each clear it, thirty players buying one each do not. - **12 players** is the anti-farm floor. A rank table small enough for one group to occupy all three paid positions is not a contest, whatever it collected. Both are frozen onto the round when it opens, so a change to either can never reach a round somebody has already paid into. Nobody plays for a purse that was never going to be paid, and nobody is out of pocket for a round that did not happen. The deal in one line: the purse never changes, but the round only runs if enough attempts are played in it. ## Creating one Developer Portal, on the program. You supply a name and description, the metric name and unit, the direction, a tiebreak metric, an entry fee, and the UTC hour a new round opens. ### The tiebreak metric, and why it is required Two players will post the same score sooner than you expect. On a metric with a best possible value, fewest moves, a perfect accuracy, a completed puzzle, the top of the board ties constantly, because every strong player reaches the same optimum. A round ranked on one number has to break those ties somehow, and every somehow except a second measurement is hostile to somebody: first-to-post hands the rank to whoever got there first and tells everyone after them that matching it loses, which empties the round exactly when it needs entrants. So a tournament declares a **tiebreak metric** at creation: a second number your game reports with every score, that orders players who tie on the first. Time is the usual choice for a score or move-count game (the fastest perfect game wins), moves or accuracy for a time game. It cannot be the same measurement as the metric itself, and creation is refused without it. Your game then reports both numbers on every tournament attempt: `{ "score": 7, "tiebreak": 12.431 }`. A report without the tiebreak is refused with `tiebreak_required` and the attempt stays open, so a half-wired integration fails loudly at the first report rather than quietly building an unrankable board. Ranking is score first, tiebreak second, and only then the clock, as pure determinism for the near-impossible identical pair. The entry fee must come from the allowlist your integrity tier permits: | Tier | Entry fees | |---|---| | C | none: tier C cannot run tournaments | | B | $20 | | A | $20 | The tier is how well Challenge can verify what you report, and it reaches money through the ceiling on a single payout. A $1,000 purse pays $510 at rank one, which fits under tier B's ceiling and does not fit under tier C's, so a tier C program is refused with the tier requirement named rather than offered a fee that cannot work. A fee outside the list is refused with the list named. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). Everything else is platform-owned and identical for every partner: | Setting | Value | |---|---| | Prize funding | Challenge, from its own funds | | Round duration | 48 hours | | Rounds per tournament | Back to back, about three and a half a week | | Attempt cap | 10 paid attempts per player per round | | Entry cutoff | 10 minutes before the round closes | | Reporting grace | 15 minutes after the round closes | | Minimum scored attempts | 70, break-even fill plus a 40% margin, frozen at spawn | | Minimum entrants | 12, the anti-farm floor, frozen at spawn | | Purse split | Top three, at 51% / 31% / 18% | **There is no calibration step.** A tournament prices nothing and has no bar to measure, so there is no sample to collect first: the first round opens on the next window, and every later round opens as the one before it ends. ### The schedule is a grid, in UTC Rounds tile a fixed lattice of instants: the spawn hour you chose, plus whole multiples of the round duration. Every round opens on a grid point and closes on the next one, so one round's close is the next round's open and the schedule a player learns in the first week is the schedule they keep. Nothing is measured from when the previous round happened to settle, so a settlement that runs late, or a day late, moves nothing. The grid is UTC, and it is the whole reason the spawn hour is stated in UTC. No clock change anywhere moves a grid point, so a round is never an hour early, an hour long or an hour short twice a year. A round is only ever opened into the front half of its own window. A tournament created partway through a window waits for the next grid point rather than opening a round that is already half over: that round would carry a few hours of entries against a full attempts gate, and would be cancelled at its midpoint anyway. Editing or pausing a tournament never touches a round that is already running. Each round freezes its entry fee, purse, payout table, attempt cap, minimum scored attempts and minimum entrants when it opens and runs to completion on those. Changes apply from the next round, which is what lets a player treat the numbers on the card as a promise. ### Pausing, resuming and closing Three actions, on the tournament row in the Developer Portal. | Action | What it does | What it does not do | |---|---|---| | Pause | No new round opens at the next grid point | Nothing to the open round: it keeps selling entries to its own cutoff and settles or cancels on schedule | | Resume | Rounds open again from the next grid point | Nothing retroactive: the windows that passed while paused stay skipped | | Close | Permanent. No new round opens, ever | Nothing to the open round, which finishes exactly as it would have | **None of the three is a stop button, and there is deliberately no stop button.** A round holds money the field has already paid in, under terms it was sold on. Ending one early would either strand that money or refund a contest people are in the middle of winning, so the only two ways a round ends are the two it always had: it settles, or it fails its minimums and refunds everybody in full. Pausing at the worst possible moment costs a partner at most one more round. Closing is final. There is no reopen, because a closed tournament has told its players it is over and putting a round back on a schedule they stopped watching is worse than making a new one. Creating another costs nothing: a tournament prices nothing and has no calibration step. Pausing a live tournament pauses its sandbox twin with it, so the two never disagree about whether the tournament is running. The actions live on the Live view for that reason: the twin follows. ## The round ``` opens_at ─────────────── entries_close_at ──── closes_at ──── settlement | | | | round opens, re-buys stop, scores stop standings, attempts sell 10 min before being accepted payouts, next the close round spawns ``` | Moment | What is true | |---|---| | `opens_at` | Attempts can be bought and scores can be reported | | `entries_close_at` | No further attempts can be bought, first entry or re-buy. Attempts already bought stay playable and reportable. | | `closes_at` | Scores are no longer accepted. Reporting one answers `round_closed`. | | `closes_at` + 15 min | Settlement runs | The entry cutoff exists because standings are visible live. Without it the optimal play is to sit out, watch the board, and buy exactly enough attempts at the death to clear the last paid place. The reporting grace exists for the scores still travelling from your server when the bell goes. Both are on the round object (`entries_close_at`), so read them rather than subtracting a constant yourself. ### Entries and ranks - One entry per player per round, created the first time they buy an attempt in it. - Every attempt costs the same. No discount and no premium for a re-buy. - At most **10** paid attempts per player per round. The cap is what keeps the table a skill contest rather than a spending contest. - Only a player's **best** settled score counts. More attempts buy more chances at a better score, never more rows on the table. - Ties break on the declared tiebreak metric: the second number your game reports with every score, so a late entrant who matches a score can still win it by outplaying it. The clock is last-resort determinism only, for the near-impossible identical pair. - Only players with at least one settled score are ranked. An entrant who bought an attempt and never played is not on the table, does not count toward the minimum field, and their attempt is refunded rather than counted toward the minimum scored attempts. ## Entering a round ### From the island The player arms a round, and your existing call enters it. A tournament card in the island's expanded panel carries the same slide control a Challenge card does. Sliding it arms that round. Your game then starts a round exactly the way it already does: ```javascript const attempt = await Challenge.startAttempt() ``` That call enters whatever the player armed, a Challenge or a tournament round. Everything after it is identical, `attemptId` included. A player has at most one armed thing, of one kind. Arming a tournament replaces an armed Challenge and the other way round, which is what makes the fee they agreed to the fee they are charged. ### Naming the round explicitly If your own code decides which round a player is in: ```javascript const attempt = await Challenge.startAttempt({ tournamentRound: roundId }) ``` Nothing needs to be armed on this path, because naming the round is the decision. Both forms end in the same entry, with the same guards and the same failures. ### From your server **Runs on your server.** The same endpoint a Challenge attempt uses, with a different parent. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/attempts \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Idempotency-Key: 5f1c9d4e-3b7a-4f61-8a9c-2d0e6b1f4c33" \ -H "Content-Type: application/json" \ -d '{ "tournament_round_id": "<>", "player_ref": "player_42" }' ``` Send **exactly one** of `challenge_id` and `tournament_round_id`. Both, or neither, is `invalid_request`: the two are different deals, and picking one for you would be picking what your player's money is spent on. Every attempt object names the one it belongs to, so a single feed always says which kind it was. Nothing is charged on any refusal: | Code | Meaning | |---|---| | `insufficient_funds` | The player cannot fund the entry fee | | `entries_closed` | The round is not open, or the cutoff has passed | | `attempt_cap_reached` | This player has used their ten attempts in the round | | `blocked` | This player may not play for money: their own responsible gaming decision, or a state Challenge does not serve | | `insider_entry` | The account behind this `player_ref` is linked to your developer account | | `not_found` | Unknown round, or one belonging to another program | `insider_entry` is the one that surprises people, so it is worth stating plainly: **an account linked to your developer account cannot enter your own live tournament.** That covers your developer login's own player account, anyone on your team, and your sandbox test accounts. Your game reports the scores that decide the ranks and the purse is Challenge's money, so an account on your side of that cannot also compete for it. Test against the sandbox twin with your test key, where entry is free and the whole round still runs. ## Reporting a score Identical to a Challenge. The same endpoint, the same header, the same body, and no flag anywhere that says which kind of attempt it was. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/attempts/<>/outcome \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Idempotency-Key: <>" \ -H "Content-Type: application/json" \ -d '{ "score": 41200 }' ``` A tournament attempt has no bar and no prize of its own, so the response comes back with `met_threshold: false`, `threshold_applied: null` and `award: null`. The score is recorded, the player's entry keeps it if it beats their previous best, and money moves at settlement by rank rather than here. A report that arrives after the round stopped accepting scores answers `round_closed`, and costs the player nothing: that attempt is expired and refunded in the first step of settlement. ## Settlement Fifteen minutes after a round closes, in a fixed order: 1. **Expire and refund unplayed attempts.** Any attempt still without a score is expired and its fee returned in full. Only players who actually played can be ranked. 2. **Cancel if the round cannot run.** Fewer scored attempts than the minimum, fewer scored players than the minimum, or a program that has been suspended, and the round is unwound instead of paid. Every attempt is refunded in full and every entry reports `payout_status: "refunded"`. 3. **Rank and pay the frozen table.** Three positions, the amounts the round opened with, cents allocated so the three sum to the purse exactly. Settlement is resumable. A crash mid-payout leaves the round settling and the next sweep re-runs it from the top, moving nothing twice. ### The midpoint, where a round can end early A round can also end at its halfway mark. If fewer than 24 scored attempts have been played by then, about a third of the way to the gate of 70, the round is cancelled there and every fee is refunded in full, exactly as it would have been at the close. It is the same cancellation, a day earlier. A round that thin at half time was not going to reach 70 in the quieter half of its window, and leaving it open parks everyone who did pay in something they cannot win and cannot leave. A player refunded on the first evening can enter the next round with the same money. Three things the check never does: it never runs on a round with no attempts gate, it never runs once the round has closed, since the close judges the round on the real gate, and it never runs twice, because a cancelled round is final. Rounds cancelled this way carry the reason `insufficient_attempts_midpoint` and an `early_cancel_at` field with the threshold they missed. An entrant with an open fraud alert or enforcement case has their payout marked `held`. Everyone else on the table is paid on time, and disposition is a human decision, never an automatic forfeit. ## Telling your side Your server hears settlement on two webhooks. Register the endpoint in the Developer Portal and verify the signature before trusting a payload. | Event | When | Carries | |---|---|---| | `tournament.settled` | The round paid | The round, the entrant count, the positions paid, and every ranked entrant with their rank, payout and payout status | | `tournament.cancelled` | The round was unwound, at its close or at its midpoint | The reason (`insufficient_attempts`, `insufficient_attempts_midpoint`, `insufficient_entrants` or `program_suspended`), both minimums, the scored attempts, the scored entrants, and how many attempts were refunded. A midpoint cancellation adds `early_cancel_at`, the threshold it missed, and `checked_at: "midpoint"` | One event covers both moments, so nothing new has to be handled: a round can simply disappear a day earlier than its close. `attempt.settled` fires for tournament attempts exactly as it does for Challenge attempts, and carries `tournament_round_id` so one feed can tell the two apart. Players are told on their own WebSocket channel, with their own rank, payout and payout status, so no relay of yours is involved. The island renders the round card, the field against the minimum, the countdown, the player's standing and the leaderboard itself, and on a settled tournament attempt it shows the score and the rank rather than a win or a miss, because the money is decided when the round closes. For a board of your own, `GET /v1/tournaments` returns each tournament with the round it is running, and `GET /v1/tournaments/rounds/{round_id}/leaderboard` returns the standings: live while the round is open, final once it has settled, ranked by `player_ref` so you can reconcile against your own side. Both are documented in the [API reference](https://www.withchallenge.com/docs/api.md). ### The board is enter to see Ranks and scores go only to a player who has paid for an attempt in that round. Before their first paid attempt a player reads everything the round promises, the purse, the three prize amounts, the entry fee, the scored field against its minimum and the time left, and none of what the field has scored. The leaderboard endpoint therefore takes an optional `player_ref`. Name the player you are rendering for and you get their board if they have entered. Without one, or for a player who has not entered this round, you get the masked shape: a `200` carrying `locked: true`, `reason: "enter_to_see"`, the round object and the round's promises, and no `data` array. It is not an error and does not need error handling; it is what the pre-entry surface is made of. Entering buys a neighborhood, not the whole table. While the round is live, the rows are that player's own row with one rank either side, and nothing else: not even the leaders, whose scores are the ones that read as a reason to stop. The rank above is always in view, so the next target is always concrete, and the paid positions surface on their own as a player climbs next to them. A player who has paid but not yet scored gets no rows until their first score lands. `scored_players` says how long the full table is, and the `rank` numbers say where the visible rows sit. The complete table arrives when the round settles. This applies to the island's own board as well, so the rule is the same wherever a player reads it. Ask for the board with a `player_ref` and render whichever of the two shapes comes back. Under a house-funded purse this is a solvency rule rather than a taste. What underwrites the prize is turnout, so a player who is put off entering does not shrink the purse, they raise the chance the round cancels and everybody is refunded. A visible board is a list of reasons not to enter. Everything that is a promise stays visible; what is behind the fee is the part that is a threat. `entrant_count` against `min_entrants`, and the round's scored attempts against `min_scored_attempts`, are the round's open questions, and both are worth surfacing in your game. "8 of 12 players" is a call to action in a way that "8 players" is not, and the attempts line is the one that decides whether the purse is paid. ## Related - [Challenges](https://www.withchallenge.com/docs/challenges.md): the other thing an attempt can be for - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): the tier that decides your entry fee allowlist - [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference, including every tournament payload # Install Challenge.js Source: https://www.withchallenge.com/docs/install.md Two script tags, one call, and the island is on your page. **Runs in the browser** Challenge.js is distributed as script tags only. There is no npm package, for the browser or for the server, and there is nothing to bundle. The whole browser half of the integration is the two tags below plus one `init()` call. ## The two script tags Put both in the page that runs your game, in this order. ```html ``` The first is synchronous and tiny. The second is the rest of the SDK and loads out of the way. ## What the stub does The stub is on the critical path of someone else's game, so it does exactly three things and nothing else. 1. **It defines ``** as a custom element and paints the dormant pill into its shadow DOM immediately. Your page never shows a hole where the money surface is about to be. 2. **It queues `init()`.** A call that lands before `island.js` has arrived is pushed onto `Challenge._queue` and replayed the moment the full bundle takes over, so `Challenge.init(...)` is safe to run from the first line of your boot code. 3. **It holds one early tap.** If a player taps the pill before the bundle is live, the stub dims it and remembers a single tap, which is replayed once there is something to open. One tap, never a list: replaying a burst would run the arm flow several times over. The stub makes no network request beyond its font, and it cannot know whether the player has anything armed, so it always paints the dormant costume and lets the bundle correct it. If you want the pill to land in a specific corner before `init()` has run, name it on the stub's own tag: ```html ``` The anchor has to be spelled out. An empty `data-anchor` mounts nothing. ## Initialise Once, wherever your game boots: ```javascript Challenge.init({ program: '<>' }) ``` `program` is the only required option. It is your program ID from the [Developer Portal](https://app.withchallenge.com), it is public, and it belongs in the browser. Your `sk_test_` and `sk_live_` keys do not: see [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). `init()` returns the `Challenge` object, so it chains. A call with no `program`, or with a non-string one, logs an error and mounts nothing. Until your program has a live Challenge, the island draws a small dormant pill reading **Coming soon**. It offers nothing and charges nothing, but your players do see it, so decide deliberately whether to ship this line before a live Challenge exists. ## The options you will actually set The full option table is on the [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md). These are the ones that come up in practice. | Option | Type | Default | What it does | |---|---|---|---| | `program` | string | required | Your program ID | | `anchor` | string | `'bottom-right'` | An overlay corner, 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 | | `draggable` | boolean | `false` | Lets the player move the island, and remembers where they put it | | `challenge` | string | `null` | Pin the island to one Challenge id instead of letting it pick from your catalog | | `apiBase` | string | script origin | Local development only | ### Placement Six names mount the island as a fixed overlay: `bottom-right`, `bottom-left`, `bottom-center`, `top-right`, `top-left`, `top-center`. Safe-area insets are handled, so the island clears a notch and a home indicator without you doing anything. Anything else is read as a CSS selector, and the island renders inline inside your own layout: ```javascript Challenge.init({ program: '<>', anchor: '#challenge-slot', }) ``` A selector that matches nothing falls back to the default overlay corner and says so loudly in the console. The island cannot simply not appear: while a player is armed it is the only thing on the page telling them money is at stake. Which edge is pinned is which way the island grows. A top anchor expands downward, a bottom anchor expands upward, so an expanded island at any of the five corners is fully on screen by construction. ### Theme One choice reaches every surface: the dormant pill, the armed costumes, the arming card and the account panel. An unrecognised value renders light rather than erroring, because a theme is not worth refusing to render over. `dormantTheme` is a deprecated alias of `theme`. It still works and logs one console warning per page; `theme` wins when both are given. ### Pinning one Challenge The option key is literally `challenge`, not `challenge`: ```javascript Challenge.init({ program: '<>', challenge: '<>', }) ``` Pinning is filtered against live Challenges only. If the id you named is not live, the island warns, falls back to the first Challenge that is, and keeps working. A partner with one Challenge in their game wants this; a partner with a catalog should leave it off and let the island offer the whole list. ### Options that are gone Passing any of `apiKey`, `gameId`, `entryFee`, `mode`, `matchmaking`, `settlement`, `playerRating`, `position`, `variant`, `dashboardUrl`, `challengeDomain`, `ssoEnabled` or `ssoTimeout` logs a warning and is ignored. They belong to the retired versus model. `apiKey` in particular must never appear in browser code at all. ## Single-page apps `init()` is safe to call on every route change, and that is the intended use. - **`init()` never calls `destroy()`.** That is a house rule, not an implementation detail. An implicit teardown inside `init` is how a re-render silently drops a player's state. - The `` element is **reused** when `anchor` and `draggable` have not changed, so navigating inside your app does not blink the island out and back. - The player's **armed session survives** a re-init, and so does a **paid attempt that is already running**, because a route change is not the end of a round. - **`destroy()` is explicit and total.** It tears down the element, every listener and timer, the state machine, the settlement socket and its poll, any queued `record()` calls, and the remembered session. Call it when the game leaves the page for good, not on every navigation. A complete React mount and unmount. Note that `destroy()` lives on the component that owns the game for the whole session, not on each route: ```jsx 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, anchor: 'bottom-right', theme: 'dark', }) return () => { // Only on real unmount. destroy() also forgets the player's armed // session, so calling it between routes would disarm them silently. window.Challenge.destroy() } }, [programId]) return children } ``` With the two script tags in `index.html`: ```html
``` If you write `` into your own markup rather than letting the overlay create one, an element that connects after `init()` has run is adopted and painted rather than replaced. ## Local and self-hosted sandboxes The API base is detected from the origin the script was loaded from, so a production page needs no configuration. Point it somewhere else only when the sandbox you are integrating against is a Challenge backend running somewhere other than `api.withchallenge.com`, such as a local one on port 4837. Both script tags come from that same origin, and `apiBase` names it as well: ```html ``` ```javascript Challenge.init({ program: '<>', apiBase: 'http://localhost:4837', }) ``` Load the scripts and set `apiBase` 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. Everything else on that backend is at the same base, including these docs: a self-hosted or local backend serves every page at `{your API base}/api/docs/.md`, for example `http://localhost:4837/api/docs/install.md`. The agent index sits beside them under the same `/api/docs` path, named `llms.txt`. Read `Challenge.version` in the console to confirm which bundle a page is running. ## Next - [Record rounds](https://www.withchallenge.com/docs/record-rounds.md) is the next line to add, and the one that ships before anything chargeable exists. - [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md) covers `startAttempt()` and branching on its error codes. - [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md) has every option, method and error code, plus the catalog elements. # Record rounds Source: https://www.withchallenge.com/docs/record-rounds.md One line at the end of every round your game already has, from the day you install Challenge.js. **Runs in the browser** ```javascript Challenge.record('moves', 34) ``` Paid or free. Signed in or not. Practice included. There is no condition under which you should skip it, and it charges nothing, blocks nothing and renders nothing. Ship this before you have a Challenge, before you have a server handler, before you have anything to charge for. It is the one part of the integration that gets better the earlier it lands. ## Why it goes first A Challenge's bar is measured, never chosen. A bar you picked would set the pass rate, which would set what Challenge expects to pay, so Challenge insists on measuring it from real rounds. The question is only where those rounds come from. Recorded rounds are the answer that costs nobody anything. Your game reports what an ordinary round looks like while nothing is at stake, and when you later create a Challenge on a metric that already has a sample behind it, the bar is computed and the Challenge is live **in the same request**. No player ever meets it sitting in calibration unable to pay anyone. A metric qualifies when it has **5,000 recorded rounds from at least 200 distinct player refs**. Below either target nothing bad happens: the Challenge is created `calibrating` and collects its sample the slower way instead. [Calibration](https://www.withchallenge.com/docs/calibration.md) covers all three paths to a bar. The Developer Portal reports listening progress per metric, and beside it the metric key each of your Challenges is waiting on. ## What it does not do Worth being exact about, because the name invites the wrong assumption. - Recorded rounds **do not advance a calibrating Challenge's own counter**. That counter is adjudicated attempts, and it is reported as `calibration.collected` on `GET /v1/challenges?status=all`. - Recorded rounds are **never merged into a later recalibration**. They place a bar once, at creation, and only at creation. A recalibration has to be able to remove one player's contribution whole, and a recorded round is keyed on a device ref that cannot always be attributed to an account. - Recorded rounds **do not feed the drift monitor**. Drift is judged on settled outcomes from paid attempts, which is the only population that says what a bar is actually costing. Keep the line in forever anyway. Every new metric you add, and every Challenge you create on one, is priced from the sample this call is building. ## The contract `record()` lives inside somebody else's game loop, which is the reason for every rule below. **It never rejects.** A listener that throws is a listener that ends someone's round. It resolves `{ recorded: true }` or `{ recorded: false }`, and never anything else. A bad argument warns at the developer, a dead network writes one `console.debug`, and both resolve `{ recorded: false }`. **It never floods.** At most one request per second per metric. Rapid calls are not dropped and not batched into one body: they queue in that metric's own lane and go out in order, a second apart, so a game that finishes ten rounds in a burst still reports ten. The clock starts when a request goes out, not when it comes back, so a slow response cannot make the next round wait twice. **It never waits on the island.** Calls made before `init()` has finished its bootstrap are held and flushed as soon as there is a program to file them under, including when the bootstrap itself failed. A program id is all a recorded round needs. ## Limits, published | Limit | Value | What happens at it | |---|---|---| | Throttle | 1 request per second per metric | Calls queue in order. Nothing is dropped | | Queue depth | 50 rounds per metric | Over it, the round is dropped and a `console.warn` names the metric | | Metric name length | 40 characters | Longer names are refused in the browser and resolve `{ recorded: false }` | | `player_ref` length | 64 characters | Enforced by the server | | Per device, per metric | 300 scores per rolling 24 hours | The call still answers success. The score is silently not stored | The 50-round queue is only reachable by a game reporting far faster than it plays, which is an integration bug rather than a very quick player. That is why the overflow is dropped and said out loud instead of queued into a drain that would run for minutes. The 300-per-day bound is a limit on junk from one device, not a security control. It answers success because a listener that can fail is a listener a partner eventually removes. At the 5,000-round target across 200 refs the average ref contributes 25 rounds, so the cap only binds on something automated. After `Challenge.destroy()`, anything still queued resolves `{ recorded: false }` rather than hanging. ## Metric names Pass **your game's own metric name**. Not the example's, not a generic one. It has to be the same string as the `metric_name` on the Challenge you eventually create, because that string is the key the sample is stored under. Names are trimmed and lowercased before they are sent, so `'Moves'`, `'moves '` and `'moves'` are one metric rather than three quarter-full ones. The server additionally collapses runs of internal whitespace, so `'combo length'` and `'combo length'` are also one. **A mismatched name fails silently.** There is no error for recording under a metric no Challenge uses. `record()` resolves `{ recorded: true }`, the server answers `202`, and the sample accumulates under a key nothing is reading. The listening progress you are building goes to the wrong metric, and the Challenge waiting on the right one never sees a round. Nothing in the response, the console or the network tab tells you. So verify it rather than trusting it. Fetch `GET /v1/challenges?status=all` with your server key, read the `metric_name` field off the Challenge, and compare it character for character, after trimming and lowercasing both sides, with the string your `record()` call passes. The Developer Portal shows the same key per Challenge if you would rather read it there. The name identifies *what is measured*, not what it is measured in. Two Challenges on one program can share a unit while measuring unrelated things, so the unit is never the key. ```javascript // Good: the game's own vocabulary, one metric per quantity. Challenge.record('moves', movesUsed) Challenge.record('solve time', secondsElapsed) // Wrong: a unit is not a metric. Two unrelated quantities would pool. Challenge.record('seconds', secondsElapsed) ``` The score must be a finite number. Anything else warns and resolves `{ recorded: false }`. ## In place ```javascript 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) } ``` If you want to confirm it is landing, `record()` resolves: ```javascript const { recorded } = await Challenge.record('moves', 34) console.log(recorded) // true ``` On the wire this is one `POST /api/island/record` carrying the program, the metric, the score and a per-device ref, answered `202` with `{ "recorded": true }`. No session is required. When a player happens to be signed in the round is stored as practice and carries their account; signed out it is stored anonymously. A client cannot name its own source, because a client that could would claim the stronger one. ## Next - [Calibration](https://www.withchallenge.com/docs/calibration.md) explains what the sample is turned into, and the two other ways a Challenge can get a bar if you do not want to wait. - [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md) is the other browser call, and the only one that costs a player anything. - [Challenges](https://www.withchallenge.com/docs/challenges.md) covers the status machine a new Challenge walks through. # Start a paid attempt Source: https://www.withchallenge.com/docs/start-attempt.md One call turns an ordinary round into a paid one, and it is the only browser call that costs a player anything. **Runs in the browser** ## The player arms first Before your code is involved at all, the player taps the island, sees the fee, the prize and the bar they would have to beat, and confirms. That confirmation is what arming means, and it is the whole of the transaction the player agreed to. Challenge renders every part of it. You never draw a fee, a balance, a prize, a bar or a verdict. Sign-up, the age check and a deposit, if the player needs one, all happen inside the same flow on the same surface. There is no API that arms on a player's behalf, on any key, and there will not be one. Agreeing to a price is the player's act, so it happens in front of numbers Challenge is quoting. Your job starts the moment a round begins after that. ## The call ```javascript const attempt = await Challenge.startAttempt() // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection } ``` Three things happen on the server before the promise resolves. - **The fee is charged.** Starting an attempt is the money event. - **The bar and its direction are copied onto the attempt.** A later recalibration cannot reach back and change what this player was playing for. - **The prize on offer right now is copied onto the attempt.** If the Challenge is inside its go-live ramp, that is the ramped prize, which is the number the player was shown. - **The attempt is given an expiry one hour out.** It resolves only once the money has actually moved. While the request is in flight the island dims and stays armed and idle, because showing "round live" for a charge that then failed would be the island lying about someone's balance. ### What it resolves with | Field | Type | Meaning | |---|---|---| | `attemptId` | string | The id your server reports the outcome against | | `feeCharged` | number | What this player actually paid, frozen | | `prizeOffered` | number | What this attempt pays on a win, frozen. `0` on a calibrating Challenge | | `threshold` | number or null | The bar this attempt is judged against. `null` when there is none | | `thresholdDirection` | string or null | `'higher_is_better'` or `'lower_is_better'` | These five are **this attempt's terms**. If you show the player a target during the round, read `attempt.threshold` and `attempt.thresholdDirection` and nothing else. Do not read the Challenge's current threshold from a list you fetched earlier: the two can differ, and only the frozen pair decides the outcome. ### In place ```javascript async function playPaidRound() { let attempt try { attempt = await Challenge.startAttempt() } catch (error) { handleStartFailure(error) return } const moves = await runTheRound({ target: attempt.threshold, direction: attempt.thresholdDirection, }) await fetch('/round-finished', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attemptId: attempt.attemptId, moves }), }) } ``` Send your server whatever it needs to derive the score. The point of that hop is that the number Challenge is paid against is worked out somewhere the player cannot edit. [Report the score from your server](https://www.withchallenge.com/docs/report-score.md) is the other half. ## Tournaments use the same call A tournament round is entered by naming it. Nothing is armed for one, by design: the entry **is** the attempt, so the round id is what says which deal this round is under. ```javascript const attempt = await Challenge.startAttempt({ tournamentRound: '<>' }) // { attemptId, feeCharged, prizeOffered: 0, threshold: null, thresholdDirection } ``` `prizeOffered` is `0` and `threshold` is `null`, and neither is a bug. A tournament attempt is not judged against a bar and pays nothing on its own: it buys a place on a rank table, and the purse is paid by rank when the round settles. `thresholdDirection` is the tournament's own direction, and it tells you which end of the table is winning. Everything after the call is identical, settlement included. A player who armed a tournament round on the island enters it with the plain `startAttempt()` they already shipped. The explicit form is for a game whose own server picks which round a player is in. See [Tournaments](https://www.withchallenge.com/docs/tournaments.md). ## Branching on failure A rejection is an `Error` with a `.code`. Branch on the code, never on the message: the wording is not part of the contract and gets reworded. The island has already told the player whatever they need to know, including a refusal, so most branches are a log line and a return. Your job is to not start the round. ```javascript 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. The island // shows their own words. Never work around this. 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': // A paid round is already open and has not settled. Do not start another. console.warn('Challenge: an attempt is already running') return case 'not_initialised': // init() has not run, or the island was destroyed while this was in // flight. Fix the mount order. 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. Nothing for your code to do. return case 'network': // The request never reached Challenge, after its own retries. Nothing // was charged. The player can tap and try again. return case 'client_reporting_removed': // Only ever thrown by the removed Challenge.reportScore(). Delete that // call: scores enter the money system from your server, on /v1. console.error('Challenge: scores are reported by your server, not the browser') return default: // 'unknown', and anything else the server may add later. console.warn('Challenge: attempt not started:', error.code) playFreeRound() } } ``` ### The code table | Code | Cause | What to do | |---|---|---| | `not_initialised` | `init()` has not run, or the island was destroyed or re-initialised while the call was in flight | Call `init()` before `startAttempt()`. Do not start attempts during teardown | | `not_armed` | Nothing is armed and no `tournamentRound` was named | Not an error. Run the round for free | | `attempt_in_progress` | A previous attempt has not settled yet | One at a time. Wait for settlement or the one-hour expiry | | `client_reporting_removed` | `Challenge.reportScore()` was called. It never comes from `startAttempt()` | Delete the call. Report from your server instead | | `network` | The request never reached the server, after its retries. Nothing was charged | Nothing. The player can retry by playing again | | `unauthorized` | The player's session is not valid | Nothing. The island handles sign-in | | `insufficient_funds` | The balance does not cover the fee. Nothing was charged | Nothing. The island offers a deposit | | `challenge_not_available` | The Challenge is suspended or closed, its prize is over your tier ceiling, or the server has nothing armed for this player | Do not run a paid round. The island resyncs itself | | `blocked` | A responsible gaming break or self-exclusion on the account, or a state where the player may not play this Challenge for money | Nothing, ever. Do not offer an alternative paid path | | `unknown` | Anything unrecognised | Log it and fall back to a free round | Server-side conflict codes pass through unchanged, so a few more can reach you on a Challenge that a player has a history with: `challenge_already_claimed` (a Challenge pays one prize per player, and that refusal is permanent for the pair), `rung_locked` (a ladder rung whose parent this player has not claimed), `attempt_cap_reached` (ten paid attempts on one Challenge in a day, or a tournament round's own cap), `eligibility_incomplete` (the account is missing something a paid round requires) and, on a tournament round, `entries_closed`. Treat any code you do not recognise the way you treat `unknown`. The list of codes only ever grows, and the `default` branch is what keeps a new one from breaking your game. ## If nothing is ever reported An attempt expires one hour after it was created. Challenge voids it, refunds the fee in full, tells the player's client and fires the `attempt.refunded` webhook with `reason: "expired"`. The fee buys one adjudication against a fixed bar, so an adjudication that never happened is a fee that was never earned. ## Next - [Report the score from your server](https://www.withchallenge.com/docs/report-score.md) is the request that settles the attempt you just started. - [Real-time settlement](https://www.withchallenge.com/docs/realtime.md) explains why nothing is relayed back to your game and you never draw the result. - [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) tells the whole sequence once, in order. # Report the score from your server Source: https://www.withchallenge.com/docs/report-score.md One HTTPS request, from a server you control, holding a key the browser never sees. **Runs on your server** This is the entire server half of the integration. There is no npm package and no SDK to install. It is one `POST`. ## The request ``` POST https://api.withchallenge.com/v1/attempts/{attempt_id}/outcome ``` | Header | Value | |---|---| | `X-API-Key` | `sk_test_...` in sandbox, `sk_live_...` in production. Server side only, always | | `Idempotency-Key` | Required. Use the attempt id | | `Content-Type` | `application/json` | ```json { "score": 34 } ``` `score` must be a finite number. An optional `evidence` object is accepted alongside it and is written as a receipt before any verdict is reached, so a receipt can never be back-dated to fit a disputed result. ### The attempt id is the idempotency key An attempt has exactly one outcome, forever, so the attempt id is the natural key and there is nothing to generate or store: ``` Idempotency-Key: 7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40 ``` That is not a convenience. It is what makes a blind retry the correct response to a timeout, rather than a risk. ### Measurement, never verdict Notice what the body does not accept: whether the player won. There is no `won` field, no `result`, no `passed`. You supply what was measured, Challenge applies the bar it froze onto the attempt when it took the fee, and decides. That is the shape of the whole API and not an oversight. A partner who could report a verdict could pay themselves. [The trust model](https://www.withchallenge.com/docs/trust-model.md) states the rule and what else follows from it. ## The response `200` with the settlement itself: ```json { "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" } } ``` | Field | Meaning | |---|---| | `met_threshold` | Challenge's determination against the frozen bar | | `threshold_applied` | The bar this attempt was judged against. `null` on a calibrating or tournament attempt | | `recorded_at` | When the outcome was written | | `receipt_id` | The receipt for the `evidence` you sent, or `null` | | `award` | The prize paid, or `null` | `award` is `null` when the bar was missed, and also when the frozen prize was zero, which is every calibrating attempt and every tournament attempt. Money in `/v1` is always a decimal **string**, never a float: `"9.00"`, not `9`. There is no step after this. Nothing is relayed back to your game, and your game is never asked to display a verdict. The player's island hears the settlement on its own and renders it. See [Real-time settlement](https://www.withchallenge.com/docs/realtime.md). ## Idempotent replay | Situation | Answer | |---|---| | Same key, same body, first call completed | The original result, with `Idempotency-Replayed: true`. The handler does not run again. Semantically identical, though JSON key order may differ, so never assert on the raw body | | Same key, same body, first call still in flight | `409` `idempotency_in_progress`. Retry shortly | | Same key, **different** body | `409` `idempotency_key_reused`. Use a new key for a new operation | | No `Idempotency-Key` header | `400` `invalid_request` | | A different key on an attempt that already settled | `409` `attempt_already_settled`. Treat as success | Idempotency records are retained for **24 hours**. Keys are scoped per endpoint, so the same string on a different route is a different record, and they may be at most 255 characters. Only `2xx` responses are stored for replay. Replaying a `500` would turn a transient failure into a permanent one, so a failed call can simply be retried under the same key. ### The other refusals | Code | HTTP | Cause | |---|---|---| | `invalid_request` | 400 | `score` is not a number, or the `Idempotency-Key` header is missing | | `unauthorized` | 401 | The key is wrong, revoked, or for the other environment | | `not_found` | 404 | No such attempt, or it belongs to another program | | `attempt_already_settled` | 409 | An outcome already exists for this attempt | | `round_closed` | 409 | A tournament attempt reported after its round closed. The attempt is refunded by the sweep, so the player pays nothing | A foreign attempt id answers `not_found` rather than a forbidden, because a forbidden would confirm the attempt exists. An attempt past its one-hour expiry is reaped and refunded on the spot and the call is refused, so a late report settles nothing and costs the player nothing. ## The server, in full **Where the handler runs decides two details below.** If your 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 your own server hosts the handler on the same origin as the game, the relative path `/round-finished` works and the CORS middleware is unnecessary: delete it. Both templates below are complete and runnable. Pick one. ### Supabase edge function The fastest server is one you do not run. Create `supabase/functions/round-finished/index.ts` with exactly this content: ```typescript // 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: ```bash # The key lives on your server. It never reaches the browser. supabase secrets set CHALLENGE_API_KEY=sk_test_... supabase functions deploy round-finished ``` ### Express If you already run a backend, create `server.js` with exactly this content: ```javascript // 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: ```bash # 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 posts to, unless the game is served from this same origin. ### curl, to check the endpoint by hand ```bash # The key lives on your server. It never reaches the browser. curl -i -X POST \ "https://api.withchallenge.com/v1/attempts/<>/outcome" \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Idempotency-Key: <>" \ -H "Content-Type: application/json" \ -d '{"score": 34}' ``` Run it twice. The second answer is the same settlement, semantically identical, and carries `Idempotency-Replayed: true`. Compare the parsed fields rather than the raw body: key order is not part of the contract. ## Hardening before production The templates above are the integration, not a sketch of one. Challenge bounds what a cheat can cost, but it does not yet catch a fabricated score for you, so the checks below are yours to make and the first two are not optional. What Challenge guarantees is a ceiling on the damage: your tier caps every prize (100 dollars at the entry tier), a player can win 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. The score you report is trusted, so make it one you can stand behind. **You do not need your own player login. Challenge owns the player.** The player authenticated to Challenge through the island, not to your game, so do NOT require a game session to settle and do NOT add a per-player "does this caller own this attempt" check. Making a player log in to your game to report a score defeats the point of Challenge. The `attemptId` belongs to the player who armed it — an opaque value only they ever hold — and Challenge resolves the player on its own side. What keeps a report honest is not a login. It is two things you already control. First, **only your SERVER holds the secret key**, so only your server can report an outcome: keep `sk_...` out of the browser and a client cannot forge one. Second, **the score you report is one you can stand behind** — the next two checks. The attack the old version of this section chased, 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 attempt id, which is never exposed. The score worth defending is a fabricated one on the caller's own attempt, and that is bounding and deriving, below. **Always: bound the number.** Reject a score that your game cannot produce: above the maximum a round can reach, below zero, or from a round shorter than a round can be. One line of arithmetic, and it is the check that turns an unbounded fabricated score into, at worst, a plausible one. A client that can post `{ "score": 1 }` on a lower-is-better Challenge is a client that has already won. **As your prizes grow: check the score against state only you hold.** The two checks above are the floor, not the ceiling. Challenge's caps make a single cheat survivable at the entry tier, but they do not detect one, so as the prize on the line grows, move toward reporting a number your server derived rather than received: the round length you observed, the moves you accepted, and, if your engine is deterministic, re-deriving the score outright from a seed you dealt. At the top tier this question leaves your hands entirely: tier A programs do not settle on partner-reported scores at all, only on independently verified sources. **Log what you can correlate on.** Log the `attempt_id`, the `Idempotency-Key` you used, the HTTP status, and the `receipt_id` from the response, on both the success and the failure path. Challenge does not currently return a per-request id header, so the attempt id is your correlation handle for a support conversation, and it is the id every one of Challenge's own records is keyed on. Never log the key itself. Two more habits worth having: - Treat `409 attempt_already_settled` as success. It means the outcome landed on an earlier try. - Retry on a timeout or a `5xx`, under the **same** key. That is what the key is for. ## Next - [Webhooks](https://www.withchallenge.com/docs/webhooks.md) is how your own systems hear about settlement, refunds and suspensions without polling. - [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md) is the second door, for games with no browser client at all. - [Go-live checklist](https://www.withchallenge.com/docs/go-live.md) is the list to walk before `sk_live_`. - [API reference](https://www.withchallenge.com/docs/api.md) has every `/v1` endpoint and the full error contract. # Server-created attempts Source: https://www.withchallenge.com/docs/server-attempts.md The second door into an attempt, for a game with no browser client at all. **Runs on your server** Read this page only if the answer to "where does the island render?" is "nowhere". A native mobile game with no web view, a console title, a headless simulation, a partner running their own storefront: those are the cases this exists for. If your game runs in a browser, use [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md) instead and skip this page. Almost every integration should. ## Two doors Both are open, and both charge the fee and freeze the bar and the prize at creation. | Door | Who authenticates | Use it when | |---|---|---| | `Challenge.startAttempt()` | The player's own session, in the browser | The player is in your game with the island in front of them. This is the usual case | | `POST /v1/attempts` | Your API key, with a `player_ref` | Your server drives the whole flow and there is no island | What you give up by taking the second door is everything the island renders. Challenge still runs accounts, verification, deposits, balances, responsible gaming limits and the ledger, but the surfaces that put those in front of a player live in Challenge.js. Without it, your player signs up, verifies, deposits and reads their balance in the [Player Portal](https://app.withchallenge.com) rather than inside your game, and you are the one who has to get them there. There is still no arming endpoint. Arming is the player agreeing to a price on a surface Challenge owns, and the server door does not create one: it charges a fee against an account the player already funded, on a Challenge your server named. ## Link the player first Every attempt resolves a `player_ref` through a link row, and nothing else creates one. Without this call your server cannot create an attempt for anyone. ```bash # 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" }' ``` ```json { "player_ref": "user_8134", "linked": true, "created_at": "2026-08-24T11:42:07.000Z" } ``` `player_ref` is **your own** identifier for the player, up to 64 characters. It is the only identity that ever crosses back to you: leaderboards and attempts are keyed on it, never on a Challenge user id and never on a Challenge username. **Challenge does not provision accounts from an API call.** A `player_ref` is a string you chose, and treating it as authority to open an account that holds money would mint accounts with no email, no age verification and no consent. The player signs up with Challenge themselves; you link the account they already have. | Answer | Meaning | |---|---| | `201` | Linked. Repeating the call with the same pair is safe and returns the same answer | | `404 not_found` | No Challenge account matches that email. Send the player to sign up, then call again | | `400 invalid_request` | `player_ref` or `email` missing | The `404` deliberately does not distinguish "no such account" from anything else. Confirming which emails have Challenge accounts would let any partner enumerate Challenge's users. No `Idempotency-Key` is required here. The link upserts on the pair, so repeating it is naturally safe. ## Create the attempt ```bash # 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": "<>", "player_ref": "user_8134" }' ``` | Field | Required | Notes | |---|---|---| | `challenge_id` | one of two | Playing a fixed bar for a fixed prize | | `tournament_round_id` | one of two | Buying a place on a round's rank table | | `player_ref` | yes | Must already be linked | | `metadata` | no | An object stored on the attempt and handed back unchanged | **Exactly one** of `challenge_id` and `tournament_round_id`. Sending both, or neither, answers `400 invalid_request`. They are not interchangeable: a Challenge attempt is judged against a fixed bar for a prize Challenge funds, and a tournament attempt buys a place on a rank table. A request naming both has not said which deal it wants, and picking one for you would be picking how your player's money is used. The same rule is enforced by a constraint on the attempts table, not only by the route. `Idempotency-Key` is **required**. This call moves money. ### The 201 ```json { "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` is ever non-null, and which one it is says what this attempt is playing for. Money is a decimal string. `outcome` fills in once you report a score. A replay under the same key returns the same `201` body with `Idempotency-Replayed: true`, charges nothing and, on a tournament round, consumes no cap slot. ### The refusals | Code | HTTP | Cause | |---|---|---| | `invalid_request` | 400 | Both parents named, neither named, `player_ref` missing, or no `Idempotency-Key` | | `unauthorized` | 401 | The key is wrong or for the other environment | | `insufficient_funds` | 402 | The player's balance does not cover the fee. Nothing was charged | | `not_found` | 404 | Unknown Challenge, a Challenge belonging to another program, or a `player_ref` that is not linked | | `challenge_not_available` | 409 | The Challenge is suspended or closed, or its prize exceeds your tier ceiling | | `challenge_already_claimed` | 409 | This player has already won this Challenge. Permanent for the pair | | `rung_locked` | 409 | A ladder rung whose parent this player has not claimed | | `attempt_cap_reached` | 409 | Ten paid attempts on this Challenge today, or the round's own cap | | `entries_closed` | 409 | The tournament round is closed or past its entry cutoff | | `idempotency_key_reused` | 409 | The same key with a different body | A Challenge belonging to another program answers `not_found` rather than a forbidden, because a forbidden would confirm it exists. You can avoid most of the `409`s by building your menu from `GET /v1/challenges?player_ref=...`, which returns exactly what this endpoint will accept for that player: Challenges they have already claimed are absent, and so are rungs they have not unlocked. ## Reporting the outcome is identical There is only one outcome endpoint and it does not care which door the attempt came through: ``` POST /v1/attempts/{attempt_id}/outcome ``` Same key, same `Idempotency-Key` convention, same response, same one-hour expiry. See [Report the score from your server](https://www.withchallenge.com/docs/report-score.md). ## This door also runs calibrating Challenges A calibrating Challenge has no bar, charges nothing and can pay nothing. The island will not arm one, because arming quotes a fee and a prize and a calibrating Challenge has neither to quote. `POST /v1/attempts` accepts one. The attempt is created with `fee_charged: "0.00"`, `threshold_applied: null` and `prize_offered: 0`, it settles as `recorded` rather than won or lost, and it contributes a score toward the Challenge's calibration sample. That is the money-free loop. In sandbox it lets you run the whole sequence, create, report, settle, fetch, with nothing to undo afterwards, and in production it is how the observed calibration path collects its 1,000 adjudicated attempts from 200 distinct players. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md) and [Calibration](https://www.withchallenge.com/docs/calibration.md). The one thing it cannot do is pay. `award` is `null` on every calibrating attempt, and `met_threshold` is `false`, because there was no bar to meet. ## Next - [Report the score from your server](https://www.withchallenge.com/docs/report-score.md) settles the attempt you just created. - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md) covers what a key may touch and what it never may. - [Tournaments](https://www.withchallenge.com/docs/tournaments.md) covers entering rounds through this same endpoint. - [API reference](https://www.withchallenge.com/docs/api.md) has every `/v1` endpoint in full. # Webhooks Source: https://www.withchallenge.com/docs/webhooks.md How your own systems hear that an attempt settled, a fee came back, or a Challenge was pulled, without polling for it. **Runs on your server** ## Get started 1. **Write a handler** at a public HTTPS URL that verifies the signature, answers `2xx` immediately, and does its real work afterwards. 2. **Register it** in the Developer Portal and copy the signing secret it gives you into your server's environment. 3. **Send a test event** from the portal and confirm your handler answers `200`. 4. **On `attempt.settled`, re-fetch.** Call `GET /v1/attempts/{attempt_id}` and trust that, rather than reconstructing state from the payload. Webhooks are optional. A settled attempt is already rendered to the player by Challenge.js on its own, and the outcome call you made returns the settlement synchronously. Add webhooks when something on your side needs to know: your own ledger, an analytics pipeline, a support tool, an email. The full list of events and their payloads lives on [Webhook events](https://www.withchallenge.com/docs/webhook-events.md). This page is about delivery. ## Create a handler The signature is computed over the **raw request body**, so your handler has to see the bytes before any JSON parser has touched them. In Express that means `express.raw`, not `express.json`, on this route. Create `webhooks.js` with exactly this content: ```javascript // The key lives on your server. It never reaches the browser. const express = require('express') const crypto = require('crypto') const app = express() app.post( '/webhooks/challenge', // RAW, not JSON. The signature is over the bytes Challenge sent. express.raw({ type: 'application/json' }), (req, res) => { const signature = req.header('X-Webhook-Signature') || '' const eventType = req.header('X-Webhook-Event') const deliveryId = req.header('X-Webhook-Delivery-Id') if (!verify(req.body, signature, process.env.CHALLENGE_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'bad_signature' }) } const payload = JSON.parse(req.body.toString('utf8')) // ANSWER FIRST. Anything slow here is a delivery that times out and // retries, which is how one slow email turns into three of them. res.status(200).json({ received: true }) // Then do the work, off the request. setImmediate(() => { handleEvent(eventType, deliveryId, payload).catch((err) => { console.error('challenge webhook failed', deliveryId, err) }) }) } ) function verify(rawBody, header, secret) { if (!header.startsWith('sha256=')) return false const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const given = header.slice('sha256='.length) const a = Buffer.from(expected, 'utf8') const b = Buffer.from(given, 'utf8') // Constant time, and length-checked first because timingSafeEqual throws on // a length mismatch rather than answering false. return a.length === b.length && crypto.timingSafeEqual(a, b) } const seen = new Set() async function handleEvent(eventType, deliveryId, payload) { // Deliveries retry, so the same delivery id can arrive more than once. // In production this belongs in your database, not in memory. if (seen.has(deliveryId)) return seen.add(deliveryId) if (eventType === 'attempt.settled') { // RE-FETCH, DO NOT RECONSTRUCT. This is the authoritative record. const fresh = await fetch( `https://api.withchallenge.com/v1/attempts/${payload.attempt_id}`, { headers: { 'X-API-Key': process.env.CHALLENGE_API_KEY } } ).then((r) => r.json()) await recordSettlement(fresh) } } app.listen(3000, () => console.log('Webhook handler on http://localhost:3000')) ``` Set both secrets in the environment and nowhere else: ```bash # The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_... export CHALLENGE_WEBHOOK_SECRET=whsec_... ``` ## Test your handler Two ways, in this order. **From the portal.** Open [app.withchallenge.com/developer/webhooks](https://app.withchallenge.com/developer/webhooks) and use the send control on the endpoint. It delivers a `webhook.test` event, signed exactly like a real one, and records the result in that endpoint's delivery log: ```json { "event": "webhook.test", "timestamp": "2026-08-24T12:00:00.000Z", "data": { "message": "This is a test webhook from Challenge" } } ``` Confirm three things: your handler answered `200`, the delivery log shows `delivered`, and your signature check actually ran. A handler that accepts everything passes this test too, so flip one character of the secret and confirm you get a `401`. **From a real sandbox round.** Run the [Quickstart](https://www.withchallenge.com/docs/quickstart.md) loop with your `sk_test_` key and watch `attempt.settled` arrive. Sandbox deliveries carry an extra `"test": true` in the payload, which is the flag to branch on if your handler writes to a production ledger. A local handler is not reachable from Challenge's servers. Put a tunnel in front of it while you develop, and register the tunnel's URL as a second endpoint. ## Register your endpoint In the Developer Portal, **Webhooks** in the developer sidebar, then **Add Endpoint**. | Field | Notes | |---|---| | URL | Public HTTPS. This is what receives the `POST` | | Events | Pick the ones you handle. An empty list means every event | | Game | Optional. Scope the endpoint to one program, or leave it off for all of them | | Description | Yours, for the list view | Each endpoint gets its own signing secret, shaped `whsec_` followed by 64 hex characters. Copy it into your environment when you create it. The row also carries the controls you will want later: an on/off toggle that stops delivery without deleting the endpoint, a rotate control that issues a new secret, a send control for test events, and a delivery log with the status, the HTTP code and the first part of your response body for every attempt. Rotating a secret takes effect immediately and there is no overlap window, so deploy the new secret first, or accept a short gap where deliveries fail their signature check and retry. ## Secure your endpoint Every delivery carries three headers: | Header | Value | |---|---| | `X-Webhook-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with that endpoint's secret | | `X-Webhook-Event` | The event type, for example `attempt.settled` | | `X-Webhook-Delivery-Id` | A UUID identifying this delivery. Constant across retries of the same delivery | Verify the signature before you read the payload, and compare in constant time. Your endpoint is public: without this check, anyone who learns the URL can tell you an attempt was won. ### Node ```javascript // The key lives on your server. It never reaches the browser. const crypto = require('crypto') function verifyChallengeSignature(rawBody, signatureHeader, secret) { if (typeof signatureHeader !== 'string' || !signatureHeader.startsWith('sha256=')) { return false } const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const given = signatureHeader.slice('sha256='.length) const a = Buffer.from(expected, 'utf8') const b = Buffer.from(given, 'utf8') return a.length === b.length && crypto.timingSafeEqual(a, b) } ``` ### Python ```python # The key lives on your server. It never reaches the browser. import hashlib import hmac import os def verify_challenge_signature(raw_body: bytes, signature_header: str) -> bool: secret = os.environ["CHALLENGE_WEBHOOK_SECRET"] if not signature_header or not signature_header.startswith("sha256="): return False expected = hmac.new( secret.encode("utf-8"), raw_body, hashlib.sha256 ).hexdigest() given = signature_header[len("sha256="):] return hmac.compare_digest(expected, given) ``` In Flask, `request.get_data()` gives you the raw bytes. In Django, `request.body`. In both cases read them before anything parses the JSON. ## Debug The delivery log on each endpoint records the HTTP status, the first 1,000 characters of your response body, and the error message when there was no response at all. Start there. | Symptom | What Challenge recorded | Likely cause | Fix | |---|---|---|---| | Connection failure | No status, an error message | DNS does not resolve, the port is closed, or the host is behind a firewall | Confirm the URL resolves and answers from the public internet, not just from your VPC | | TLS failure | No status, a TLS error message | Expired certificate, missing intermediate chain, or a hostname the certificate does not cover | Fix the chain. Challenge does not skip verification, in sandbox or in production | | Timeout | No status, an abort error | Your handler did the work before answering | Answer `2xx` inside 5 seconds and process afterwards | | `3xx` | The status at the end of the redirect chain | The endpoint moved | Redirects are followed, but register the final URL. A chain that ends on a non-2xx is a failure | | `4xx` | `400`, `401`, `403`, `404`, `422` | Signature check failing, route not mounted, a body parser rejecting the raw payload, or your own auth middleware in front of the route | A `401` from your own handler is usually the wrong secret or a JSON parser that reserialised the body before you signed over it | | `5xx` | `500`, `502`, `503` | Your handler threw, or the process is down | Catch inside the handler and still answer `2xx`. A crash costs you the delivery | Two specific traps worth naming: - **A JSON body parser upstream of your route.** Once `express.json()` has parsed and your framework has reserialised, key order and whitespace can differ and the HMAC will not match. Use the raw body. - **CSRF middleware.** A webhook is not a browser form post and carries no CSRF token. Exempt the route. ## Delivery behaviors Published, so you can size your own retry and alerting around them. | Behavior | Value | |---|---| | Method | `POST`, `Content-Type: application/json` | | Success | Any `2xx`. Everything else is a failure, including `4xx` | | Timeout | **5 seconds** from request to response | | Delivery attempts | **3 in total**, the first plus two retries | | Retry schedule | The next attempt is due **1 minute** after the first failure, then **5 minutes** after the second | | Retry sweep | Due retries are picked up every 30 seconds, so an attempt fires at or shortly after its due time | | After the third failure | The delivery is marked `failed` and never retried. The endpoint stays active | | Ordering | **Not guaranteed.** Do not rely on it | | Duplicates | Possible. The same `X-Webhook-Delivery-Id` can arrive more than once | Ordering is not guaranteed and the reason is structural: each delivery is dispatched independently, and a delivery that failed once is retried minutes after later deliveries have already landed. Two events about the same attempt can reach you in either order. This is exactly why the answer to `attempt.settled` is to re-fetch rather than to apply a diff. An endpoint is never disabled automatically for failing. If it is down for an hour, the deliveries in that hour are lost after their third attempt and there is no backfill, so treat repeated `failed` rows in the log as an alert on your side. Every event is offered to every active endpoint of yours that subscribes to it. An endpoint scoped to one program is offered only that program's events. ## Best practices **Dedupe by `X-Webhook-Delivery-Id`.** It is stable across retries of one delivery, so a table keyed on it, written inside the same transaction as your side effect, makes your handler idempotent. Nothing else in the payload is a safe dedupe key. **Answer `2xx` fast, work afterwards.** The budget is 5 seconds end to end. Anything that talks to a payment provider, sends an email or runs a report belongs on a queue, not in the request. **Subscribe to what you handle, not to everything.** An empty event list means every event, including ones you will ignore, which costs you deliveries to answer and log noise to read. Name the events you act on. **Exempt the route from CSRF protection, and from any global auth middleware.** The signature is the authentication. A framework-level guard in front of the route turns every delivery into a `403` that retries twice and then dies. **On `attempt.settled`, re-fetch rather than reconstruct.** Call `GET /v1/attempts/{attempt_id}` with your key and write what it returns. That call is the authoritative record of an attempt: it carries the frozen fee, the bar that was applied, the score, and the award if there was one. Rebuilding that state from a payload that may be out of order, duplicated, or one of several is how ledgers drift. **Never log the payload with the secret, and never log the secret.** Log the delivery id, the event type and the attempt id. **Verify in sandbox before you swap keys.** The signature scheme, the headers and the retry schedule are identical in both environments, so an endpoint that verifies correctly against `sk_test_` traffic will verify correctly against live traffic. ## Next - [Webhook events](https://www.withchallenge.com/docs/webhook-events.md) lists every event with its payload and trigger condition. - [Real-time settlement](https://www.withchallenge.com/docs/realtime.md) covers the browser side, and why a partner server should use webhooks rather than the player-facing socket. - [Errors](https://www.withchallenge.com/docs/errors.md) has the full catalog, including this page's debug table. - [Go-live checklist](https://www.withchallenge.com/docs/go-live.md) includes webhook verification. # Real-time settlement Source: https://www.withchallenge.com/docs/realtime.md How the result of a paid round reaches the player, and why almost no integration has to do anything about it. **Runs in the browser** ## Challenge.js already does this A paid round ends somewhere the player's device cannot see: on your server, or in Challenge's expiry sweep. So the island does not wait to be told by your game. It listens on its own, twice. The moment `startAttempt()` resolves, before the promise even returns to your code, Challenge.js starts two things: - A **WebSocket subscription** to `attempt:{attemptId}`, on a socket carrying the player's own token. - A **poll** of `GET /api/attempts/{attemptId}`, every **3 seconds**. Whichever answers first renders the win, the miss or the refund. The second answer is a no-op: settling clears the attempt being watched, so a duplicate broadcast, a poll already in flight and a re-delivered message after a reconnect all arrive to find nothing left to settle. The poll is also the reconnect. A dropped socket is not replaced on a backoff of its own; the next poll tick reopens it, which is bounded by construction because it can only happen while a round is live, at most once every three seconds, and cannot outlive the attempt. **So most integrations need nothing on this page.** You do not render a result, you are not told to render a result, and there is no callback to register. Your game hands the round to your server and moves on. The island draws what happened. If your server needs to hear about settlement, use [webhooks](https://www.withchallenge.com/docs/webhooks.md), not this socket. ## When you do care One case: your own UI needs to react to settlement. A lobby that should refresh, a "best today" counter, an animation you want to run at the same moment the island announces a win. That is what the rest of this page is for. It is the player-facing socket, in a browser where a Challenge player is signed in. ## Connecting ``` wss://api.withchallenge.com/ws?token= ``` The token authenticates the connection during the HTTP upgrade, because a browser cannot set a header on a WebSocket handshake. A missing or invalid token is refused with `401` before the socket opens. On connect the server subscribes you to your own channel and confirms: ```json { "type": "connected", "data": { "userId": "uuid", "role": "player" }, "ts": 1755691200000 } ``` The server pings every 30 seconds and terminates connections that stop answering. ## Channels | Channel | Subscription | Who may subscribe | |---|---|---| | `user:` | Automatic on connect | That user | | `attempt:` | Ask for it | The player who owns the attempt, and nobody else | | `admin:all` | Automatic for admins | Admin accounts | | `developer:` | Automatic for developers | That developer | An attempt has one player, so there is no second party with a reason to watch. Authorisation is scoped inside the query rather than checked after it, so a subscriber who guesses another player's attempt id is refused because the row does not come back. An unauthorised subscribe request is **ignored rather than answered**. There is no rejection message, nothing to wait for and nothing to retry, which is why Challenge.js runs the poll beside the socket instead of treating a subscription as confirmation. The last two rows are the Developer and Admin Portals, and are not part of a game integration. ### Client to server ```json { "type": "subscribe", "channels": ["attempt:"] } { "type": "unsubscribe", "channels": ["attempt:"] } { "type": "ping" } { "type": "auth.refresh", "token": "" } ``` `ping` answers `{ "type": "pong", "ts": … }`. `auth.refresh` answers `{ "type": "auth.refreshed", "ts": … }` and keeps the same connection, as long as the new token belongs to the same user. ## The message envelope Two shapes travel on this socket. Settlement is flatter than everything else, on purpose, so a client can read a result without knowing the general envelope: ```json { "channel": "attempt:7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "event": "attempt.settled", "attempt": { "id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "challenge_id": "a4e1c8b7-6f20-4d93-8c15-3b7e0a2f9d64", "tournament_round_id": null, "challenge": { "name": "Fewest moves", "metric_name": "moves", "metric_unit": "moves" }, "game": { "id": "e0b5c1d7-4a83-4f2b-9c60-1d8f7a3e5b24", "name": "Word Rush", "icon_url": null }, "status": "settled", "result": "won", "created_at": "2026-08-24T12:00:00.000Z", "settled_at": "2026-08-24T12:00:31.000Z", "fee_charged": 2, "threshold_applied": 36, "threshold_direction": "lower_is_better", "prize_offered": 9, "score": 34, "prize_won": 9, "paid_at": "2026-08-24T12:00:31.000Z" }, "ts": 1755691231000 } ``` Everything else uses the general envelope: ```json { "type": "balance.updated", "channel": "user:", "data": { }, "ts": 1755691200000 } ``` Every message carries a `ts` in milliseconds. `result` is one of `won`, `lost`, `recorded` (a free calibrating round, which had no bar to meet), `expired` or `voided`. The last two mean the fee has already been returned. The payload is exactly what `GET /api/attempts/{id}` returns, read back through the same shaper, so a client meets one spelling of an attempt whether it arrived over the socket or over HTTP. That is the **player-facing** shape, not the `/v1` contract: money is passed through as stored, so read it with `Number()` rather than assuming a decimal string. The event is fire and forget and is published only after the money is durable, so a socket that is down can never fail a payout that already happened. If you build your own listener, build it the way Challenge.js does: subscribe, poll beside it, settle once, from whichever source arrives first. ### Other events on `user:` | Event | Fires when | |---|---| | `balance.updated` | A deposit or withdrawal changed the balance. `{ balance, change, reason }` | | `verification.completed` | Identity verification was approved. Not emitted on decline | There is no live opponent score stream, because there are no opponents. A player attempts a fixed standard. ## This socket is player-facing. Your server should not use it. Stated plainly, because it is the mistake worth not making: - It authenticates with **the player's JWT**, not your API key. Your server does not hold one and should not be issued one. - It is scoped to **one player**. There is no channel that carries your program's traffic, and `attempt:` authorises to the owner only, so a server would have to hold every player's token to hear about every attempt. - It has **no delivery guarantee**. Events are fire and forget with no retry and no replay. A server that missed one has no way to ask for it again. The server-side equivalent is [webhooks](https://www.withchallenge.com/docs/webhooks.md): signed, retried, logged, and delivered to a URL you control. Use those for your ledger, your analytics and your alerts, and let the socket do the one job it is good at, which is telling the browser the player is sitting in front of. ## Next - [Webhooks](https://www.withchallenge.com/docs/webhooks.md) is the server-side way to hear about settlement. - [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md) is where the attempt id this channel is named after comes from. - [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) puts this step in the sequence around it. # Sandbox Source: https://www.withchallenge.com/docs/sandbox.md The whole integration, running end to end, against a Challenge that cannot charge and with a key nobody has to approve. Sandbox is not a second API and not a second integration to write. Same host, same paths, same payloads, same error codes, same webhooks. What differs is which key you send and what your Challenges are worth while you are testing. ## What a test key gives you Every program is created with a test key, integrity tier C and `approved: false`. The test key works from that moment. Sandbox keys skip the approval check deliberately, which is the whole point of a sandbox; an `sk_live_` key on an unapproved program answers 401. ```bash # The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_... ``` The key is on the program in the Developer Portal, behind a reveal control, and rotating it is self-serve. Where each value is allowed to appear, and why every key failure answers one indistinguishable 401, is in [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). A key resolves to exactly one program, so nothing else about your code changes between environments. You swap one environment variable. ### Sandbox is a key, not a copy Worth knowing before you plan around it. `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. Two consequences follow: - What you do with a test key is done to your real program. Seeding a distribution takes that Challenge live for everyone, and it cannot be undone by seeding again. - The fee is a fact about the Challenge, not about the key. A test-key attempt on a Challenge that carries a fee charges that fee. If you want somewhere permanently free to experiment after you are live, register a second program and leave its Challenges calibrating. ## A calibrating Challenge is free by database constraint This is the part worth understanding, because it is what makes a money-free loop possible without a simulator. A Challenge is created `calibrating`, with no threshold, a fee of 0 and a prize of 0. Free is not a convention here, it is a table constraint: ```sql CONSTRAINT challenges_calibrating_is_free CHECK (status <> 'calibrating' OR fee = 0) ``` A calibrating Challenge and a non-zero fee cannot coexist in the database. Its sibling constraint is the other half of the same idea: a Challenge cannot be `live` with a null threshold. Between them, a bar that has not been earned cannot charge, and a Challenge that charges cannot be unjudgeable. Attempt creation follows the same rule from the other side. A calibrating attempt freezes `prize_offered` at 0, and because the fee is zero the four paid-attempt guards are all skipped: the rung lock, the one-prize-per-player win lock, the ten-paid-attempts-a-day cap on a Challenge and any responsible gaming break on the account. One `player_ref` can run the loop as many times as your test needs. None of this is a sandbox mode. It is the model's own cold start, and it behaves identically in production. A calibrating Challenge is how a bar gets measured in the first place. See [Calibration](https://www.withchallenge.com/docs/calibration.md). ## The free loop runs through the server door **Runs on your server.** The island offers live Challenges only. The bootstrap sends live rows, and the state machine filters for `live` again before it draws a card, because a cached payload must never put an offer in your page that the server would refuse. A calibrating Challenge therefore cannot be armed, and `Challenge.startAttempt()` with nothing armed rejects with `not_armed` rather than starting a free attempt. The door that does accept a calibrating Challenge is `POST /v1/attempts`, which permits `live` and `calibrating` by name. So the money-free loop is the server-created attempt path, in three calls. ### 1. Link the player ```bash # 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" }' ``` ```json { "player_ref": "sandbox-player-1", "linked": true, "created_at": "2026-08-24T11:58:02.000Z" } ``` The email has to belong to a Challenge account that already exists. This endpoint links, it never creates: a `player_ref` is a string you chose, and treating it as authority to open an account that holds money would mint accounts with no email, no age check and no consent. An unmatched email answers `not_found`, worded so that nobody can enumerate who has an account. Sign up as a player once at [app.withchallenge.com](https://app.withchallenge.com) and use that account for your sandbox runs. ### 2. Create the attempt ```bash # The key lives on your server. It never reaches the browser. 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": "<>", "player_ref": "sandbox-player-1" }' ``` ```json { "id": "9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042", "challenge_id": "<>", "tournament_round_id": null, "player_ref": "sandbox-player-1", "status": "open", "fee_charged": "0.00", "created_at": "2026-08-24T11:59:12.000Z", "metadata": {}, "outcome": null } ``` `Idempotency-Key` is required, and a replay returns the same attempt with `Idempotency-Replayed: true` rather than creating a second one. Use a fresh key per attempt. ### 3. Report the outcome The same call your production server will make, with the same key header and the same idempotency rule. See [Report the score from your server](https://www.withchallenge.com/docs/report-score.md). ```bash # The key lives on your server. It never reaches the browser. curl -s -X POST \ https://api.withchallenge.com/v1/attempts/9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042/outcome \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042" \ -d '{ "score": 34 }' ``` ```json { "attempt_id": "9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042", "score": 34, "met_threshold": false, "threshold_applied": null, "recorded_at": "2026-08-24T12:00:00.000Z", "receipt_id": null, "award": null } ``` A calibrating attempt always settles `met_threshold: false` with `threshold_applied: null`. There was no bar to judge it against, and settling it cleanly as not met is deliberate: judging a score against a null would compare against `NaN` and return false by luck rather than by decision. The score still counts toward calibration. `attempt.settled` fires on your registered endpoint, signed exactly as it will be in production. That is the whole loop: your key, your idempotency keys, your handler, your signature verification, all exercised, with no money anywhere near it. ### Watch the calibration fill ```bash # The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/challenges?player_ref=sandbox-player-1&status=all" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` `status=all` is the partner view. Without it you get live Challenges only, and a calibrating one is invisible. Each calibrating row carries its progress instead of a threshold: ```json "calibration": { "collected": 412, "required": 1000, "playersCollected": 96, "playersRequired": 200 } ``` Both minimums are real and they are bought with different currencies: 1,000 adjudicated attempts and 200 distinct players. You are not going to fill either by hand today, which is what the next section is for. ## Getting a live Challenge in sandbox To exercise the island path, arming and `Challenge.startAttempt()`, the Challenge has to be live. Hand Challenge the distribution you already have and it goes live in the same request. **Runs on your server.** ```bash # The key lives on your server. It never reaches the browser. curl -s -X POST \ https://api.withchallenge.com/v1/challenges/<>/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 }' ``` ```json { "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 rise 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. A live Challenge is refused, because reseeding would move the bar under players mid-flight, and a suspended one is refused, because a new distribution is not a way to reset the breaker that pulled it. Seeding is one way, so seed the Challenge you meant to seed. Now reload your game. The island shows the Challenge, the player can arm it, and `Challenge.startAttempt()` resolves against a frozen threshold. See [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md). ```javascript const attempt = await Challenge.startAttempt() // { attemptId, feeCharged: 0, prizeOffered: 0, threshold: 57.5, thresholdDirection: 'higher_is_better' } ``` The fee and the prize come back as `0` because Challenge prices a Challenge and has not priced this one. Everything else is the shape you will ship: the arming gesture, the freeze, the reporting call, the settlement event, the island rendering the result on its own. With a real threshold frozen on the attempt, `met_threshold` can now come back `true`. The `award` stays `null`, because a zero prize pays nothing. An award object is the one part of the loop sandbox cannot show you today. ## Sandbox and production differences | | Sandbox (`sk_test_`) | Production (`sk_live_`) | |---|---|---| | Approval | None. The key works the moment the program exists. | Required. An unapproved program answers 401 on a live key. | | Host, paths, payloads, error codes | Identical | Identical | | Program and Challenge data | The same records. Sandbox is a key, not a copy. | The same records. | | Fee and prize | `0.00` on every Challenge, because none is priced yet | Set by Challenge, capped by your integrity tier | | Money movement | None. A zero fee takes nothing and a zero prize pays nothing. | Real balances, real payouts | | Awards | `award` is always `null`, even on a met threshold | An object with an `amount` when the bar is met | | Players | Real Challenge accounts, linked through `POST /v1/players` | The same | | Player portal | Out of reach for a sandbox test account. The island's Profile, History and Withdraw links are greyed out. | Live. The three links open the player's portal in a new tab. | | Paid-attempt guards | Skipped while the fee is 0: win lock, daily cap, rung lock, responsible gaming | All four apply | | Webhooks | Same endpoints, same `sha256` signature, same 3-attempt delivery with retries due at 1m and 5m, no test marker on the payload | The same | | Drift monitor | Runs on any live Challenge. The fairness band can suspend a sandbox Challenge after 30 settled outcomes; the solvency check cannot, since it needs a fee above zero. | Both checks apply | Two of those deserve a sentence more. **No test marker.** A sandbox delivery is byte-for-byte the shape of a production one, and webhook endpoints are registered per developer account rather than per environment. Your handler cannot tell the two apart from the payload, so do not write one that tries. **No money simulator.** Sandbox does not fake balances, deposits or payouts. It avoids money entirely, by running against Challenges that carry none. That is the honest version, and it is why the free loop is worth as much as it is: everything except the amount is real. ## What to check before you move on - One attempt created, reported and settled through the server door, with `attempt.settled` received and its signature verified. - One attempt started from the island against a seeded Challenge, with the result rendering itself in the browser you played in. - One attempt you deliberately never report. It expires an hour after it was created and the sweep picks it up within five minutes of that, refunding the fee and firing `attempt.refunded` with `reason: "expired"`. Handle that event before you are live, not after. - A branch for every rejection code `startAttempt()` can carry and every error code `/v1` can answer. See [Errors](https://www.withchallenge.com/docs/errors.md). - Duplicate webhook deliveries deduplicated by `X-Webhook-Delivery-Id`, which is stable across retries. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md). Then work through the [Go-live checklist](https://www.withchallenge.com/docs/go-live.md), which is the rest of it: approval, the key swap, your tier ceiling, the prize ramp and what to watch once real attempts are landing. ## Related - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): the two keys, where each may appear, approval, rotation and integrity tiers - [Calibration](https://www.withchallenge.com/docs/calibration.md): what the free attempts you just ran are for, and the three ways a bar gets placed - [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md): the same door in production, for games with no browser client - [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): everything that has to be true before the key changes # Go-live checklist Source: https://www.withchallenge.com/docs/go-live.md Everything that has to be true before you swap `sk_test_` for `sk_live_`, in the order it is worth checking. There is one mistake on this page that is worse than all the others, so it goes first: **a live key in a browser bundle**. Anyone who opens devtools can then create attempts, report outcomes and move your players' money in your name, and a key that has been in a client bundle is public forever. Rotation is the only remedy and it is a remedy after the fact. Everything else here is a thing you fix; this is a thing you survive. Going live is not a switch. It is approval, one environment variable, and a short list of behaviors your code has to already handle. ## The checklist - [ ] **Your program is approved.** Approval is a manual review of your program by Challenge, not a form that returns instantly. It is what makes an `sk_live_` key work at all: a live key presented for an unapproved program answers `401` with "This program is not approved for live use." Real players, real money and the live key arrive together, and the same review is what raises your integrity tier. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). - [ ] **The live key is in your server environment config, and nowhere else.** One variable changes. The host, the paths, the payloads, the headers and the error codes are the ones you already tested. Set `CHALLENGE_API_KEY` to the `sk_live_` value in your server's environment, your serverless function's secret store or your CI secret store, and read it as `process.env.CHALLENGE_API_KEY` or `Deno.env.get('CHALLENGE_API_KEY')`. This is the moment the catastrophic mistake happens: a live key pasted into the wrong file is a live key on the internet, so put it where the test key already was and change nothing else. - [ ] **No key appears in any client bundle, `NEXT_PUBLIC_` variable, or repository.** Grep the thing you are about to ship for `sk_live_` and for `sk_test_`. A key must never be in page JavaScript, a minified bundle, a mobile or desktop build, a `NEXT_PUBLIC_*` or `VITE_*` variable, a committed `.env`, a screenshot or a support ticket. Only the program ID goes in the browser, and it is public by design. If a live key has ever been in a client bundle, treat it as public and rotate it before you launch. - [ ] **You know your integrity tier ceiling.** Your tier caps what a single prize may be: **C is $100, B is $1,000, A is $10,000**. Every program starts at C, and a tier is raised by review, never at signup. The ceiling is checked when a prize is paid rather than only when a Challenge is priced, so a prize over it answers `challenge_not_available` and names the limit. The same ceiling is what stops a [Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md) climbing, since each rung doubles the prize below it. - [ ] **You know what the prize ramp will show your players.** A Challenge calibrated from observed play, and a rung priced from the players who claimed the Challenge below it (`calibration_source` of `observed` or `rung_derived`), pays **half its configured prize for the first 200 paid attempts** while the bar is confirmed against real money. Attempt 201 is the first at the full prize, and the change is automatic. Show the `prize` field exactly as the API returns it, or `prizeOffered` from `startAttempt()`. Never hardcode the number you saw in the portal: during a ramp the `ramp` object carries `{ active, fullPrize, attemptsSeen, attemptsRequired }`, and `fullPrize` is what it will become, not what to advertise now. - [ ] **You know whether your Challenge is provisionally capped.** A bar that came from a claim rather than from observed play is provisional: `calibration_source` of `listened` or `partner_seeded` may offer at most **20% of your tier ceiling** until real attempts confirm it. Provisional and ramped are mutually exclusive, so a Challenge is on one list or the other and never both. The cap lifts on its own when the drift monitor sees the realised pass rate land inside its band. See [Calibration](https://www.withchallenge.com/docs/calibration.md). - [ ] **Your webhook endpoint is registered and verifying signatures in production.** Register the production URL in the Developer Portal and select the events you consume. Every delivery carries `X-Webhook-Signature: sha256=`, computed over the **raw request body**, plus `X-Webhook-Event` and `X-Webhook-Delivery-Id`. Compute the HMAC over the bytes you received, before any JSON parsing or reserialisation, and compare in constant time. A handler that trusts an unverified payload is a handler anyone on the internet can settle attempts through. Answer `2xx` within 5 seconds; a failed delivery is attempted 3 times in total, the retries due 1 minute and then 5 minutes after each failure. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md). - [ ] **Duplicate deliveries are handled, and `attempt.settled` is re-fetched rather than reconstructed.** There is no ordering guarantee and no exactly-once guarantee. Dedupe on `X-Webhook-Delivery-Id`, which is stable across every retry of the same delivery, and make your handler idempotent on top of that. When `attempt.settled` arrives, call `GET /v1/attempts/{id}` and trust that, rather than rebuilding your own copy of the settlement from the payload fields. The fetched attempt is the truth; the event is only the news that there is something to fetch. - [ ] **The expiry refund path is exercised.** An attempt with no outcome expires **one hour** after it was created, and the sweep that reaps it runs every five minutes. The fee is refunded in full and `attempt.refunded` fires with `reason: "expired"`. Reporting a score after that point answers `attempt_expired`. This is the branch that only fires when your server is already having a bad day, so run it in sandbox on purpose before you need it: start an attempt, report nothing, and watch the event arrive. - [ ] **Every failure has a branch.** In the browser, a rejected `startAttempt()` carries a `.code`: `not_armed`, `insufficient_funds`, `attempt_in_progress`, `challenge_not_available`, `blocked`, `not_initialised`, `unauthorized`, `client_reporting_removed`, `network`, `unknown`. The island has already told the player whatever they need to know, so your job is not to draw a message, it is to not continue the round. On the server, every `/v1` error is `{ "error": { "code", "message" } }`, and the codes you branch on are the codes, never the message text. See [Errors](https://www.withchallenge.com/docs/errors.md). - [ ] **Your logs keep the identifiers that let you reconstruct one attempt.** Log the attempt id on every call you make about it, the `Idempotency-Key` you sent, the `receipt_id` the outcome returned when you sent evidence with it, and the `X-Webhook-Delivery-Id` of every delivery you accepted or rejected. Challenge does not return a request id header today, so the attempt id is the join key between your logs, your webhook deliveries and anything support has to look up on our side. A settlement dispute that arrives a week later is answerable with those four values and not answerable without them. ## After you are live Calibration does not stop when a Challenge goes live, because populations do not stop moving. Every live Challenge is checked against its last 500 settled outcomes, with no judgement made below 30 of them, on two separate questions: whether the realised pass rate is inside the band around its target, and whether the expected payout has overtaken the fee. Fail either and the Challenge is set to `suspended` immediately. New attempts answer `challenge_not_available`, and `challenge.suspended` fires with the realised rate, the target and the reason. Attempts already open keep the bar frozen at their creation, so nothing changes retroactively. The response is suspension rather than an alert because an alert needs someone awake and a circuit breaker does not. Pass both and an unconfirmed bar is promoted to `observed`, which is what lifts a provisional ceiling with no request to make. Three places to watch it from: - **`challenge.suspended` and `challenge.calibrated`**, if you want to know without looking. Subscribe to both; the first is the one that stops your revenue, the second is the one that starts it. - **`GET /v1/challenges?player_ref=...&status=all`**, which is the partner view. The default list is player-facing and shows live Challenges only, so a suspended one simply vanishes from it. `status=all` is how you see the status, the `calibration_source` and the ramp position. - **The Developer Portal**, which shows each Challenge's status, threshold and calibration progress on the program. If a Challenge of yours is suspended, do not try to seed a new distribution over it. Seeding is refused on anything that is not `calibrating`, deliberately, because a new bar is not a way to reset the breaker that pulled it. Reinstatement is a review. ## Related - [Sandbox](https://www.withchallenge.com/docs/sandbox.md): the money-free loop, and what does and does not differ between the two environments - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): approval, integrity tiers, rotation, and where each value may appear - [Calibration](https://www.withchallenge.com/docs/calibration.md): the provisional ceiling, the ramp and the drift monitor in full - [Webhooks](https://www.withchallenge.com/docs/webhooks.md): handler, registration, signature verification, retries and debugging - [Errors](https://www.withchallenge.com/docs/errors.md): every code with its cause and its fix # Challenge.js Source: https://www.withchallenge.com/docs/challenge-js.md Every option, method, error code and custom element in the browser half of the integration. **Runs in the browser.** The guide is [Install Challenge.js](https://www.withchallenge.com/docs/install.md). Challenge.js mounts the island, runs the account, age-check, deposit and arming flows, starts paid rounds, and renders every result a player sees. It does not report scores. A finished round goes to your own server, and that server reports the measurement to Challenge with your secret key. See [Report the score from your server](https://www.withchallenge.com/docs/report-score.md). ## The two script tags ```html ``` | File | Loading | What it does | |---|---|---| | `island-stub.js` | Synchronous, about 2 KB | Defines the `` custom element and paints the dormant pill into its shadow DOM immediately. Installs a shim `Challenge.init` that queues configs, and a shim `Challenge.destroy` that empties the queue. Captures one early tap and dims the pill until the bundle can answer it. | | `island.js` | Asynchronous | The island proper: state machine, bootstrap, arming, the panel, the catalog elements, the round hooks. It replaces `window.Challenge` and replays whatever the stub queued. | | `island-full.js` | Fetched on demand | The deep flows: sign-up, email verification, identity and age verification, deposits, withdrawals, support. Loaded from the same directory as `island.js`, so keep the files together if you mirror them. | Both script tags define `window.Challenge`, so `Challenge.init()` is safe to call in the same tick as the tags. The stub only ever queues one tap, never a list: replaying a burst of impatient taps would run the arming flow several times over. To paint the island before your own script runs at all, name the corner on the stub tag itself: ```html ``` The anchor has to be spelled out. An empty `data-anchor` mounts nothing. ## Configuration ```javascript Challenge.init({ program: '<>' }) ``` `init()` returns the `Challenge` object, so it chains. It is safe to call on every route change of a single-page app: the element is reused when the placement has not changed, the player's armed session survives, and an attempt already in flight is carried across — a route change **mid-round keeps the live pill live** and the score still lands on it, so you never need to guard `init()` against a round in progress. It **never** calls `destroy()` for you. A call with no `program`, or a non-string one, writes one `console.error` and does nothing else. The island does not mount. ### Options | Option | Type | Default | Meaning | |---|---|---|---| | `program` | string | required | Your program ID from the Developer Portal. Public, and meant to be in your client. | | `anchor` | string | `'bottom-right'` | `bottom-right`, `bottom-left`, `bottom-center`, `top-right`, `top-left` and `top-center` float the island over the page. Any other string is treated as a CSS selector and the island renders inline inside the first element that matches. | | `size` | string | `'regular'` | `'compact'` or `'regular'`. Any other value reads as `'regular'`. | | `theme` | string | `'light'` | `'light'` or `'dark'`. Reaches every surface: the dormant pill, all four armed costumes, the arming card and the account panel. An unrecognised value falls back to light rather than throwing. | | `dormantTheme` | string | none | Deprecated alias of `theme`. Still honoured. `theme` wins when both are given. | | `draggable` | boolean | `false` | Lets the player drag the island. Overlay anchors only; an inline mount ignores it. | | `challenge` | string | none | A Challenge id. Pins the island to that one Challenge instead of letting the player pick from the catalog. | | `dormantCopy` | string | `'play-for-money'` | The dormant line, chosen from a built-in list. Not free text. | | `apiBase` | string | detected | Local development only. Otherwise the API base is read from the script origin. | Anything not in this table is ignored. #### `anchor` Which edge is pinned is which way the card grows. A top anchor grows down into the room below it, a bottom anchor grows up. Combined with the card's viewport ceiling, an expanded island at any of the five anchors is fully on screen by construction. An anchor that looks like an overlay name but is not one of the five (anything starting `top-` or `bottom-`) falls back to `bottom-right` with a console warning. A selector that matches no element in the document also falls back to the default overlay, loudly: an armed island is the only thing telling a player money is at stake, so it is never allowed to simply not appear. An inline anchor may point at a `` element you wrote yourself, at a container holding one, or at any element at all, in which case the island appends its own. #### `theme` One choice, every surface and every state. The armed costumes used to be exempt, on the reading that they are what money at stake looks like rather than a preference. **They are themed now.** The old behaviour made the armed pill a black lozenge in the middle of a light page, and it meant one island changed theme underneath a player the moment they armed something. A theme is a placement decision about the page, and arming does not move the island to a different page. `dormantTheme` was honest while the theme only reached the dormant pill. It now governs every expanded surface too, so the name became a lie rather than a synonym. The old name still works, because a partner with a live game should not get a white card because Challenge renamed something. Passing it writes **one** console warning for the life of the page, not one per `init()`, since `init()` runs on every route change of a single-page app. #### `challenge` **The key is literally `challenge`, and it takes a Challenge id.** The name predates the naming law that made the product unit a Challenge, and the option is part of a shipped public surface, so it was not renamed. Everywhere else, including the value you pass here, the unit is a Challenge. ```javascript Challenge.init({ program: '<>', challenge: '<>' }) ``` A non-string value is read as no pin at all. #### `dormantCopy` The dormant line is a money claim, so it is picked from an approved list rather than typed. | Key | Renders | |---|---| | `play-for-money` | Play for money | | `challenge-mode` | Challenge mode | | `mark-only` | Nothing. The squiggle alone. | An unrecognised key falls back to the default rather than rendering an empty pill. ### Options that are accepted and ignored These are from the retired versus-era widget. They are ignored rather than rejected, because a partner upgrading a live game should not have their integration throw for still passing an `entryFee`. Passing any of them writes one console warning naming exactly which ones were dropped. `apiKey`, `gameId`, `entryFee`, `mode`, `matchmaking`, `settlement`, `playerRating`, `position`, `variant`, `dashboardUrl`, `challengeDomain`, `ssoEnabled`, `ssoTimeout`. `apiKey` is the one worth calling out. There is no configuration in which a secret key belongs in a browser. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). ### What the island shows The island reflects the state of your program, with no code change on your side as that state moves. | Program state | What renders | |---|---| | Nothing heard from the game yet | Nothing at all | | Rounds recorded, no Challenge live | The coming-soon island, where a player can ask to be told | | A Challenge live | The dormant pill, the arming card, and the panel behind it | | Something armed | The armed island, and the live costume during a paid round | A bootstrap that fails is a dormant island, not a broken one: the partner's page keeps working and nothing claims money is at stake. ## Methods ### `Challenge.init(config)` Mounts the island. Documented above. Returns `Challenge`. ### `Challenge.record(metric, score)` Report one ordinary round. Call it at the end of every round your game already has: paid or free, armed or not, signed in or not, practice included. ```javascript Challenge.record('moves', 34) ``` Returns a `Promise` that resolves `{ recorded: true }` or `{ recorded: false }`. Every rule below follows from the fact that this call lives inside a game loop. - **It never rejects.** A listener that throws is a listener that ends someone's round. A bad metric or a non-finite score writes one `console.warn` and resolves `{ recorded: false }`. A dead network writes one `console.debug` and does the same. - **One request per second per metric.** Rapid calls are not dropped and not batched into one body: they queue in that metric's own lane and go out in order, a second apart, so a game that finishes ten rounds in a burst still reports ten. The clock starts when a request goes out, not when it comes back, so a slow server cannot make the next round wait twice. - **At most 50 unsent rounds per metric.** Past that, the newest calls resolve `{ recorded: false }` with a console warning. Reaching it means the game is reporting far faster than it plays, which is an integration bug rather than a very quick player. - **Calls made before the bootstrap are held**, not lost, and flushed the moment there is a program to report them against. That holds even if the bootstrap failed: a program id is all a recorded round needs. - **Metric names are trimmed and lowercased**, so `Combo Length`, `combo length` and `combo length` are one metric. They may be 1 to 40 characters. The string you pass here and the `metric_name` on the Challenge must be the same string. - **After `destroy()`**, queued rounds resolve `{ recorded: false }` rather than hanging, and further calls are not sent. These rounds are what a Challenge's bar is measured from, which is why this line ships before anything chargeable. See [Record rounds](https://www.withchallenge.com/docs/record-rounds.md) and [Calibration](https://www.withchallenge.com/docs/calibration.md). ### `Challenge.startAttempt(options?)` Start a paid round and charge for it. ```javascript const attempt = await Challenge.startAttempt() ``` The island is told nothing until the server has charged the fee. During the round trip it dims and stays armed and idle, because "attempt live" is a claim that money is at stake and showing it for a request that then fails would be the island lying about a balance. The dim only appears if the request takes longer than 600 ms. Resolves with four facts plus the id, frozen onto this attempt at its creation: | Field | Type | Meaning | |---|---|---| | `attemptId` | string | The id your server reports the score against | | `feeCharged` | number | What the player actually paid | | `prizeOffered` | number | The prize frozen onto this attempt. `0` on a calibrating Challenge and on a tournament round. | | `threshold` | number or null | The bar frozen onto this attempt. Null on a calibrating Challenge and on a tournament round. | | `thresholdDirection` | string or null | `higher_is_better` or `lower_is_better`, in the Challenge's own units | Something must be armed first. There is no `arm()` for a game to call on a player's behalf: agreeing to a price is the player's act, and the island is the one place it happens. **One call covers tournaments too.** A player arms a Challenge by sliding its card in the panel and arms a tournament round by sliding its card the same way, so a game that already offers Challenges offers tournaments without changing a line. A player has at most one armed thing at a time, of one kind; arming either replaces the other. If your own server decides which round a player is in, name it instead: ```javascript const attempt = await Challenge.startAttempt({ tournamentRound: '<>' }) ``` | Option | Type | Meaning | |---|---|---| | `tournamentRound` | string | An open tournament round id. Nothing needs to be armed on this path, because naming the round is the decision. | With nothing named, the request carries no parent id at all and the armed session is read on the backend. That is deliberate: sending the armed id from the browser would let the two disagree about what was armed at the moment the fee moved. #### Rejections Rejections are an `Error` with `name: 'ChallengeAttemptError'` and a `.code`. Branch on the code, never on the wording. | Code | Cause | |---|---| | `not_initialised` | `startAttempt()` ran before `init()`, or the island was destroyed while the request was in flight | | `not_armed` | Nothing is armed for this player, neither a Challenge nor a tournament round, and no `tournamentRound` was named | | `attempt_in_progress` | A round is already live. It ends when your server reports its score. | | `client_reporting_removed` | Only from `reportScore()`, which is removed. Never from `startAttempt()`. | | `insufficient_funds` | The balance does not cover the fee. Nothing was charged, and the island offers a deposit. | | `challenge_not_available` | Suspended, closed, or a prize over your integrity tier ceiling. Also what the server answers when it and the island disagree about what is armed. | | `blocked` | A responsible gaming decision the player made about themselves. Shown to them verbatim. | | `network` | The request never reached the server, after its retries | | `unauthorized` | The player's session is not valid | | `unknown` | Anything the server refused with a code this list does not name. Read `.message`. | Server-side conflicts pass through with the server's own code, so `challenge_already_claimed`, `rung_locked`, `attempt_cap_reached` and `entries_closed` can all arrive here too. See [Errors](https://www.withchallenge.com/docs/errors.md) for the full catalog. The island shows the player whatever they need to see, refusals included, so most integrations only log the error and let the round not start. ### `Challenge.reportScore()` **Removed.** It writes a `console.error` and throws an `Error` with code `client_reporting_removed`. No money rides on a score the player's own device reported. Your server reports the measurement to `POST /v1/attempts/{id}/outcome` with your secret key, and the island renders the result as soon as Challenge settles it. A stale bundle that still calls the old browser endpoint gets `410` with the same code and no side effects, so an old cache and a current one fail the same way. It throws rather than resolving quietly because a game that keeps calling this is a game whose rounds are not being settled at all, and the fastest way to that discovery is a loud one. ### `Challenge.disarm()` Stop playing for money. Returns a `Promise` resolving to `Challenge`. The server is asked first and the island only changes once it has agreed. An island that went dormant while the session was still armed would be the one lie this surface must never tell. If the server call fails, one `console.warn` is written and nothing changes. Between rounds it takes effect immediately and closes the settlement socket and its poll. During a paid round the machine queues it to the next round boundary and leaves the socket alone, because that round is paid for and its result is still owed. ### `Challenge.destroy()` Tear everything down. Returns `Challenge`. Used by single-page apps on unmount. What goes: - The full layer, if a deposit or sign-up screen is open over the game. - The catalog controller. Your `` and `` elements stay exactly where you wrote them and go empty. - The listen queue. Rounds still waiting resolve `{ recorded: false }` rather than hanging. - Every timer: the press timer, the attempt-wait timer, the tally rotation. - The document-level escape and outside-click listeners, the slide controllers and the drag-to-scroll controllers. - Every animation on the pill, every pin, and the spring group's `requestAnimationFrame` loop. A frame loop still running on a page with no island is a permanent wakeup in someone else's tab. - The settlement WebSocket and its poll timer, unconditionally. A live round has no claim on a page with no island to show it on. - The state machine, the keydown listener and the mount. An overlay island Challenge created is removed from the DOM; an element you wrote yourself stays and its shadow root is emptied. - The remembered session and the dragged position. What survives on purpose: the anonymous device ref, so a re-mount does not look like a new device, and the per-program quiet flag, so a game with nothing to offer does not flash a loading pill on the way back. `init()` never calls `destroy()`. That is a house rule and it stays: an implicit destroy inside init is how a re-render silently drops a player's state. ### `Challenge.version` The string `'0.9.0'`. ## Custom elements Three elements. All of them are registered by the code, not by you, and none of them takes a render call. ### `` Defined by the stub, so it exists before the bundle arrives. Two attributes, both set by Challenge rather than by you: | Attribute | Set by | Meaning | |---|---|---| | `overlay` | The stub or `createMount` | This element is Challenge furniture, floating over the page, and `destroy()` may remove it | | `anchor` | `applyAnchor` | Which of the five corners is currently pinned | Write it into your own markup when you want the island inside your layout, and point `anchor` at it or at its container: ```html
``` ```javascript Challenge.init({ program: '<>', anchor: '.hud' }) ``` The island adopts an element it finds rather than creating a second one, including one that connects later in a single-page app. There is only ever one island on a page: two would be two answers to "what am I being charged". ### `` and `` The catalog, in your own layout. The elements are the API, which is why there is no `renderCatalog()` and no options object: you already have a language for saying where things go, and it is HTML. ```html ``` | Attribute | Element | Meaning | |---|---|---| | `challenge-id` | `` | Which Challenge this card shows. The attribute name predates the naming law, exactly as the `challenge` init option does; the value is a Challenge id. | | `theme` | both | `theme="dark"`, light by default. | `theme` here is **per element and deliberately not inherited** from `Challenge.init({ theme })`. That theme is about the island, which floats over your game canvas. These elements sit in your own layout, which may be a light Challenges page belonging to a dark game, so the two are separate knobs and a partner who wants them to match says so twice. Both elements observe their attributes, so changing one repaints that element. Lifecycle: - They are registered by `init()`, not by the stub. A card has nothing honest to draw until the bootstrap has landed, so it costs the critical path nothing to wait. - Registration is idempotent, because `init()` runs on every route change. - With no island behind them they render **empty**: no placeholder, no "unavailable" card. What an island-less page has to say about Challenges is nothing at all. - After `destroy()` they go empty again and stay in your markup. A card still quoting a fee with nothing behind it to honour would be an offer nobody can take. - Arming anywhere, whether on the island, a panel row or another card, repaints every card on the page in the same tick. - A `challenge-id` that this program does not have renders nothing and warns once. Falling back to some other Challenge would put a price in front of a player for a contest you never meant to offer. Tapping a card runs the same arming flow through the island, with the same fee confirmation, so there is still exactly one place a player agrees to a price. ## What is not API Anything on `window.Challenge` beginning with an underscore is internal. It is not versioned, not documented beyond this paragraph, and may change or disappear in any release: `_island`, `_adopt`, `_queue`, `_hydrate`, `_pendingTap`, `_debugSend`, `_debugFull`, `_debugState`. The three `_debug*` members are the state driver behind `?debug=1` on Challenge's own test page. They are inert unless the page asks for them, and they only forward events the state machine already validates, so they cannot fake a costume that money is not behind. ## What Challenge.js handles for you Account creation and sign-in, email verification, age and identity verification, deposits, balances and withdrawals, responsible gaming limits, the arming flow and its fee confirmation, the account panel, the leaderboard, and every result screen. None of it needs a callback from your game, and none of it is something to rebuild. See [Real-time settlement](https://www.withchallenge.com/docs/realtime.md) for how a result reaches the island without your game relaying it. ## Related - [Install Challenge.js](https://www.withchallenge.com/docs/install.md): the guide this page is the reference for - [Record rounds](https://www.withchallenge.com/docs/record-rounds.md): what `record()` is for - [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md): the arming and `startAttempt()` walkthrough - [Errors](https://www.withchallenge.com/docs/errors.md): the full code catalog - [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference your server calls # API Source: https://www.withchallenge.com/docs/api.md Every endpoint under `/v1`: authentication, money, idempotency, the error envelope, and the complete request and response for each route. **Runs on your server.** The guide is [Report the score from your server](https://www.withchallenge.com/docs/report-score.md). ## The contract ### Base URL ``` https://api.withchallenge.com/v1 ``` Local development is `http://localhost:4837/v1`. There is no separate sandbox host: the key decides the environment. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md). Anything under `/v1` is stable. Fields are added, never removed or retyped, without a new version. Routes under `/api` carry no such promise; they belong to Challenge's own portals and the island, and they are free to change. ### Authentication Every request carries an `X-API-Key` header. `sk_test_...` is sandbox, `sk_live_...` is production. ```bash # The key lives on your server. It never reaches the browser. curl https://api.withchallenge.com/v1/challenges?player_ref=player_42 \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` The key resolves to exactly one program, so `program` is not a parameter on any route. That is what removes the class of tenancy bug where a request reads across partners: there is no "which of this developer's programs did they mean" question to answer wrong. **Every key failure is one 401 with the code `unauthorized`.** A missing header, a malformed key, an unknown key, a test key used where a live one was expected, a live key on a program that is not yet approved, a suspended or closed program, and an account scheduled for deletion all answer identically. Distinguishing them would tell an attacker which half of a guess was right. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). ### Money is a decimal string Every amount in `/v1` is a string: `"25.00"`, never `25.0`. Binary floats cannot represent cents exactly, and a partner parsing `25.00` as `24.999999` would be a bug Challenge owned. Parse them with a decimal type, not with `parseFloat`. Scores and thresholds are JSON numbers, because they are measurements rather than money. Webhook payloads are the one exception in the other direction: their amounts are JSON numbers. See [Webhook events](https://www.withchallenge.com/docs/webhook-events.md). ### The error envelope ```json { "error": { "code": "challenge_already_claimed", "message": "This player has already claimed this challenge.", "details": {} } } ``` `code` is a closed, versioned set. **Branch on the code, never on the message.** Wording is not part of the contract and may change at any time. `details` is present only on some validation failures. The full catalog, with a cause and a fix for each code, is [Errors](https://www.withchallenge.com/docs/errors.md). ### Idempotency `Idempotency-Key` is **required** on every endpoint that moves money: `POST /v1/attempts`, `POST /v1/attempts/{id}/outcome` and `POST /v1/challenges/{id}/distribution`. Use a fresh value per logical operation, typically a UUID, up to 255 characters. | Situation | Answer | |---|---| | Header missing | `400` `invalid_request` | | Header longer than 255 characters | `400` `invalid_request` | | First use | The handler runs. The response is stored. | | Replay, same key and same body | The original result, with `Idempotency-Replayed: true`. No handler code runs, nothing is charged again. Semantically identical, though JSON key order may differ, so never assert on the raw body. | | Same key, different body | `409` `idempotency_key_reused` | | Same key, original still in flight | `409` `idempotency_in_progress`. Retry shortly. | | Same key, original died mid-flight over 60 seconds ago | The retry takes over and runs | Records are retained for **24 hours**, and the same key on a different endpoint is a different record. Only `2xx` responses are stored: replaying a `500` would turn a transient failure into a permanent one. This sits on top of the ledger's own idempotency. The ledger guarantees a repeated key moves no money, which protects the balance but not the caller: a retried request would still re-run the side effects around the transfer and hand back a different body. Every webhook retries, timeouts included, so "charged once but told twice" is a real state your code has to survive. ### Cross-program resources answer `not_found` A challenge, attempt, round or integration that belongs to another program answers `404` `not_found`, not `403`. A 403 would confirm the resource exists. Tenancy is applied inside the query rather than checked after it, so there is no path where the wrong row is read and then rejected. ### Rate limits `/v1` allows **300 requests per minute per API key**. Over the limit the answer is `429` with `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. The 429 body is the limiter's own shape rather than the error envelope: ```json { "error": "Too many requests, please try again later.", "retry_after": 37, "limit": 300, "reset_at": "2026-08-24T18:22:41.006Z" } ``` ### The retired client-reporting route `POST /api/attempts/{id}/outcome` was how a browser used to report a score. It is mounted, bare, and answers `410`: ```json { "error": "Scores are reported by the game's own server through the partner API at /v1, not from the browser.", "code": "client_reporting_removed" } ``` `410` rather than `404` because the resource is not missing, and rather than `403` because the caller is not unwelcome: it is retired. It carries no auth and no idempotency middleware, so a stale bundle in someone's cache costs nothing and gets an explanation instead of what it would read as an outage. `Challenge.reportScore()` throws the same code in the browser. See [The trust model](https://www.withchallenge.com/docs/trust-model.md). --- ## Challenges The guide is [Challenges](https://www.withchallenge.com/docs/challenges.md). ### GET /v1/challenges The Challenges this player may attempt right now. Safe to render straight into a menu: everything returned is something `POST /v1/attempts` will accept for that player. | Parameter | In | Required | Meaning | |---|---|---|---| | `player_ref` | query | yes | Your own identifier for this player, up to 255 characters | | `status` | query | no | Omit for the player-facing list, which is `live` Challenges only. Pass `all` for your own view, which returns every status and reports calibration progress. | ```bash # The key lives on your server. It never reaches the browser. curl "https://api.withchallenge.com/v1/challenges?player_ref=player_42" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "data": [ { "id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "name": "Fewest Moves", "description": "Solve the board in as few moves as you can.", "fee": "1.00", "prize": "4.50", "currency": "USD", "status": "live", "prize_structure": "threshold", "threshold": 27, "min_calibration_n": 1000, "calibration_source": "observed", "metric_name": "moves", "metric_unit": "moves", "rung": 1, "parent_challenge_id": null, "ramp": { "active": true, "fullPrize": "9.00", "attemptsSeen": 132, "attemptsRequired": 200 } } ] } ``` | Field | Type | Meaning | |---|---|---| | `id` | uuid | The value you send as `challenge_id` | | `name` | string | What the player reads | | `description` | string or null | Optional longer line | | `fee` | decimal string | What one paid attempt costs. `"0.00"` while calibrating. | | `prize` | decimal string | The prize **on offer to this player right now**, not the prize configured | | `currency` | string | `USD` | | `status` | string | `calibrating`, `live`, `suspended` or `closed` | | `prize_structure` | string | `threshold` or `top_n` | | `threshold` | number or null | The bar. Null while calibrating. | | `min_calibration_n` | number | The attempt floor this Challenge calibrates against | | `calibration_source` | string | `observed`, `listened`, `partner_seeded` or `rung_derived` | | `metric_name` | string | The metric, in your game's vocabulary | | `metric_unit` | string | What it is measured in | | `rung` | number | How far up a ladder this sits. `1` for an ordinary Challenge. | | `parent_challenge_id` | uuid or null | The Challenge that must be claimed to unlock this one | | `ramp` | object | Present only while the go-live ramp is still capping the prize | | `calibration` | object | Present only when `status` is `calibrating` | The `ramp` object carries `active` (always `true` when present), `fullPrize` as a decimal string, and `attemptsSeen` against `attemptsRequired`. The `calibration` object carries `collected` against `required` and `playersCollected` against `playersRequired`. See [Calibration](https://www.withchallenge.com/docs/calibration.md). Three things are deliberately absent from the list: - **Challenges this player has already claimed.** A Challenge pays one prize per player for life, so a claimed one cannot be attempted again and listing it would make you carry that rule. - **Locked rungs.** A rung whose parent this player has not claimed is not attemptable yet. The island lists them, marked locked, because a locked rung is what a ladder sells; your own page is not Challenge's to put a "you cannot have this" card on. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). - **Anything the player is ineligible for**, by jurisdiction, age, verification level or self-exclusion. An unknown `player_ref` is not an error. Someone who has never played has claimed nothing, which claims none and locks every rung, and both of those are the true answer. `calibration_players`, `min_calibration_players` and `ramp_completed_at` exist on the row and are stripped from the response. The first two are reported inside the `calibration` object instead; the third is internal bookkeeping that the `ramp` object says better. **Errors:** `400` `invalid_request` when `player_ref` is missing, `401` `unauthorized`. ### POST /v1/challenges/{id}/distribution Skip the cold start by supplying percentiles from your own history. Requires `Idempotency-Key`. You send percentiles rather than rows: that is all a threshold needs, it carries no personal data, and it comes out of one SQL query on your side. | Field | Type | Required | Meaning | |---|---|---|---| | `distribution.p5` | number | yes | The 5th percentile of the metric, in the Challenge's own units | | `distribution.p25` | number | yes | The 25th percentile | | `distribution.p50` | number | yes | The median | | `distribution.p75` | number | yes | The 75th percentile | | `distribution.p95` | number | yes | The 95th percentile | | `sample_size` | integer | yes | How many rounds those percentiles came from. At least **500**. | Percentiles must rise from `p5` to `p95`. Percentiles that do not ascend are not percentiles, and accepting them would interpolate nonsense. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/challenges/<>/distribution \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f2b1c47-6a0e-4f39-9d21-3b7c5e08a412" \ -d '{ "distribution": { "p5": 61, "p25": 48, "p50": 41, "p75": 35, "p95": 27 }, "sample_size": 12400 }' ``` ```json { "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "threshold": 28, "target_pass_rate": 0.1, "sample_size": 12400, "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." } ``` `201`. The Challenge is live immediately, at a prize capped to **20% of your tier ceiling** until the drift monitor confirms the claim against real paid attempts. Supplying the distribution means setting the threshold, which sets the pass rate, which sets Challenge's expected cost per attempt, so the cap is what makes an unverified claim safe to act on. **Errors:** `400` `invalid_request` for a bad or non-ascending distribution, a `sample_size` under 500, a Challenge that is not `calibrating` (a live one would move the bar under players mid-flight; a suspended one is not reinstated this way), a Challenge that is a rung of a ladder (a rung's bar is measured from the players who claimed the rung below it and cannot be supplied), and for a challenge id this program does not own. `401` `unauthorized`. `409` on idempotency conflicts. --- ## Players The guide is [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md). ### POST /v1/players Link one of your player identifiers to an existing Challenge account. Without this, nothing can create the row that `player_ref` resolves through, so no server-created attempt can be made. | Field | Type | Required | Meaning | |---|---|---|---| | `player_ref` | string | yes | Your own identifier for this player | | `email` | string | yes | The email on the player's existing Challenge account | ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/players \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "player_ref": "player_42", "email": "player@example.com" }' ``` ```json { "player_ref": "player_42", "linked": true, "created_at": "2026-08-24T17:04:11.882Z" } ``` `201`. No `Idempotency-Key`: the link upserts on the program and the ref, so repeating the call is naturally safe and returns the same answer. Idempotency belongs where repetition is unsafe, not on every write. There is no auto-provisioning on first attempt, deliberately. A `player_ref` is a string you chose, and treating it as authority to create a Challenge account would mint accounts that hold money with no email, no age check and no consent. The player signs up with Challenge; you link the account they already have. **Errors:** `400` `invalid_request` when either field is missing. `404` `not_found` when no Challenge account matches the email, phrased as a plain absence rather than "no such user": confirming which emails have accounts would let any partner enumerate Challenge's players. `401` `unauthorized`. --- ## Attempts The guides are [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md) and [Report the score from your server](https://www.withchallenge.com/docs/report-score.md). ### POST /v1/attempts Create an attempt and charge the fee in the same transaction. Requires `Idempotency-Key`. | Field | Type | Required | Meaning | |---|---|---|---| | `challenge_id` | uuid | one of two | Play a Challenge for a fixed prize Challenge funds | | `tournament_round_id` | uuid | one of two | Buy a place in that round's rank table | | `player_ref` | string | yes | Your own identifier for this player, up to 255 characters | | `metadata` | object | no | Opaque to Challenge, stored and returned on reads of the attempt | **Send exactly one parent.** Sending both, or neither, is `400` `invalid_request`. The two are not interchangeable: one is judged against a frozen bar for a fixed prize, the other buys a place in a round that pays by rank. A request naming both has not said which deal it wants, and picking one would be picking how a player's money is used. The same rule is enforced by a check constraint on the table. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/attempts \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 2c8e4d10-77a1-4d0f-b1c3-9e5a6f2b0d84" \ -d '{ "challenge_id": "<>", "player_ref": "player_42", "metadata": { "session": "s_9931" } }' ``` ```json { "id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "tournament_round_id": null, "player_ref": "player_42", "status": "open", "fee_charged": "1.00", "created_at": "2026-08-24T17:09:52.310Z", "metadata": { "session": "s_9931" }, "outcome": null } ``` Entering a tournament round is the same call with the other parent: ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/attempts \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b1f9c33-05de-4a72-8f44-c02e17b95a6d" \ -d '{ "tournament_round_id": "<>", "player_ref": "player_42" }' ``` ```json { "id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4", "challenge_id": null, "tournament_round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "player_ref": "player_42", "status": "open", "fee_charged": "1.00", "created_at": "2026-08-24T17:11:03.774Z", "metadata": {}, "outcome": null } ``` `201` in both cases. **Exactly one of `challenge_id` and `tournament_round_id` is ever non-null**, on this response and on every later read of the attempt, and which one it is says whether the attempt is playing for a fixed prize or for a rank. A replay returns the original body with `Idempotency-Replayed: true`. It charges nothing further, and on a tournament round it consumes no second slot against the round's attempt cap. **Errors:** | Status | Code | Cause | |---|---|---| | `400` | `invalid_request` | Both parents, neither parent, no `player_ref`, or a missing `Idempotency-Key` | | `401` | `unauthorized` | Any key failure | | `402` | `insufficient_funds` | The player cannot fund the fee. Nothing was charged. | | `404` | `not_found` | No such challenge in this program, or no such round | | `409` | `challenge_not_available` | Suspended, closed, or a prize over your integrity tier ceiling | | `409` | `challenge_already_claimed` | This player has been paid this Challenge. Permanent for the pair. | | `409` | `rung_locked` | A rung whose parent this player has not claimed. Clears when they claim it. | | `409` | `attempt_cap_reached` | The Challenge's rolling daily cap, or the round's per-player cap | | `409` | `entries_closed` | The round is not open, or it is inside the ten minutes before it closes when re-buys stop | | `409` | `idempotency_key_reused` / `idempotency_in_progress` | See the idempotency table above | No `409` charges anything. ### POST /v1/attempts/{attempt_id}/outcome Report what the player achieved. Requires `Idempotency-Key`. | Field | Type | Required | Meaning | |---|---|---|---| | `score` | number | yes | The measured result, in the Challenge's own units | | `tiebreak` | number | on tournament attempts | The tournament's declared tiebreak metric for this attempt, e.g. completion time for a move-count game. Required when the attempt belongs to a tournament round whose tournament declares one; the report is refused with `tiebreak_required` (422) without it, and the attempt stays open for a correct report. Ignored on Challenge attempts. | | `evidence` | object | no | Whatever proves the score: a replay trace, a signed device payload, a CI run id. Stored verbatim and replayable in a dispute. | Note what this endpoint does **not** accept: whether the player won. That is Challenge's determination, applied against the threshold frozen onto the attempt at its creation. You supply a measurement; Challenge applies the standard. It is also why generous reporting cannot inflate payouts. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/attempts/<>/outcome \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: b7d3f2a9-4e61-4c08-a5f7-31d0b96e28c1" \ -d '{ "score": 24, "evidence": { "replay": "r_7731", "engine": "1.4.2" } }' ``` ```json { "attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "score": 24, "met_threshold": true, "threshold_applied": 27, "recorded_at": "2026-08-24T17:14:36.201Z", "receipt_id": "d81c6a45-3f97-4be2-8a05-72e1c4b09f36", "award": { "id": "9a4e17c0-6d5b-4832-b7f1-08c3e5d2a641", "amount": "4.50", "payout_status": "paid", "paid_at": "2026-08-24T17:14:36.318Z" } } ``` | Field | Type | Meaning | |---|---|---| | `attempt_id` | uuid | The attempt this settles | | `score` | number | What you reported, echoed | | `met_threshold` | boolean | Challenge's determination, not the reporter's | | `threshold_applied` | number or null | The bar in force when this attempt started. A later recalibration never changes whether a past attempt won. | | `recorded_at` | timestamp | When the outcome was written | | `receipt_id` | uuid | Signed record of the reported evidence, replayable in a dispute | | `award` | object or null | Present only when the player cleared the bar. Carries `id`, `amount` as a decimal string, `payout_status`, and `paid_at`. | | `award.payout_status` | string | `paid` when the money moved, or `held` when the win is recorded but withheld pending a review (a fraud or enforcement flag on the player, or a payout-velocity trip). A held award has `paid_at: null` and moved no money; the player cleared the bar (`met_threshold` is still true), and a reviewer releases or forfeits it. Reconcile paid winnings on `payout_status: "paid"`, never on the presence of `award`. | **A tournament attempt settles through this same endpoint and gets a different shape**, because there is no bar and no per-attempt prize on a rank table: ```json { "attempt_id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4", "score": 24, "met_threshold": false, "threshold_applied": null, "recorded_at": "2026-08-24T17:15:02.884Z", "receipt_id": "5f0b8d21-97ac-4e63-b118-2d4a7c60e839", "award": null } ``` `met_threshold: false` here is not a loss. It is the honest answer to a question that does not apply: the score has been entered on the round's board, and the round pays by rank at its close. `award` stays null because a tournament pays through settlement, not through the outcome. `receipt_id` is written exactly as it is on a Challenge. **Errors:** | Status | Code | Cause | |---|---|---| | `400` | `invalid_request` | `score` is not a finite number, or `Idempotency-Key` is missing | | `401` | `unauthorized` | Any key failure | | `404` | `not_found` | No such attempt, or it belongs to another program | | `409` | `attempt_already_settled` | This attempt already has an outcome | | `409` | `round_closed` | The round stopped accepting scores. The attempt expires and its entry is refunded by the settlement sweep, so a late report costs the player nothing. | | `422` | `score_out_of_range` | The score lies outside the plausibility window the Challenge declares. Nothing settles and no fee is charged back; the attempt is left to expire and refund. This fires only on a Challenge that has declared a bound, and it is a refusal of a malformed measurement, not a held win. | On `attempt.settled`, re-fetch rather than reconstruct: call `GET /v1/attempts/{id}` and trust that. ### GET /v1/attempts/{attempt_id} Read one attempt and its outcome. The authoritative answer for what an attempt did. ```bash # The key lives on your server. It never reaches the browser. curl https://api.withchallenge.com/v1/attempts/<> \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "tournament_round_id": null, "player_ref": "player_42", "status": "settled", "fee_charged": "1.00", "created_at": "2026-08-24T17:09:52.310Z", "metadata": { "session": "s_9931" }, "outcome": { "attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "score": 24, "met_threshold": true, "threshold_applied": 27, "recorded_at": "2026-08-24T17:14:36.201Z", "receipt_id": "d81c6a45-3f97-4be2-8a05-72e1c4b09f36", "award": { "id": "9a4e17c0-6d5b-4832-b7f1-08c3e5d2a641", "amount": "4.50", "paid_at": "2026-08-24T17:14:36.318Z" } } } ``` | Field | Type | Meaning | |---|---|---| | `status` | string | `open`, `settled`, `expired` or `voided` | | `fee_charged` | decimal string | What this attempt actually cost, frozen when it was paid for | | `metadata` | object | Whatever you sent at creation. `{}` when you sent none. | | `outcome` | object or null | Null while the attempt is still open | An attempt with no outcome after an hour expires and the fee is refunded, which is `status: "expired"` here and an `attempt.refunded` webhook. **Errors:** `401` `unauthorized`, `404` `not_found` for an unknown attempt or one belonging to another program. --- ## Tournaments The guide is [Tournaments](https://www.withchallenge.com/docs/tournaments.md). ### GET /v1/tournaments This program's tournaments, each with the round it is running. | Parameter | In | Required | Meaning | |---|---|---|---| | `status` | query | no | Omit for the player-facing list, which is `active` tournaments only. Pass `all` for your own view, which includes paused and closed ones. | **There is no `player_ref` here, and the absence is deliberate.** A Challenge list is filtered per player because eligibility decides what may be offered. A tournament round is one public board, identical for everyone, so asking for a player identifier to return a list that does not depend on it would be collecting an identifier Challenge has no use for. ```bash # The key lives on your server. It never reaches the browser. curl https://api.withchallenge.com/v1/tournaments \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "data": [ { "id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46", "name": "Daily Sprint", "description": "Fastest clear of the day.", "metric_name": "clear time", "metric_unit": "seconds", "threshold_direction": "lower_is_better", "entry_fee": "1.00", "attempt_cap": 10, "status": "active", "created_at": "2026-07-02T09:00:00.000Z", "current_round": { "id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46", "tournament_name": "Daily Sprint", "status": "open", "opens_at": "2026-08-24T00:00:00.000Z", "closes_at": "2026-08-25T00:00:00.000Z", "entries_close_at": "2026-08-24T23:50:00.000Z", "settled_at": null, "entry_fee": "1.00", "funding": "house", "purse_amount": "100.00", "purse_table": [ { "rank": 1, "amount": "51.00" }, { "rank": 2, "amount": "31.00" }, { "rank": 3, "amount": "18.00" } ], "pool_amount": "0.00", "rollover_in": "0.00", "entrant_count": 37, "attempt_cap": 10, "min_entrants": 24 } } ] } ``` The tournament object: | Field | Type | Meaning | |---|---|---| | `id` | uuid | The tournament | | `name` | string | What the player reads | | `description` | string or null | Optional longer line | | `metric_name` | string | What is measured, in your game's vocabulary | | `metric_unit` | string | What it is measured in | | `threshold_direction` | string | `higher_is_better` or `lower_is_better`. Which end of the scale wins the round. | | `entry_fee` | decimal string | Charged per attempt, flat. An entry and a re-buy cost the same. | | `attempt_cap` | integer | Paid attempts one player may buy in one round | | `status` | string | `active`, `paused` or `closed` | | `created_at` | timestamp | When the tournament was created | | `current_round` | object or null | See below | **`current_round` semantics.** It is the **open** round when there is one, and otherwise the **most recent** round there has been, so a tournament between rounds still renders with the board its last round finished on. It is `null` only before the first round has ever spawned. Read `current_round.id` to enter, and check `current_round.status` first: only an `open` round accepts entries. The round object, which appears here and on the leaderboard response: | Field | Type | Meaning | |---|---|---| | `id` | uuid | The value you send as `tournament_round_id` | | `tournament_id` | uuid | Its parent | | `tournament_name` | string or null | Denormalised, so a round renders without a second lookup | | `status` | string | `open`, `settling`, `settled` or `cancelled` | | `opens_at` | timestamp | When entries began | | `closes_at` | timestamp | Scores are accepted until this moment | | `entries_close_at` | timestamp | When re-buys stop, ten minutes before `closes_at` | | `settled_at` | timestamp or null | When standings became final | | `entry_fee` | decimal string | Frozen at spawn. Editing the tournament never changes a running round. | | `funding` | string | Always `"house"` | | `purse_amount` | decimal string | The fixed purse this round pays, frozen at spawn | | `purse_table` | array | The literal frozen split, as `{ rank, amount }` rows with `amount` a decimal string | | `pool_amount` | decimal string | Always `"0.00"` | | `rollover_in` | decimal string | Always `"0.00"` | | `entrant_count` | integer | Distinct players who have bought at least one attempt | | `attempt_cap` | integer | Paid attempts one player may buy in this round | | `min_entrants` | integer | The field this round needs to run | | `min_scored_attempts` | integer | The scored attempts this round needs to run. `0` means no attempts gate. | `funding` is always `"house"`: Challenge funds a fixed purse, announced before the round opened and identical at any field size. `pool_amount` and `rollover_in` are legacy fields from the retired pooled model. They are always `"0.00"` and are kept only so parsers written against the older shape do not break. Do not compute a prize from them; `purse_amount` and `purse_table` are the numbers that pay. `min_entrants` is a condition of the contest, not an internal threshold, and is worth showing to players: below it at close, the round cancels and every entry is refunded in full. That refund is how a fixed purse is underwritten. `settling` is the intermediate status between `open` and `settled`: the round has closed and standings are being computed. A round in `settling` accepts no entries and its `final_rank` fields are not yet written. **Errors:** `401` `unauthorized`. ### GET /v1/tournaments/rounds/{round_id}/leaderboard The rank table for one round. Live standings while it is open, the final table once it has settled. **The board is enter to see.** Ranks and scores are returned only for a player who has paid for at least one attempt in this round, so `player_ref` says who you are rendering for. Name a player who has entered and you get the board below. Send no `player_ref`, an unknown one, or one who has not entered this round, and you get the masked shape instead: a `200` with `locked: true` and no `data`. A valid API key is not a player, so a call with no `player_ref` is always masked. **A live board is a neighborhood, not the whole table.** While the round is running, `data` carries only the named player's own row with one rank above and one below: the race they are actually in. There is no always-visible top; the leading rows come into view only as the player climbs next to them. A player who has paid but not yet scored gets an empty `data` array. `scored_players` on the response says how long the full table is, so "rank 6 of 41" is renderable without the rest of it, and the `rank` numbers say where the visible rows sit. Once the round is `settled` or `cancelled`, `data` is the complete final table. Render what you receive: the rows are the board that player is allowed to see, and padding it out from your own records would defeat the point of the cut. | Parameter | In | Required | Meaning | |---|---|---|---| | `round_id` | path | yes | The round to read | | `player_ref` | query | no | The player you are rendering for. Ranks and scores are returned only when this player has paid for an attempt in this round. | ```bash # The key lives on your server. It never reaches the browser. curl "https://api.withchallenge.com/v1/tournaments/rounds/<>/leaderboard?player_ref=player_42" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "round": { "id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46", "tournament_name": "Daily Sprint", "status": "settled", "opens_at": "2026-08-23T00:00:00.000Z", "closes_at": "2026-08-24T00:00:00.000Z", "entries_close_at": "2026-08-23T23:50:00.000Z", "settled_at": "2026-08-24T00:02:14.663Z", "entry_fee": "1.00", "funding": "house", "purse_amount": "100.00", "purse_table": [ { "rank": 1, "amount": "51.00" }, { "rank": 2, "amount": "31.00" }, { "rank": 3, "amount": "18.00" } ], "pool_amount": "0.00", "rollover_in": "0.00", "entrant_count": 41, "attempt_cap": 10, "min_entrants": 24 }, "scored_players": 37, "data": [ { "rank": 1, "player_ref": "player_42", "best_score": 18.4, "best_score_at": "2026-08-23T19:41:07.552Z", "paid_attempts": 6, "final_rank": 1, "payout_amount": "51.00", "payout_status": "paid" }, { "rank": 2, "player_ref": "player_318", "best_score": 18.9, "best_score_at": "2026-08-23T21:03:55.118Z", "paid_attempts": 4, "final_rank": 2, "payout_amount": "31.00", "payout_status": "paid" } ] } ``` | Field | Type | Meaning | |---|---|---| | `rank` | integer | Position right now. Final once the round's status is `settled`. | | `player_ref` | string | Your own identifier for this player. Never the Challenge user id and never the Challenge username: this is the only identity you can reconcile against your side. | | `best_score` | number | Their best settled score in this round, in the tournament's units | | `best_tiebreak` | number or null | The tiebreak value of the run holding `best_score`. Null on tournaments with no declared tiebreak metric. | | `best_score_at` | timestamp | When that score was posted | | `paid_attempts` | integer | How many attempts they bought | | `final_rank` | integer or null | Written at settlement. Null while the round is still running. | | `payout_amount` | decimal string or null | Null while no payout decision has been made for this row | | `payout_status` | string | `none`, `paid`, `held` or `refunded` | Only players who have posted a score appear. **Ties break on play, then on the clock**: the better score, then the better `best_tiebreak` on the tournament's declared tiebreak metric (null ranks worse than any value), then the earlier `best_score_at`, then the entry id. The tiebreak metric is what keeps a round alive on a metric with a reachable best: a player who matches the leading score wins by beating their tiebreak, right now, in the game, instead of having lost to an earlier post. The timestamp and the entry id are last-resort determinism, so two computations of the same board always agree. `best_tiebreak` is the tiebreak of the run that holds `best_score`: one performance, ranked as a pair. On tournaments predating the tiebreak rule every `best_tiebreak` is null and ties fall to the clock as before. **Before settlement**, `final_rank` is null, `payout_amount` is null and `payout_status` is `none`. That combination says the table is not final yet. **Null is not zero**, and this field never confuses the two: null means no decision has been made, `"0.00"` would mean a decision was made and it pays nothing. **After settlement**, `payout_status` is `paid` for a rank that has been paid, `held` for one behind an open investigation, and `refunded` for every entrant of a round cancelled for too small a field. **The masked shape**, returned when the caller has not named a player who has entered this round: ```json { "locked": true, "reason": "enter_to_see", "round": { "id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "status": "open", "...": "the same round object as above" }, "entrant_count": 41, "scored_players": 37, "min_entrants": 12, "min_scored_attempts": 70, "purse_amount": "1000.00", "purse_table": [ { "rank": 1, "amount": "510.00" }, { "rank": 2, "amount": "310.00" }, { "rank": 3, "amount": "180.00" } ], "closes_at": "2026-08-24T00:00:00.000Z" } ``` | Field | Type | Meaning | |---|---|---| | `locked` | boolean | Always `true`. Absent on a full board, so `body.locked` is the branch. | | `reason` | string | Always `"enter_to_see"` | | `round` | object | The same round object as above, complete. Nothing in it is a rank or a score. | | `entrant_count` | integer | Distinct players who have bought at least one attempt | | `scored_players` | integer | Distinct players who have posted a score. A count, and nothing about who they are. | | `min_entrants` | integer | The field this round needs to run | | `min_scored_attempts` | integer | The scored attempts this round needs to run | | `purse_amount` | decimal string | The fixed purse | | `purse_table` | array | The frozen split, as `{ rank, amount }` rows | | `closes_at` | timestamp | When the round ends | There is no `data` array on a masked response. An empty array would be a claim that nobody has scored, and this response is not making one; `scored_players` is the honest count. It is a `200` rather than a refusal on purpose. Not having entered yet is the ordinary state of a player who has not entered yet, so the response keeps its shape and every field on it is something you would render on the pre-entry screen. Branch on `locked`, not on a status code. Live scores were visible to any caller before 26 August 2026 and are not any more. Under a house-funded purse what underwrites the prize is turnout, so a player deterred by the board does not shrink the purse, they raise the chance the round cancels and refunds. Everything the round promises stays visible; the field's scores are behind the fee. **Errors:** `401` `unauthorized`. `404` `not_found` for an unknown round **and for a round belonging to another program**: tenancy is applied to the query, so another partner's round is absent rather than forbidden. Tenancy is checked before the board rule, so another partner's round is `not_found` rather than masked. --- ## Integrations An integration is how Challenge and your systems exchange outcomes. Creating one from the API is what makes the descriptor architecture usable without hand-written SQL. Two things happen at save time that cannot happen later. The descriptor is validated, so a mistyped path is caught while you configure rather than when your first live outcome silently fails to map and a player goes unpaid. And a pull endpoint is checked for safety at save time as well as at fetch time, so an internal URL cannot sit in the database waiting for the poller. | Transport | What it means | |---|---| | `push_webhook` | Your system posts outcomes to Challenge. Gets a signing secret. | | `pull_poll` | Challenge polls an endpoint you host, on an interval you set | | `sdk_direct` | Your server calls `/v1` directly. No transport configuration and no secret. | All five routes answer only four codes: `invalid_request` (400), `unauthorized` (401), `not_found` (404) and `internal_error` (500). None of them takes an `Idempotency-Key`. ### POST /v1/integrations | Field | Type | Required | Meaning | |---|---|---|---| | `transport` | string | yes | `push_webhook`, `pull_poll` or `sdk_direct` | | `descriptor` | object | no | How to read your payloads. Validated here. Defaults to `{}`. | | `pull_interval_seconds` | integer | no | For `pull_poll`. At least 30. | ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/integrations \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "transport": "push_webhook", "descriptor": {} }' ``` ```json { "id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f", "transport": "push_webhook", "status": "draft", "descriptor": {}, "has_signing_secret": true, "pull_interval_seconds": null, "last_polled_at": null, "next_poll_at": null, "consecutive_failures": 0, "last_poll_error": null, "created_at": "2026-08-24T17:20:44.019Z", "signing_secret": "<>", "webhook_url": "/webhooks/integrations/a1c73f65-9082-4be7-8d41-5c26e0b93a7f/outcome", "signing_instructions": "Send X-Challenge-Signature: sha256=. Store this secret now; it cannot be retrieved again." } ``` `201`. The secret is 64 hexadecimal characters. **It is shown exactly once, in this response**, and is never returned again by any route. A secret readable on demand is a secret that leaks through every subsequent bug: a logged response, an over-broad admin view, a support screenshot. Store it now. Only `push_webhook` gets a secret, along with `webhook_url` and `signing_instructions`. Generating one for every transport would create secrets nobody uses and everyone has to protect. Every integration is created `draft`. One that went live on creation would start accepting money-moving callbacks before anyone had reviewed it. `X-Challenge-Signature` is the header **you** send Challenge on a push webhook. It is not the same as `X-Webhook-Signature`, which is the header Challenge sends **you** on an outbound event. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md). **Errors:** `400` `invalid_request` for an unknown transport, a descriptor that fails validation, a `pull_interval_seconds` under 30 or not an integer, or a pull endpoint that is not a safe URL. ### GET /v1/integrations Every integration on this program, newest first. ```bash # The key lives on your server. It never reaches the browser. curl https://api.withchallenge.com/v1/integrations \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "data": [ { "id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f", "transport": "push_webhook", "status": "active", "descriptor": {}, "has_signing_secret": true, "pull_interval_seconds": null, "last_polled_at": null, "next_poll_at": null, "consecutive_failures": 0, "last_poll_error": null, "created_at": "2026-08-24T17:20:44.019Z" } ] } ``` | Field | Type | Meaning | |---|---|---| | `id` | uuid | The integration | | `transport` | string | `push_webhook`, `pull_poll` or `sdk_direct` | | `status` | string | `draft`, `active` or `suspended` | | `descriptor` | object | How Challenge reads your payloads | | `has_signing_secret` | boolean | Enough to answer "is it configured" without disclosing anything | | `pull_interval_seconds` | integer or null | For `pull_poll` | | `last_polled_at` | timestamp or null | The last poll attempt | | `next_poll_at` | timestamp or null | When the next poll is due | | `consecutive_failures` | integer | Reset on the first success | | `last_poll_error` | string or null | The most recent poll failure | | `created_at` | timestamp | When it was created | `has_signing_secret` is the only thing said about the secret anywhere after creation. ### PATCH /v1/integrations/{id} | Field | Type | Required | Meaning | |---|---|---|---| | `descriptor` | object | no | Replaces the stored descriptor. Validated. | | `status` | string | no | `draft`, `active` or `suspended` | | `pull_interval_seconds` | integer | no | At least 30 | ```bash # The key lives on your server. It never reaches the browser. curl -X PATCH https://api.withchallenge.com/v1/integrations/<> \ -H "X-API-Key: $CHALLENGE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "active" }' ``` The response is the same public view as the list, with the update applied. Only the fields you send are changed. **Errors:** `400` `invalid_request` for a status outside the three, a descriptor that fails validation, a bad interval, an unsafe pull endpoint, and for activating a `push_webhook` that has no signing secret. `404` `not_found` for an unknown id or one on another program. ### POST /v1/integrations/{id}/rotate-secret Issue a new signing secret while the old one keeps verifying. ```bash # The key lives on your server. It never reaches the browser. curl -X POST https://api.withchallenge.com/v1/integrations/<>/rotate-secret \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` ```json { "id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f", "transport": "push_webhook", "status": "active", "descriptor": {}, "has_signing_secret": true, "pull_interval_seconds": null, "last_polled_at": null, "next_poll_at": null, "consecutive_failures": 0, "last_poll_error": null, "created_at": "2026-08-24T17:20:44.019Z", "signing_secret": "<>", "note": "The previous secret still verifies until you retire it. Deploy this one, then call DELETE /v1/integrations/{id}/previous-secret." } ``` **The rotation overlaps on purpose.** A hard cutover drops every delivery already in flight, which is the reason partners avoid rotating at all, and a secret nobody rotates is worse than an awkward rotation. Deploy the new secret, confirm traffic verifies against it, then retire the old one. This is the second and last time a secret is returned, and it is a different secret from the first. **Errors:** `400` `invalid_request` when the integration is not a `push_webhook`, since only those have a signing secret. `404` `not_found` for an unknown id or one on another program. ### DELETE /v1/integrations/{id}/previous-secret Retire the old secret. After this, only the current one verifies. ```bash # The key lives on your server. It never reaches the browser. curl -X DELETE https://api.withchallenge.com/v1/integrations/<>/previous-secret \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` The response is the public view. Safe to call when there is no previous secret; it is a no-op. **Errors:** `404` `not_found` for an unknown id or one on another program. --- ## What is not in this API - **There is no `POST /v1/challenges`.** Creating a Challenge is a pricing decision Challenge participates in, made in the Developer Portal. See [Challenges](https://www.withchallenge.com/docs/challenges.md). - **There is no endpoint that sets a threshold, a fee or a prize.** The bar is measured, and the fee and prize follow from it. See [Calibration](https://www.withchallenge.com/docs/calibration.md). - **There is no endpoint that reports a win.** The outcome call accepts `score`, never `won`. - **There is no way to read a signing secret** after the response that created or rotated it. - **There is no self-serve live-key rotation.** Live keys are issued at approval. Test keys can be regenerated from the Developer Portal. ## Related - [Report the score from your server](https://www.withchallenge.com/docs/report-score.md): the guide this page is the reference for - [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md): the `player_ref` path for games with no browser client - [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): every event Challenge emits, with payloads - [Errors](https://www.withchallenge.com/docs/errors.md): every code, with cause and fix - [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): what each key may touch # Webhook events Source: https://www.withchallenge.com/docs/webhook-events.md Every event Challenge sends, what triggers it, and the exact payload it carries. **Runs on your server.** The guide is [Webhooks](https://www.withchallenge.com/docs/webhooks.md). ## The catalog | Event | Trigger | |---|---| | `attempt.settled` | A score was reported and judged | | `attempt.refunded` | An attempt expired with no outcome and the fee went back | | `challenge.calibrated` | A bar was placed and the Challenge went live | | `challenge.suspended` | The drift monitor pulled a live Challenge | | `settlement.paid` | A monthly period settled and your share accrued | | `tournament.settled` | A round closed, standings are final and ranks were paid | | `tournament.cancelled` | A round could not run and every entry was refunded | Subscribe to the events you handle and no others. An endpoint that answers 200 to events it ignores is indistinguishable from one that is quietly dropping the events it cares about. ## Amounts are JSON numbers here This is the one place in the platform where money is not a decimal string. `/v1` responses use `"4.50"`; webhook payloads use `4.5`. Round to cents when you store them, and do not compare a webhook amount to a `/v1` amount without parsing both into the same type. The reason to prefer the API anyway is stronger than formatting: on `attempt.settled`, re-fetch rather than reconstruct. Call `GET /v1/attempts/{id}` and trust that. The webhook is a notification that something changed, not the record of what it changed to. --- ## attempt.settled **Trigger.** Your server reported a score to `POST /v1/attempts/{id}/outcome` and Challenge judged it against the threshold frozen onto the attempt. Emitted for wins and losses alike, and for tournament attempts, which have no threshold to meet. | Field | Type | Meaning | |---|---|---| | `attempt_id` | uuid | The attempt that settled | | `challenge_id` | uuid or null | The Challenge played. Null on a tournament attempt. | | `tournament_round_id` | uuid or null | The round entered. Null on a Challenge attempt. | | `program_id` | uuid | Your program | | `player_ref` | string | Your own identifier for this player | | `score` | number | The measurement you reported | | `met_threshold` | boolean | Challenge's determination | | `threshold` | number or null | The bar in force when the attempt started. Null on a tournament attempt. | | `fee` | number | What this attempt cost, frozen when it was paid for | | `prize_awarded` | number | What actually moved: the award amount, or `0` when nothing was won AND `0` when a win is held for review. Reconcile paid winnings on this number, never on `met_threshold`. | | `payout_status` | string or null | `paid`, `held`, or null when the player did not win. `held` means the player cleared the bar but the payout is withheld pending a review, so `prize_awarded` is `0` even though `met_threshold` is `true`. A later change is not re-notified today, so re-fetch `GET /v1/attempts/{id}` if you need the resolution. | | `settled_at` | timestamp | When the outcome was written | Exactly one of `challenge_id` and `tournament_round_id` is non-null, so a consumer reading this feed can tell which of the two parents settled without a second call. ```json { "attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "tournament_round_id": null, "program_id": "<>", "player_ref": "player_42", "score": 24, "met_threshold": true, "threshold": 27, "fee": 1, "prize_awarded": 4.5, "payout_status": "paid", "settled_at": "2026-08-24T17:14:36.201Z" } ``` **On a tournament attempt this event is not a result.** `challenge_id` is null, `threshold` is null, `met_threshold` is `false` and `prize_awarded` is `0`, every time, because a round attempt buys a place on a rank table rather than a shot at a bar. What it pays, if anything, is decided at the round's close and arrives in `tournament.settled`. ```json { "attempt_id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4", "challenge_id": null, "tournament_round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "program_id": "<>", "player_ref": "player_42", "score": 18.4, "met_threshold": false, "threshold": null, "fee": 1, "prize_awarded": 0, "settled_at": "2026-08-24T17:15:02.884Z" } ``` Do not read `met_threshold: false` on a row with a `tournament_round_id` as a loss. Branch on which parent is set first. --- ## attempt.refunded **Trigger.** An attempt reached an hour with no outcome reported, so the expiry sweep closed it and returned the fee. Nothing your server did causes this; it is what happens when nothing did. | Field | Type | Meaning | |---|---|---| | `attempt_id` | uuid | The attempt that expired | | `challenge_id` | uuid or null | The Challenge played. Null on a tournament attempt. | | `tournament_round_id` | uuid or null | The round entered. Null on a Challenge attempt. | | `program_id` | uuid | Your program | | `player_ref` | string | Your own identifier for this player | | `fee_refunded` | number | What went back, which is the whole fee | | `reason` | string | `expired` | ```json { "attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57", "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "tournament_round_id": null, "program_id": "<>", "player_ref": "player_42", "fee_refunded": 1, "reason": "expired" } ``` The attempt's `status` becomes `expired` and reporting an outcome on it afterwards is refused. A steady trickle of these means rounds are being started and never reported, which is usually a crash between `startAttempt()` and the outcome call, or an outcome call that is failing silently. --- ## challenge.calibrated **Trigger.** A bar was placed on a Challenge and it went live. Emitted from all three paths that can do that: the calibration sweep on a Challenge that collected enough observed attempts, a Challenge created on a metric that already had enough listened rounds, and the ladder sweep pricing a new rung. | Field | Type | Meaning | |---|---|---| | `challenge_id` | uuid | The Challenge that is now live | | `program_id` | uuid | Your program | | `name` | string | The Challenge's name | | `threshold` | number | The bar, in the Challenge's own units | | `threshold_direction` | string | `higher_is_better` or `lower_is_better` | | `sample_size` | number | Total scores the bar was placed on | | `player_count` | number | Distinct players in that sample | ```json { "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "program_id": "<>", "name": "Fewest Moves", "threshold": 27, "threshold_direction": "lower_is_better", "sample_size": 1042, "player_count": 214 } ``` The payload is identical from all three paths, and it does not say which one produced it. Read `calibration_source` from `GET /v1/challenges?status=all` when you need to know, and read `fee` and `prize` from the same place: neither is in this event, and the Challenge may be inside its go-live ramp at half the configured prize. See [Calibration](https://www.withchallenge.com/docs/calibration.md). There is no separate event for a rung being spawned. A rung appears unpriced and is announced by this event when it is priced. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). --- ## challenge.suspended **Trigger.** The drift monitor pulled a live Challenge: its realised pass rate left the band around its target, or the expected payout at that realised rate exceeded the fee. No new attempts are accepted until it is reviewed. | Field | Type | Meaning | |---|---|---| | `challenge_id` | uuid | The Challenge that was pulled | | `program_id` | uuid | Your program | | `name` | string | The Challenge's name | | `reason` | string | A sentence naming which test failed and with what numbers. Human-readable, not an enum. | | `realised_pass_rate` | number | The measured rate, as a fraction | | `target_pass_rate` | number | What it was priced for, as a fraction | ```json { "challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40", "program_id": "<>", "name": "Fewest Moves", "reason": "expected payout 1.35 exceeds the 1.00 fee at a realised pass rate of 30.0%", "realised_pass_rate": 0.3, "target_pass_rate": 0.1 } ``` **Do not parse `reason`.** Branch on `realised_pass_rate` against `target_pass_rate` if you need to react differently to a bar that turned out too soft versus too hard. The wording is written for a person reading an alert. Attempts already open when this fires keep the threshold captured at their creation, so nobody's result changes retroactively. Suspension is a circuit breaker, not a deletion. --- ## settlement.paid **Trigger.** A monthly settlement period was computed and your revenue share accrued to your payable balance. One event per program per period. | Field | Type | Meaning | |---|---|---| | `settlement_id` | uuid | The settlement record | | `program_id` | uuid | The program that earned it | | `period_start` | date | First day of the settled period | | `period_end` | date | Last day of the settled period | | `margin` | number | The platform margin the period produced | | `amount` | number | Your share of that margin, accrued to the developer account | ```json { "settlement_id": "e2b90c14-77af-4d35-a6e1-05c8b3f2941d", "program_id": "<>", "period_start": "2026-07-01", "period_end": "2026-07-31", "margin": 1842.5, "amount": 552.75 } ``` `amount` is owed to the **developer account**, not to the program: a developer may run several programs and is paid once. `program_id` records which program earned it. A period that earns nothing is still recorded, but it does not emit this event: the accrual is what the event announces, and there is no accrual. "We looked at July and it earned nothing" and "nobody has settled July yet" are different facts, and only the settlement record tells them apart. --- ## tournament.settled **Trigger.** A round closed with enough of a field to run, standings were computed and ranks were paid. | Field | Type | Meaning | |---|---|---| | `round_id` | uuid | The round that settled | | `tournament_id` | uuid | Its parent | | `tournament_name` | string or null | Denormalised for readability | | `program_id` | uuid | Your program | | `funding` | string | Always `house` | | `entrants` | number | Players on the board, meaning those who posted a score | | `positions_paid` | number | How many ranks received a payout | | `settled_at` | timestamp | When standings became final | | `pool_amount` | number | Always `0` on a house round | | `purse_amount` | number | The fixed purse this round paid | | `rollover_in` | number | Always `0` on a house round | | `standings` | array | Every ranked entrant, ordered by final rank | Each `standings` row: | Field | Type | Meaning | |---|---|---| | `player_ref` | string | Your own identifier for this player | | `rank` | number | Final rank | | `payout` | number | What that rank was paid. `0` for a rank outside the purse table. | | `payout_status` | string | `paid`, `held` or `none` | ```json { "round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46", "tournament_name": "Daily Sprint", "program_id": "<>", "funding": "house", "entrants": 41, "positions_paid": 3, "settled_at": "2026-08-24T00:02:14.663Z", "pool_amount": 0, "purse_amount": 100, "rollover_in": 0, "standings": [ { "player_ref": "player_42", "rank": 1, "payout": 51, "payout_status": "paid" }, { "player_ref": "player_318", "rank": 2, "payout": 31, "payout_status": "paid" }, { "player_ref": "player_77", "rank": 3, "payout": 18, "payout_status": "paid" }, { "player_ref": "player_205", "rank": 4, "payout": 0, "payout_status": "none" } ] } ``` **`standings` carries every ranked entrant, not only the paid ones.** A round with four hundred players on the board sends four hundred rows. Stream or page your handler accordingly, and do not assume the array is short. `pool_amount` and `rollover_in` are legacy fields from the retired pooled model. They are always `0` and are kept only so older parsers do not break. `purse_amount` is the number that paid. A row with `payout_status: "held"` is behind an open investigation. The money stays with Challenge until a person decides it, and the rank is real either way. --- ## tournament.cancelled **Trigger.** A round ended without enough play behind it to run, or its program was suspended before it could settle. That happens at the close, and it can also happen at the round's halfway point when the round is clearly not going to get there. Every attempt is refunded in full, the platform's cut included. | Field | Type | Meaning | |---|---|---| | `round_id` | uuid | The round that was cancelled | | `tournament_id` | uuid | Its parent | | `tournament_name` | string or null | Denormalised for readability | | `program_id` | uuid | Your program | | `reason` | string | One of the four values below | | `funding` | string | Always `house` | | `min_entrants` | number | The field the round needed | | `entrants_with_score` | number | The field it got | | `min_scored_attempts` | number | The scored attempts the round needed. `0` on a round with no attempts gate | | `scored_attempts` | number | The scored attempts it got | | `attempts_refunded` | number | How many attempts were returned | | `cancelled_at` | timestamp | When the round was cancelled | | `early_cancel_at` | number | Midpoint cancellations only, absent otherwise. The scored attempts the round had to reach by half time | | `checked_at` | string | Midpoint cancellations only, absent otherwise. Always `midpoint` | | `players` | array of strings | The `player_ref` of every entrant who was refunded | ```json { "round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46", "tournament_name": "Daily Sprint", "program_id": "<>", "reason": "insufficient_attempts", "funding": "house", "min_entrants": 12, "entrants_with_score": 14, "min_scored_attempts": 70, "scored_attempts": 61, "attempts_refunded": 61, "cancelled_at": "2026-08-24T00:01:58.207Z", "players": ["player_42", "player_318", "player_77"] } ``` `reason` is a closed set of four values: | Value | Meaning | |---|---| | `insufficient_entrants` | Fewer than `min_entrants` players posted a score by the close | | `insufficient_attempts` | Fewer than `min_scored_attempts` attempts were played to a score by the close | | `insufficient_attempts_midpoint` | At half time the round was under `early_cancel_at`, so it was ended a day early instead of being run out | | `program_suspended` | The program was suspended before the round could settle | **Both counts ride on every cancellation, whichever one refused the round.** A round is judged on two conditions and they are not the same question: `min_entrants` is the field, `min_scored_attempts` is the play that pays for the purse. Reading `reason` alone tells you which condition failed; reading the four counts tells your players by how much. A `min_scored_attempts` of `0` is a round that opened before the attempts gate existed and is judged on its field alone. A midpoint cancellation carries two extra fields and is the only kind that does, which is how a handler tells the two apart without parsing the reason string. `early_cancel_at` is a fraction of `min_scored_attempts`, not the gate itself: the round was so far short at half time that ending it then hands the field its money back while it is still worth having, rather than holding it for another day to refund it anyway. The cancellation is not a failure to be retried. A fixed purse is underwritten by the minimum field and the attempts gate, and refunding is what that underwriting looks like when neither arrives. See [Tournaments](https://www.withchallenge.com/docs/tournaments.md). --- ## Delivery The full walkthrough, including a handler you can paste, is in [Webhooks](https://www.withchallenge.com/docs/webhooks.md). The operational facts: | | | |---|---| | Method | `POST`, `Content-Type: application/json` | | Signature | `X-Webhook-Signature: sha256=` | | Event name | `X-Webhook-Event` | | Delivery id | `X-Webhook-Delivery-Id`, a UUID | | Success | Any `2xx`. Anything else is a failure. | | Timeout | 5 seconds | | Retries | 3 attempts in total: the next is due **1 minute** after the first failure, then **5 minutes** after the second | | Ordering | None. Events can arrive out of order. | | Sandbox | Payloads from an `sk_test_` program carry an extra `"test": true` field | Verify the signature over the **raw bytes** you received, before parsing. Re-serialising the JSON and signing that will not match. **Deduplicate on `X-Webhook-Delivery-Id`.** It is unique per delivery and stable across every retry of that delivery, so storing it and ignoring a repeat is the whole of exactly-once handling on your side. The same underlying event can also be delivered to several endpoints, each with its own delivery id. Answer `2xx` fast and do the work afterwards. A handler that finishes a payout before responding is a handler that will be retried while it is still working. ## Events that no longer exist `match.completed`, `match.draw`, `match.expired`, `match.disputed` and `match.voided` are from the retired versus model. They are still accepted in endpoint configuration so existing rows stay valid, and **nothing emits them**. Do not subscribe to them and do not write handlers for them. There is no pooled wager, no PvP stake and no consensus settlement in the current model. ## Related - [Webhooks](https://www.withchallenge.com/docs/webhooks.md): the guide, with a handler and the debug table - [Real-time settlement](https://www.withchallenge.com/docs/realtime.md): how a result reaches the player's own client - [API](https://www.withchallenge.com/docs/api.md): the `/v1` reference to re-fetch from - [Errors](https://www.withchallenge.com/docs/errors.md): the delivery failure table # Errors Source: https://www.withchallenge.com/docs/errors.md Every code Challenge can hand you, what caused it, and what to do about it. The guides that raise these are [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md), [Report the score from your server](https://www.withchallenge.com/docs/report-score.md) and [Webhooks](https://www.withchallenge.com/docs/webhooks.md). Codes are a closed, versioned set. **Branch on the code, never on the message.** Wording is not part of the contract and may change at any time. ## The ones you will actually meet | Code | Surface | What it usually means | |---|---|---| | `unauthorized` | API | The key is missing, malformed, for the wrong environment, or on a program that is not approved | | `invalid_request` | API | A missing `Idempotency-Key`, both parents on `POST /v1/attempts`, or a `score` that is not a number | | `not_found` | API | A resource that belongs to another program, or an id that does not exist | | `insufficient_funds` | Both | The player cannot fund the fee. Nothing was charged. | | `attempt_already_settled` | API | Your outcome call ran twice without an `Idempotency-Key` | | `challenge_already_claimed` | API | This player has already been paid this Challenge. Permanent for the pair. | | `blocked` | Both | This player may not play for money: their own responsible gaming decision, or a state Challenge does not serve | | `insider_entry` | API | The account behind this `player_ref` is linked to your developer account, so it cannot enter your own live tournament | | `not_armed` | Challenge.js | `startAttempt()` ran before the player armed anything | | `client_reporting_removed` | Both | Something is still calling the retired browser-side reporting path | --- ## Challenge.js Rejections from `Challenge.startAttempt()` are an `Error` with `name: 'ChallengeAttemptError'` and a `.code`. The removed `Challenge.reportScore()` throws the same shape. `Challenge.record()` never rejects at all. See [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md). Most of these are already handled for the player: the island shows a note, offers a deposit or re-syncs itself. Your branch usually only needs to not start the round. ### not_initialised **Cause.** `startAttempt()` was called before `Challenge.init()`, or the island was destroyed while the request was in flight. **Fix.** Call `Challenge.init({ program })` once when your page or component mounts, and make sure your round loop cannot start an attempt before it. In a single-page app, this code also fires when a route change ran `destroy()` mid-round, which is a sign the teardown is happening in the wrong place. ### not_armed **Cause.** Nothing is armed for this player, neither a Challenge nor a tournament round, and no `{ tournamentRound }` was named. **Fix.** Nothing, in most integrations. Arming is the player's act on the island, and there is no `arm()` for a game to call on their behalf. Treat this as "this round is not for money" and run it as an ordinary round, calling `Challenge.record()` at the end as usual. ### attempt_in_progress **Cause.** A paid round is already live for this player. An attempt ends when your server reports its score, or when it expires after an hour. **Fix.** Do not start a second attempt until the first has settled. If you are seeing this without a second round having started, your server is not reporting outcomes: check that `POST /v1/attempts/{id}/outcome` is being called and is answering `200`. ### client_reporting_removed **Cause.** `Challenge.reportScore()` was called. It was removed and does not exist any more. **Fix.** Delete the call. Report the score from the server that holds your secret key, with `POST /v1/attempts/{id}/outcome`. The island hears the settlement on its own socket and renders the result with nothing further for your game to call. See [The trust model](https://www.withchallenge.com/docs/trust-model.md). ### network **Cause.** The request never reached Challenge, after its retries. **Fix.** Let the round run unpaid rather than blocking on it, and log the failure. Nothing was charged. Persistent occurrences point at a content-security-policy or an ad blocker in the player's browser rather than at Challenge. ### unauthorized **Cause.** The player's own session is not valid: signed out, or expired. **Fix.** Nothing. The island puts the sign-in surface up itself. Do not build a sign-in of your own. ### insufficient_funds **Cause.** The player's balance does not cover the fee. Nothing was charged. **Fix.** Nothing. The island shows the message and offers a deposit. ### challenge_not_available **Cause.** The Challenge is suspended, closed, or its prize exceeds your integrity tier ceiling. It is also what the server answers when it and the island disagree about what is armed. **Fix.** Nothing at the call site. If it is persistent, check the Challenge's `status` with `GET /v1/challenges?status=all` and read [Calibration](https://www.withchallenge.com/docs/calibration.md) for what suspends one. For a ceiling refusal, see [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). ### blocked **Cause.** A responsible gaming decision the player made about themselves: a deposit limit, a cool-off, a self-exclusion. **Fix.** Nothing, ever. The island shows the player's own words back to them verbatim. Do not offer a way around it and do not retry. ### unknown **Cause.** The server refused with a code this list does not name. Server-side conflict codes pass through unchanged, so `challenge_already_claimed`, `rung_locked`, `attempt_cap_reached` and `entries_closed` arrive here with their own codes rather than as `unknown`; a true `unknown` is something newer than your bundle. **Fix.** Read `.message`, log it, and let the round run unpaid. Nothing was charged. --- ## API Every `/v1` failure is `{ error: { code, message } }` with an HTTP status fixed per code. See [API reference](https://www.withchallenge.com/docs/api.md). ### invalid_request **HTTP 400.** **Cause.** The request was malformed. The cases you will actually hit: - `Idempotency-Key` missing on `POST /v1/attempts`, `POST /v1/attempts/{id}/outcome` or `POST /v1/challenges/{id}/distribution`, or longer than 255 characters. - Both `challenge_id` and `tournament_round_id` on `POST /v1/attempts`, or neither. - `player_ref` missing on any route that takes one. - `score` missing or not a finite number on the outcome call. - On `POST /v1/challenges/{id}/distribution`: a percentile that is not a number, percentiles that do not ascend from `p5` to `p95`, a `sample_size` under 500, a Challenge that is not `calibrating`, a Challenge that is a rung of a ladder, or a challenge id this program does not own. - On `/v1/integrations`: an unknown transport, a descriptor that fails validation, a `pull_interval_seconds` under 30 or not an integer, an unsafe pull endpoint, activating a `push_webhook` with no signing secret, or asking to rotate a secret on an integration that is not a `push_webhook`. **Fix.** Read `message`, which names the specific field. Note that a seeding refusal on a challenge this program does not own is `invalid_request` rather than `not_found`, unlike everywhere else. ### unauthorized **HTTP 401.** **Cause.** One code for every key failure: no `X-API-Key` header, a key that is neither `sk_test_` nor `sk_live_`, an unknown key, a test key used where a live one was expected or the reverse, a live key on a program that is not approved, a suspended or closed program, and an account scheduled for deletion. **Fix.** They answer identically on purpose, because distinguishing them would tell an attacker which half of a guess was right. Work through the list yourself: confirm the header is present, confirm the prefix matches the environment you meant, and confirm the program's approval status in the Developer Portal. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md). ### insufficient_funds **HTTP 402.** **Cause.** The player cannot fund the fee. Nothing was charged and no attempt was created. **Fix.** On the browser path, the island offers a deposit and you need do nothing. On the server path there is no surface to offer: send the player to the island, or to their Challenge account, before retrying. ### blocked **HTTP 403.** **Cause.** This player may not play for money, for a reason that is about them rather than about your request. A responsible gaming decision they made about themselves, or a state Challenge does not serve for paid play. Nothing was charged and no attempt was created. Paid attempts are checked for this on every door, the server path included. A player's location is judged live against the current list rather than at signup, so a player who could enter last month may be refused today. **Fix.** Nothing, ever. Do not retry, and do not offer an alternative paid path. Run the round unpaid instead. There is deliberately no endpoint that tells you in advance which of your players is eligible: that is a list of your players' locations and self-exclusions, and it is not yours to hold. ### insider_entry **HTTP 403.** **Cause.** The account behind this `player_ref` is linked to your own developer account: it is your developer login's player account, a member of your team, or one of your sandbox test accounts. It cannot enter a live tournament on your own program. Nothing was charged. Your game reports the scores that decide the ranks, and a house-funded purse is Challenge's money, so an account on your side of that cannot also be a competitor for it. **Fix.** Test tournaments with your test key against the sandbox twin, where entry is free, nothing is charged and the same board, ranks and settlement all run. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md). ### score_out_of_range **HTTP 422.** **Cause.** The reported `score` lies outside the plausibility window the Challenge declares. The request was well formed and well typed; the value it carries cannot be a real measurement of this game. Nothing settles and no fee is charged back, so the attempt is left to expire and refund. Fires only on a Challenge that has declared a bound. **Fix.** This is a malformed report, not a held win. It usually means the score you sent is in the wrong unit, or your own plausibility check is missing. Bound the score on your server before reporting it: reject anything above the maximum a round can reach or below the minimum, and report only a number your game could actually have produced. ### not_found **HTTP 404.** **Cause.** No such resource, **or it belongs to another program**. An unknown challenge id, attempt id, round id or integration id, and an email with no Challenge account on `POST /v1/players`, all answer this. **Fix.** Confirm you are using the key for the program that owns the resource. A resource that exists but is not yours answers `not_found` rather than a 403, because a 403 would confirm it exists. The 403 codes above are about the player and the product, never about ownership. On `POST /v1/players`, this means the player has not signed up with Challenge yet: there is no auto-provisioning, by design. ### challenge_not_available **HTTP 409.** **Cause.** The Challenge is suspended, closed, or its prize is above your integrity tier ceiling. Nothing was charged. **Fix.** Read the Challenge's `status` from `GET /v1/challenges?status=all`. A suspended Challenge was pulled by the drift monitor and is reinstated by review, not by another call. A ceiling refusal is a configuration limit and not a fault: your tier caps the prize. ### challenge_already_claimed **HTTP 409.** **Cause.** This player has already been paid this Challenge's prize. A Challenge pays one fixed prize per player for life. **Fix.** Stop offering it to that player. `GET /v1/challenges` already omits claimed Challenges for the `player_ref` you name, so hitting this means you are building a menu from something other than that list. The refusal is permanent for the pair and no later attempt changes it. Free calibration play never causes it. ### rung_locked **HTTP 409.** **Cause.** This Challenge is a rung of a ladder and this player has not claimed the rung below it, which is the only thing that unlocks one. **Fix.** Temporary, unlike a claimed Challenge: it clears the moment they claim the Challenge named in `parent_challenge_id`. You should not meet it in normal use, because `GET /v1/challenges` omits locked rungs for the player it was asked about. The island lists them, marked locked, which is the one surface that does. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md). ### attempt_cap_reached **HTTP 409.** **Cause.** This player has bought their allowance. On a Challenge that is the rolling daily cap of paid attempts per player per Challenge. On a tournament round it is the round's per-player attempt cap, which is what keeps a rank table a skill contest rather than a spending contest. **Fix.** Nothing to retry now. The Challenge cap clears on its own as the day rolls forward; the round cap clears when the next round opens. Free calibration attempts never count against either. ### entries_closed **HTTP 409.** **Cause.** The tournament round is no longer open, or it is inside the ten minutes before its close when re-buys stop. **Fix.** Read `entries_close_at` on the round and stop offering entry before it. That window exists because scores are visible live: without it, the optimal play is to sit out and buy exactly enough attempts at the death. ### round_closed **HTTP 409.** **Cause.** You reported an outcome for an attempt in a tournament round that has stopped accepting scores. Standings are computed from what was in at the bell, so a late report cannot move a rank the field may already have been paid on. **Fix.** Report faster. The attempt expires and its entry is refunded by the settlement sweep, so a late report costs the player nothing, but it also does not count. If this is frequent, your outcome call is running too long after the round ends: report at the end of each round rather than in a batch. ### attempt_already_settled **HTTP 409.** **Cause.** This attempt already has an outcome. **Fix.** Almost always a retry sent without an `Idempotency-Key`, or with a fresh one. Send the same key you sent the first time and the replay returns the original response instead of this. If you genuinely do not know whether the first call landed, call `GET /v1/attempts/{id}` and read the outcome. ### idempotency_key_reused **HTTP 409.** **Cause.** This `Idempotency-Key` was already used on this endpoint with a **different** request body. **Fix.** Use a new key for a new operation. Refusing is safer than guessing which of the two requests you meant. The common source is a key derived from something not unique per operation, such as a player id or a round id; derive it from the attempt instead, or use a UUID. ### idempotency_in_progress **HTTP 409.** **Cause.** A request with this key is still being processed. Two of your workers raced on the same operation. **Fix.** Retry shortly with the same key: once the original finishes you get its response with `Idempotency-Replayed: true`. A record stuck in flight for over 60 seconds is treated as dead and the next retry takes over. ### internal_error **HTTP 500.** **Cause.** Something failed on Challenge's side. A raw database message is never leaked into the envelope, so the detail is in Challenge's logs rather than in your response. **Fix.** Retry with the **same** `Idempotency-Key`. Nothing durable is stored for a non-2xx response, so the retry runs the operation properly. If it persists, capture the request id and the timestamp and contact support. ### client_reporting_removed **HTTP 410**, on `POST /api/attempts/{id}/outcome`, which is not a `/v1` route. **Cause.** Something is still calling the retired browser-side reporting endpoint. Usually a cached old bundle. **Fix.** Report from your server through `/v1`. The route is mounted deliberately so a stale bundle gets an explanation rather than a 404 it would read as an outage. It carries no auth and no idempotency, so it costs the caller nothing. ### Rate limiting `/v1` allows **300 requests per minute per API key**. Over the limit the answer is `429`, with `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. The `429` body is the limiter's own shape rather than the error envelope, so a parser that assumes `error.code` on every failure will fail on this one: ```json { "error": "Too many requests, please try again later.", "retry_after": 37, "limit": 300, "reset_at": "2026-08-24T18:22:41.006Z" } ``` **Fix.** Back off for `retry_after` seconds. If you are near the limit in normal operation, you are probably polling `GET /v1/challenges` per render: cache it, and use webhooks rather than polling for settlement. --- ## Webhook delivery These are not codes. They are what Challenge records when it could not deliver an event to your endpoint, and what to check for each. A delivery is attempted 3 times in total, the retries due 1 minute and then 5 minutes after each failure. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md) and [Webhook events](https://www.withchallenge.com/docs/webhook-events.md). | What Challenge saw | Likely cause | Fix | |---|---|---| | Connection refused or DNS failure | The URL is wrong, the host is not public, or the service is down | Confirm the endpoint resolves and answers from the open internet. `localhost` and private ranges are unreachable from Challenge; use a tunnel in development. | | `3xx` | Your endpoint redirects, commonly http to https or a trailing-slash rule | Redirects are not followed. Register the final URL. | | `401` or `403` | Your own auth middleware is in front of the handler | Exempt the webhook path from session auth. The signature is the authentication. | | `404` | The path does not exist, or the method is not allowed | The request is a `POST`. Confirm the route accepts one. | | Other `4xx` | Signature verification rejected the request, or the body failed to parse | Verify over the **raw bytes** before any parsing or re-serialising. A framework that parses JSON and hands you an object has already destroyed the bytes you need. | | `5xx` | Your handler threw | Answer `2xx` first and do the work afterwards. Retries will arrive while a slow handler is still working. | | TLS handshake failure | An expired, self-signed or incomplete certificate chain | Challenge does not skip verification. Serve a valid chain, intermediates included. | | Timeout | The handler took longer than 5 seconds | Acknowledge immediately and queue the work. A payout that finishes before responding is a payout that gets a retry on top of it. | | Delivered but duplicated | A retry after a response Challenge never saw | Deduplicate on `X-Webhook-Delivery-Id`, which is stable across every retry of a delivery. | After 3 failed attempts the delivery is marked failed and is not retried again. The event is not lost from Challenge's side: `GET /v1/attempts/{id}` remains the authoritative record, which is why the rule is re-fetch, never reconstruct. --- ## Related - [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md): the browser surface these codes come from - [API reference](https://www.withchallenge.com/docs/api.md): the endpoint that raises each API code - [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): payloads and delivery semantics - [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): the branches to have in place before real money # Integrate Challenge in one pass Source: https://www.withchallenge.com/docs/integrate.md 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. | What | Form | Where it goes | |---|---|---| | `PROGRAM_ID` | An opaque string from the [Developer Portal](https://app.withchallenge.com) | The browser. It is public | | `CHALLENGE_API_KEY` | `sk_test_...` in sandbox, `sk_live_...` in production | A server environment variable, and nowhere else, ever | | Metric name | The game's own word for what it measures, for example `moves`, `solve time`, `combo length`. 40 characters or fewer | Both halves of the integration | | Direction | `higher_is_better` or `lower_is_better` | The 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. | Path | What it additionally needs | |---|---| | **Paid path.** Take a Challenge live and run a real charged attempt | Five 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` Challenge | An 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](https://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: ```bash # The key lives on your server. It never reaches the browser. export CHALLENGE_API_KEY=sk_test_... ``` Throughout this page, `<>` and `<>` 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. ```html ``` 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: ```javascript Challenge.init({ program: '<>' }) ``` `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: | Option | Type | Default | What it does | |---|---|---|---| | `program` | string | required | The program ID | | `anchor` | string | `'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 | | `draggable` | boolean | `false` | Lets the player move the island and remembers where | | `challenge` | string | `null` | Pin the island to one Challenge id. The key is literally `challenge`, not `challenge` | | `apiBase` | string | script origin | Local 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: ```html ``` ```javascript Challenge.init({ program: '<>', 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/.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. ```jsx 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. ```javascript 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: ```javascript 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: ```bash # 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](https://app.withchallenge.com).** 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 `<>` 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: ```bash # The key lives on your server. It never reaches the browser. curl -s -X POST \ "https://api.withchallenge.com/v1/challenges/<>/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 }' ``` ```json { "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: ```bash # 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": "<>", "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](https://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: ```javascript // 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://.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. | Field | Type | Meaning | |---|---|---| | `attemptId` | string | The id the server reports the outcome against | | `feeCharged` | number | What this player actually paid, frozen | | `prizeOffered` | number | What this attempt pays on a win, frozen. `0` on a calibrating Challenge | | `threshold` | number or null | The bar this attempt is judged against | | `thresholdDirection` | string 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: ```javascript 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 ``` | Header | Value | |---|---| | `X-API-Key` | `sk_test_...` in sandbox, `sk_live_...` in production. Server side only, always | | `Idempotency-Key` | Required. Use the attempt id | | `Content-Type` | `application/json` | ```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: ```typescript // 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: ```bash # 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://.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: ```javascript // 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: ```bash # 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: ```json { "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: | Situation | Answer | |---|---| | Same key, same body, first call completed | The 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 flight | `409 idempotency_in_progress`. Retry shortly | | Same key, different body | `409 idempotency_key_reused`. Use a new key for a new operation | | No `Idempotency-Key` header | `400 invalid_request` | | A different key on an attempt that already settled | `409 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=` 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 ```bash # 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: ```javascript 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: ```javascript 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: , 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 ```bash # The key lives on your server. It never reaches the browser. curl -s "https://api.withchallenge.com/v1/attempts/<>" \ -H "X-API-Key: $CHALLENGE_API_KEY" ``` **Expected:** ```json { "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 saw | Cause | Fix | |---|---|---| | Connection refused or DNS failure | The URL is wrong or not public | `localhost` and private ranges are unreachable. Use a tunnel in development | | `3xx` | Your endpoint redirects | Redirects are not followed. Register the final URL | | `401` or `403` | Your own auth middleware is in front of the handler | Exempt the webhook path. The signature is the authentication | | `404` | The path or method does not exist | The request is a `POST` | | Other `4xx` | Signature verification rejected it, or the body failed to parse | Verify over the raw bytes, before any parsing or re-serialising | | `5xx` | Your handler threw | Answer `2xx` first, do the work afterwards | | TLS handshake failure | Expired, self-signed or incomplete chain | Challenge does not skip verification. Serve intermediates | | Timeout | The handler took longer than 5 seconds | Acknowledge immediately and queue the work | | Delivered but duplicated | A retry after a response Challenge never saw | Deduplicate 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](https://app.withchallenge.com), 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()`. ```bash # 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. ```bash # 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": "<>", "player_ref": "user_8134" }' ``` ```json { "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](https://www.withchallenge.com/docs/how-a-round-works.md): the whole lifecycle once, in order, as a sequence diagram. - [The trust model](https://www.withchallenge.com/docs/trust-model.md): why the browser may start an attempt and may never end one. - [Calibration](https://www.withchallenge.com/docs/calibration.md): 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](https://www.withchallenge.com/docs/sandbox.md): the free loop in full, and exactly what differs from production. - [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): everything that must be true before `sk_test_` becomes `sk_live_`. - [Webhooks](https://www.withchallenge.com/docs/webhooks.md) and [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): handler, signature verification and every payload. - [Challenge.js](https://www.withchallenge.com/docs/challenge-js.md) and [API](https://www.withchallenge.com/docs/api.md): the two references. - [Errors](https://www.withchallenge.com/docs/errors.md): the same catalog as above, with more on each code. - [Tournaments](https://www.withchallenge.com/docs/tournaments.md) and [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md): 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. ```text ## 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). ```