Webhook events
Every event Challenge sends, what triggers it, and the exact payload it carries.
Runs on your server. The guide is Webhooks.
The catalog
| Event | Trigger |
|---|---|
attempt.settled | A score was reported and judged |
attempt.refunded | An attempt expired with no outcome and the fee went back |
challenge.calibrated | A bar was placed and the Challenge went live |
challenge.suspended | The drift monitor pulled a live Challenge |
settlement.paid | A monthly period settled and your share accrued |
tournament.settled | A round closed, standings are final and ranks were paid |
tournament.cancelled | A round could not run and every entry was refunded |
Subscribe to the events you handle and no others. An endpoint that answers 200 to events it ignores is indistinguishable from one that is quietly dropping the events it cares about.
Amounts are JSON numbers here
This is the one place in the platform where money is not a decimal string. /v1 responses use "4.50"; webhook payloads use 4.5. Round to cents when you store them, and do not compare a webhook amount to a /v1 amount without parsing both into the same type.
The reason to prefer the API anyway is stronger than formatting: on attempt.settled, re-fetch rather than reconstruct. Call GET /v1/attempts/{id} and trust that. The webhook is a notification that something changed, not the record of what it changed to.
attempt.settled
Trigger. Your server reported a score to POST /v1/attempts/{id}/outcome and Challenge judged it against the threshold frozen onto the attempt. Emitted for wins and losses alike, and for tournament attempts, which have no threshold to meet.
| Field | Type | Meaning |
|---|---|---|
attempt_id | uuid | The attempt that settled |
challenge_id | uuid or null | The Challenge played. Null on a tournament attempt. |
tournament_round_id | uuid or null | The round entered. Null on a Challenge attempt. |
program_id | uuid | Your program |
player_ref | string | Your own identifier for this player |
score | number | The measurement you reported |
met_threshold | boolean | Challenge's determination |
threshold | number or null | The bar in force when the attempt started. Null on a tournament attempt. |
fee | number | What this attempt cost, frozen when it was paid for |
prize_awarded | number | What actually moved: the award amount, or 0 when nothing was won AND 0 when a win is held for review. Reconcile paid winnings on this number, never on met_threshold. |
payout_status | string or null | paid, held, or null when the player did not win. held means the player cleared the bar but the payout is withheld pending a review, so prize_awarded is 0 even though met_threshold is true. A later change is not re-notified today, so re-fetch GET /v1/attempts/{id} if you need the resolution. |
settled_at | timestamp | When the outcome was written |
Exactly one of challenge_id and tournament_round_id is non-null, so a consumer reading this feed can tell which of the two parents settled without a second call.
{
"attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"tournament_round_id": null,
"program_id": "<<YOUR_PROGRAM_ID>>",
"player_ref": "player_42",
"score": 24,
"met_threshold": true,
"threshold": 27,
"fee": 1,
"prize_awarded": 4.5,
"payout_status": "paid",
"settled_at": "2026-08-24T17:14:36.201Z"
}On a tournament attempt this event is not a result. challenge_id is null, threshold is null, met_threshold is false and prize_awarded is 0, every time, because a round attempt buys a place on a rank table rather than a shot at a bar. What it pays, if anything, is decided at the round's close and arrives in tournament.settled.
{
"attempt_id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4",
"challenge_id": null,
"tournament_round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"program_id": "<<YOUR_PROGRAM_ID>>",
"player_ref": "player_42",
"score": 18.4,
"met_threshold": false,
"threshold": null,
"fee": 1,
"prize_awarded": 0,
"settled_at": "2026-08-24T17:15:02.884Z"
}Do not read met_threshold: false on a row with a tournament_round_id as a loss. Branch on which parent is set first.
attempt.refunded
Trigger. An attempt reached an hour with no outcome reported, so the expiry sweep closed it and returned the fee. Nothing your server did causes this; it is what happens when nothing did.
| Field | Type | Meaning |
|---|---|---|
attempt_id | uuid | The attempt that expired |
challenge_id | uuid or null | The Challenge played. Null on a tournament attempt. |
tournament_round_id | uuid or null | The round entered. Null on a Challenge attempt. |
program_id | uuid | Your program |
player_ref | string | Your own identifier for this player |
fee_refunded | number | What went back, which is the whole fee |
reason | string | expired |
{
"attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"tournament_round_id": null,
"program_id": "<<YOUR_PROGRAM_ID>>",
"player_ref": "player_42",
"fee_refunded": 1,
"reason": "expired"
}The attempt's status becomes expired and reporting an outcome on it afterwards is refused. A steady trickle of these means rounds are being started and never reported, which is usually a crash between startAttempt() and the outcome call, or an outcome call that is failing silently.
challenge.calibrated
Trigger. A bar was placed on a Challenge and it went live. Emitted from all three paths that can do that: the calibration sweep on a Challenge that collected enough observed attempts, a Challenge created on a metric that already had enough listened rounds, and the ladder sweep pricing a new rung.
| Field | Type | Meaning |
|---|---|---|
challenge_id | uuid | The Challenge that is now live |
program_id | uuid | Your program |
name | string | The Challenge's name |
threshold | number | The bar, in the Challenge's own units |
threshold_direction | string | higher_is_better or lower_is_better |
sample_size | number | Total scores the bar was placed on |
player_count | number | Distinct players in that sample |
{
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"program_id": "<<YOUR_PROGRAM_ID>>",
"name": "Fewest Moves",
"threshold": 27,
"threshold_direction": "lower_is_better",
"sample_size": 1042,
"player_count": 214
}The payload is identical from all three paths, and it does not say which one produced it. Read calibration_source from GET /v1/challenges?status=all when you need to know, and read fee and prize from the same place: neither is in this event, and the Challenge may be inside its go-live ramp at half the configured prize. See Calibration.
There is no separate event for a rung being spawned. A rung appears unpriced and is announced by this event when it is priced. See The Challenge ladder.
challenge.suspended
Trigger. The drift monitor pulled a live Challenge: its realised pass rate left the band around its target, or the expected payout at that realised rate exceeded the fee. No new attempts are accepted until it is reviewed.
| Field | Type | Meaning |
|---|---|---|
challenge_id | uuid | The Challenge that was pulled |
program_id | uuid | Your program |
name | string | The Challenge's name |
reason | string | A sentence naming which test failed and with what numbers. Human-readable, not an enum. |
realised_pass_rate | number | The measured rate, as a fraction |
target_pass_rate | number | What it was priced for, as a fraction |
{
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"program_id": "<<YOUR_PROGRAM_ID>>",
"name": "Fewest Moves",
"reason": "expected payout 1.35 exceeds the 1.00 fee at a realised pass rate of 30.0%",
"realised_pass_rate": 0.3,
"target_pass_rate": 0.1
}Do not parse reason. Branch on realised_pass_rate against target_pass_rate if you need to react differently to a bar that turned out too soft versus too hard. The wording is written for a person reading an alert.
Attempts already open when this fires keep the threshold captured at their creation, so nobody's result changes retroactively. Suspension is a circuit breaker, not a deletion.
settlement.paid
Trigger. A monthly settlement period was computed and your revenue share accrued to your payable balance. One event per program per period.
| Field | Type | Meaning |
|---|---|---|
settlement_id | uuid | The settlement record |
program_id | uuid | The program that earned it |
period_start | date | First day of the settled period |
period_end | date | Last day of the settled period |
margin | number | The platform margin the period produced |
amount | number | Your share of that margin, accrued to the developer account |
{
"settlement_id": "e2b90c14-77af-4d35-a6e1-05c8b3f2941d",
"program_id": "<<YOUR_PROGRAM_ID>>",
"period_start": "2026-07-01",
"period_end": "2026-07-31",
"margin": 1842.5,
"amount": 552.75
}amount is owed to the developer account, not to the program: a developer may run several programs and is paid once. program_id records which program earned it.
A period that earns nothing is still recorded, but it does not emit this event: the accrual is what the event announces, and there is no accrual. "We looked at July and it earned nothing" and "nobody has settled July yet" are different facts, and only the settlement record tells them apart.
tournament.settled
Trigger. A round closed with enough of a field to run, standings were computed and ranks were paid.
| Field | Type | Meaning |
|---|---|---|
round_id | uuid | The round that settled |
tournament_id | uuid | Its parent |
tournament_name | string or null | Denormalised for readability |
program_id | uuid | Your program |
funding | string | Always house |
entrants | number | Players on the board, meaning those who posted a score |
positions_paid | number | How many ranks received a payout |
settled_at | timestamp | When standings became final |
pool_amount | number | Always 0 on a house round |
purse_amount | number | The fixed purse this round paid |
rollover_in | number | Always 0 on a house round |
standings | array | Every ranked entrant, ordered by final rank |
Each standings row:
| Field | Type | Meaning |
|---|---|---|
player_ref | string | Your own identifier for this player |
rank | number | Final rank |
payout | number | What that rank was paid. 0 for a rank outside the purse table. |
payout_status | string | paid, held or none |
{
"round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46",
"tournament_name": "Daily Sprint",
"program_id": "<<YOUR_PROGRAM_ID>>",
"funding": "house",
"entrants": 41,
"positions_paid": 3,
"settled_at": "2026-08-24T00:02:14.663Z",
"pool_amount": 0,
"purse_amount": 100,
"rollover_in": 0,
"standings": [
{ "player_ref": "player_42", "rank": 1, "payout": 51, "payout_status": "paid" },
{ "player_ref": "player_318", "rank": 2, "payout": 31, "payout_status": "paid" },
{ "player_ref": "player_77", "rank": 3, "payout": 18, "payout_status": "paid" },
{ "player_ref": "player_205", "rank": 4, "payout": 0, "payout_status": "none" }
]
}standings carries every ranked entrant, not only the paid ones. A round with four hundred players on the board sends four hundred rows. Stream or page your handler accordingly, and do not assume the array is short.
pool_amount and rollover_in are legacy fields from the retired pooled model. They are always 0 and are kept only so older parsers do not break. purse_amount is the number that paid.
A row with payout_status: "held" is behind an open investigation. The money stays with Challenge until a person decides it, and the rank is real either way.
tournament.cancelled
Trigger. A round ended without enough play behind it to run, or its program was suspended before it could settle. That happens at the close, and it can also happen at the round's halfway point when the round is clearly not going to get there. Every attempt is refunded in full, the platform's cut included.
| Field | Type | Meaning |
|---|---|---|
round_id | uuid | The round that was cancelled |
tournament_id | uuid | Its parent |
tournament_name | string or null | Denormalised for readability |
program_id | uuid | Your program |
reason | string | One of the four values below |
funding | string | Always house |
min_entrants | number | The field the round needed |
entrants_with_score | number | The field it got |
min_scored_attempts | number | The scored attempts the round needed. 0 on a round with no attempts gate |
scored_attempts | number | The scored attempts it got |
attempts_refunded | number | How many attempts were returned |
cancelled_at | timestamp | When the round was cancelled |
early_cancel_at | number | Midpoint cancellations only, absent otherwise. The scored attempts the round had to reach by half time |
checked_at | string | Midpoint cancellations only, absent otherwise. Always midpoint |
players | array of strings | The player_ref of every entrant who was refunded |
{
"round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46",
"tournament_name": "Daily Sprint",
"program_id": "<<YOUR_PROGRAM_ID>>",
"reason": "insufficient_attempts",
"funding": "house",
"min_entrants": 12,
"entrants_with_score": 14,
"min_scored_attempts": 70,
"scored_attempts": 61,
"attempts_refunded": 61,
"cancelled_at": "2026-08-24T00:01:58.207Z",
"players": ["player_42", "player_318", "player_77"]
}reason is a closed set of four values:
| Value | Meaning |
|---|---|
insufficient_entrants | Fewer than min_entrants players posted a score by the close |
insufficient_attempts | Fewer than min_scored_attempts attempts were played to a score by the close |
insufficient_attempts_midpoint | At half time the round was under early_cancel_at, so it was ended a day early instead of being run out |
program_suspended | The program was suspended before the round could settle |
Both counts ride on every cancellation, whichever one refused the round. A round is judged on two conditions and they are not the same question: min_entrants is the field, min_scored_attempts is the play that pays for the purse. Reading reason alone tells you which condition failed; reading the four counts tells your players by how much. A min_scored_attempts of 0 is a round that opened before the attempts gate existed and is judged on its field alone.
A midpoint cancellation carries two extra fields and is the only kind that does, which is how a handler tells the two apart without parsing the reason string. early_cancel_at is a fraction of min_scored_attempts, not the gate itself: the round was so far short at half time that ending it then hands the field its money back while it is still worth having, rather than holding it for another day to refund it anyway.
The cancellation is not a failure to be retried. A fixed purse is underwritten by the minimum field and the attempts gate, and refunding is what that underwriting looks like when neither arrives. See Tournaments.
Delivery
The full walkthrough, including a handler you can paste, is in Webhooks. The operational facts:
| Method | POST, Content-Type: application/json |
| Signature | X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw request body> |
| Event name | X-Webhook-Event |
| Delivery id | X-Webhook-Delivery-Id, a UUID |
| Success | Any 2xx. Anything else is a failure. |
| Timeout | 5 seconds |
| Retries | 3 attempts in total: the next is due 1 minute after the first failure, then 5 minutes after the second |
| Ordering | None. Events can arrive out of order. |
| Sandbox | Payloads from an sk_test_ program carry an extra "test": true field |
Verify the signature over the raw bytes you received, before parsing. Re-serialising the JSON and signing that will not match.
Deduplicate on X-Webhook-Delivery-Id. It is unique per delivery and stable across every retry of that delivery, so storing it and ignoring a repeat is the whole of exactly-once handling on your side. The same underlying event can also be delivered to several endpoints, each with its own delivery id.
Answer 2xx fast and do the work afterwards. A handler that finishes a payout before responding is a handler that will be retried while it is still working.
Events that no longer exist
match.completed, match.draw, match.expired, match.disputed and match.voided are from the retired versus model. They are still accepted in endpoint configuration so existing rows stay valid, and nothing emits them. Do not subscribe to them and do not write handlers for them. There is no pooled wager, no PvP stake and no consensus settlement in the current model.
Related
- Webhooks: the guide, with a handler and the debug table
- Real-time settlement: how a result reaches the player's own client
- API: the
/v1reference to re-fetch from - Errors: the delivery failure table
Source: https://www.withchallenge.com/docs/webhook-events.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.