---
title: "How a round works"
description: "The lifecycle of one paid round as a sequence diagram and a narration: arming, startAttempt and the freeze, your server reporting the measurement, settlement, the webhook, and the expiry refund."
url: https://www.withchallenge.com/docs/how-a-round-works
canonical_markdown: https://www.withchallenge.com/docs/how-a-round-works.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# How a round works

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

---

Source: https://www.withchallenge.com/docs/how-a-round-works.md
Docs index for AI agents: https://www.withchallenge.com/llms.txt
