Challenge
Docs>Get started>How a round works

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

Loading diagram

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

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

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

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

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:

{ "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

ActorCannotHow that is enforced
The player's browserReport a scoreChallenge.reportScore() throws client_reporting_removed, and the old player-side outcome endpoint answers 410 with the same code
Your serverDeclare a winPOST /v1/attempts/{id}/outcome accepts score and has no won field. A non-numeric score is invalid_request
Your codeArm a Challenge for a playerThere is no arming endpoint on any key
AnyoneChange the bar mid-attemptThe threshold and its direction are copied onto the attempt at creation and read from there at settlement
Another program's keySee or settle your attemptsTenancy 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 states the three rules this sequence is built from.
  • Quickstart walks the same loop with a working server handler and a verification step.
  • Sandbox 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

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.