Real-time settlement
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, 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=<player jwt>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:
{ "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:<userId> | Automatic on connect | That user |
attempt:<attemptId> | Ask for it | The player who owns the attempt, and nobody else |
admin:all | Automatic for admins | Admin accounts |
developer:<developerId> | 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
{ "type": "subscribe", "channels": ["attempt:<attemptId>"] }
{ "type": "unsubscribe", "channels": ["attempt:<attemptId>"] }
{ "type": "ping" }
{ "type": "auth.refresh", "token": "<new jwt>" }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:
{
"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:
{ "type": "balance.updated", "channel": "user:<userId>", "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:<userId>
| 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: 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 is the server-side way to hear about settlement.
- Start a paid attempt is where the attempt id this channel is named after comes from.
- How a round works puts this step in the sequence around it.
Source: https://www.withchallenge.com/docs/realtime.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.