---
title: "The trust model"
description: "The three rules money in Challenge follows: scores enter only from an authenticated partner server, fees are charged before scores are heard, and the bar and prize freeze at attempt start. Includes the removed client-reporting APIs."
url: https://www.withchallenge.com/docs/trust-model
canonical_markdown: https://www.withchallenge.com/docs/trust-model.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

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

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

---

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