Challenge
Docs>Reference>Webhook events

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

EventTrigger
attempt.settledA score was reported and judged
attempt.refundedAn attempt expired with no outcome and the fee went back
challenge.calibratedA bar was placed and the Challenge went live
challenge.suspendedThe drift monitor pulled a live Challenge
settlement.paidA monthly period settled and your share accrued
tournament.settledA round closed, standings are final and ranks were paid
tournament.cancelledA 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.

FieldTypeMeaning
attempt_iduuidThe attempt that settled
challenge_iduuid or nullThe Challenge played. Null on a tournament attempt.
tournament_round_iduuid or nullThe round entered. Null on a Challenge attempt.
program_iduuidYour program
player_refstringYour own identifier for this player
scorenumberThe measurement you reported
met_thresholdbooleanChallenge's determination
thresholdnumber or nullThe bar in force when the attempt started. Null on a tournament attempt.
feenumberWhat this attempt cost, frozen when it was paid for
prize_awardednumberWhat 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_statusstring or nullpaid, 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_attimestampWhen 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.

FieldTypeMeaning
attempt_iduuidThe attempt that expired
challenge_iduuid or nullThe Challenge played. Null on a tournament attempt.
tournament_round_iduuid or nullThe round entered. Null on a Challenge attempt.
program_iduuidYour program
player_refstringYour own identifier for this player
fee_refundednumberWhat went back, which is the whole fee
reasonstringexpired
{ "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.

FieldTypeMeaning
challenge_iduuidThe Challenge that is now live
program_iduuidYour program
namestringThe Challenge's name
thresholdnumberThe bar, in the Challenge's own units
threshold_directionstringhigher_is_better or lower_is_better
sample_sizenumberTotal scores the bar was placed on
player_countnumberDistinct 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.

FieldTypeMeaning
challenge_iduuidThe Challenge that was pulled
program_iduuidYour program
namestringThe Challenge's name
reasonstringA sentence naming which test failed and with what numbers. Human-readable, not an enum.
realised_pass_ratenumberThe measured rate, as a fraction
target_pass_ratenumberWhat 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.

FieldTypeMeaning
settlement_iduuidThe settlement record
program_iduuidThe program that earned it
period_startdateFirst day of the settled period
period_enddateLast day of the settled period
marginnumberThe platform margin the period produced
amountnumberYour 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.

FieldTypeMeaning
round_iduuidThe round that settled
tournament_iduuidIts parent
tournament_namestring or nullDenormalised for readability
program_iduuidYour program
fundingstringAlways house
entrantsnumberPlayers on the board, meaning those who posted a score
positions_paidnumberHow many ranks received a payout
settled_attimestampWhen standings became final
pool_amountnumberAlways 0 on a house round
purse_amountnumberThe fixed purse this round paid
rollover_innumberAlways 0 on a house round
standingsarrayEvery ranked entrant, ordered by final rank

Each standings row:

FieldTypeMeaning
player_refstringYour own identifier for this player
ranknumberFinal rank
payoutnumberWhat that rank was paid. 0 for a rank outside the purse table.
payout_statusstringpaid, 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.

FieldTypeMeaning
round_iduuidThe round that was cancelled
tournament_iduuidIts parent
tournament_namestring or nullDenormalised for readability
program_iduuidYour program
reasonstringOne of the four values below
fundingstringAlways house
min_entrantsnumberThe field the round needed
entrants_with_scorenumberThe field it got
min_scored_attemptsnumberThe scored attempts the round needed. 0 on a round with no attempts gate
scored_attemptsnumberThe scored attempts it got
attempts_refundednumberHow many attempts were returned
cancelled_attimestampWhen the round was cancelled
early_cancel_atnumberMidpoint cancellations only, absent otherwise. The scored attempts the round had to reach by half time
checked_atstringMidpoint cancellations only, absent otherwise. Always midpoint
playersarray of stringsThe 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:

ValueMeaning
insufficient_entrantsFewer than min_entrants players posted a score by the close
insufficient_attemptsFewer than min_scored_attempts attempts were played to a score by the close
insufficient_attempts_midpointAt half time the round was under early_cancel_at, so it was ended a day early instead of being run out
program_suspendedThe 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:

MethodPOST, Content-Type: application/json
SignatureX-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw request body>
Event nameX-Webhook-Event
Delivery idX-Webhook-Delivery-Id, a UUID
SuccessAny 2xx. Anything else is a failure.
Timeout5 seconds
Retries3 attempts in total: the next is due 1 minute after the first failure, then 5 minutes after the second
OrderingNone. Events can arrive out of order.
SandboxPayloads 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 /v1 reference 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.