The trust model
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:
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 shows the three rules in sequence, actor by actor.
- Quickstart builds the loop they describe.
- Sandbox is how you exercise all of it with no money on it.
Source: https://www.withchallenge.com/docs/trust-model.md Docs index for AI agents: https://www.withchallenge.com/llms.txt
Ready to integrate?
Put a Bounty in your game in minutes.
Challenge for Players
Create an account, deposit funds, and compete for real money in games you already play.
Challenge for Developers
Add Challenge to your game and start earning from every match. Integration takes minutes.