API reference
Every endpoint under /v1: authentication, money, idempotency, the error envelope, and the complete request and response for each route.
Runs on your server. The guide is Report the score from your server.
The contract
Base URL
https://api.withchallenge.com/v1Local development is http://localhost:4837/v1. There is no separate sandbox host: the key decides the environment. See Sandbox.
Anything under /v1 is stable. Fields are added, never removed or retyped, without a new version. Routes under /api carry no such promise; they belong to Challenge's own portals and the island, and they are free to change.
Authentication
Every request carries an X-API-Key header. sk_test_... is sandbox, sk_live_... is production.
# The key lives on your server. It never reaches the browser.
curl https://api.withchallenge.com/v1/challenges?player_ref=player_42 \
-H "X-API-Key: $CHALLENGE_API_KEY"The key resolves to exactly one program, so program is not a parameter on any route. That is what removes the class of tenancy bug where a request reads across partners: there is no "which of this developer's programs did they mean" question to answer wrong.
Every key failure is one 401 with the code unauthorized. A missing header, a malformed key, an unknown key, a test key used where a live one was expected, a live key on a program that is not yet approved, a suspended or closed program, and an account scheduled for deletion all answer identically. Distinguishing them would tell an attacker which half of a guess was right. See Programs and keys.
Money is a decimal string
Every amount in /v1 is a string: "25.00", never 25.0. Binary floats cannot represent cents exactly, and a partner parsing 25.00 as 24.999999 would be a bug Challenge owned. Parse them with a decimal type, not with parseFloat.
Scores and thresholds are JSON numbers, because they are measurements rather than money.
Webhook payloads are the one exception in the other direction: their amounts are JSON numbers. See Webhook events.
The error envelope
{
"error": {
"code": "challenge_already_claimed",
"message": "This player has already claimed this challenge.",
"details": {}
}
}code is a closed, versioned set. Branch on the code, never on the message. Wording is not part of the contract and may change at any time. details is present only on some validation failures.
The full catalog, with a cause and a fix for each code, is Errors.
Idempotency
Idempotency-Key is required on every endpoint that moves money: POST /v1/attempts, POST /v1/attempts/{id}/outcome and POST /v1/challenges/{id}/distribution. Use a fresh value per logical operation, typically a UUID, up to 255 characters.
| Situation | Answer |
|---|---|
| Header missing | 400 invalid_request |
| Header longer than 255 characters | 400 invalid_request |
| First use | The handler runs. The response is stored. |
| Replay, same key and same body | The original result, with Idempotency-Replayed: true. No handler code runs, nothing is charged again. Semantically identical, though JSON key order may differ, so never assert on the raw body. |
| Same key, different body | 409 idempotency_key_reused |
| Same key, original still in flight | 409 idempotency_in_progress. Retry shortly. |
| Same key, original died mid-flight over 60 seconds ago | The retry takes over and runs |
Records are retained for 24 hours, and the same key on a different endpoint is a different record. Only 2xx responses are stored: replaying a 500 would turn a transient failure into a permanent one.
This sits on top of the ledger's own idempotency. The ledger guarantees a repeated key moves no money, which protects the balance but not the caller: a retried request would still re-run the side effects around the transfer and hand back a different body. Every webhook retries, timeouts included, so "charged once but told twice" is a real state your code has to survive.
Cross-program resources answer not_found
A challenge, attempt, round or integration that belongs to another program answers 404 not_found, not 403. A 403 would confirm the resource exists. Tenancy is applied inside the query rather than checked after it, so there is no path where the wrong row is read and then rejected.
Rate limits
/v1 allows 300 requests per minute per API key. Over the limit the answer is 429 with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining headers. The 429 body is the limiter's own shape rather than the error envelope:
{
"error": "Too many requests, please try again later.",
"retry_after": 37,
"limit": 300,
"reset_at": "2026-08-24T18:22:41.006Z"
}The retired client-reporting route
POST /api/attempts/{id}/outcome was how a browser used to report a score. It is mounted, bare, and answers 410:
{
"error": "Scores are reported by the game's own server through the partner API at /v1, not from the browser.",
"code": "client_reporting_removed"
}410 rather than 404 because the resource is not missing, and rather than 403 because the caller is not unwelcome: it is retired. It carries no auth and no idempotency middleware, so a stale bundle in someone's cache costs nothing and gets an explanation instead of what it would read as an outage. Challenge.reportScore() throws the same code in the browser. See The trust model.
Challenges
The guide is Challenges.
GET /v1/challenges
The Challenges this player may attempt right now. Safe to render straight into a menu: everything returned is something POST /v1/attempts will accept for that player.
| Parameter | In | Required | Meaning |
|---|---|---|---|
player_ref | query | yes | Your own identifier for this player, up to 255 characters |
status | query | no | Omit for the player-facing list, which is live Challenges only. Pass all for your own view, which returns every status and reports calibration progress. |
# The key lives on your server. It never reaches the browser.
curl "https://api.withchallenge.com/v1/challenges?player_ref=player_42" \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"data": [
{
"id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"name": "Fewest Moves",
"description": "Solve the board in as few moves as you can.",
"fee": "1.00",
"prize": "4.50",
"currency": "USD",
"status": "live",
"prize_structure": "threshold",
"threshold": 27,
"min_calibration_n": 1000,
"calibration_source": "observed",
"metric_name": "moves",
"metric_unit": "moves",
"rung": 1,
"parent_challenge_id": null,
"ramp": {
"active": true,
"fullPrize": "9.00",
"attemptsSeen": 132,
"attemptsRequired": 200
}
}
]
}| Field | Type | Meaning |
|---|---|---|
id | uuid | The value you send as challenge_id |
name | string | What the player reads |
description | string or null | Optional longer line |
fee | decimal string | What one paid attempt costs. "0.00" while calibrating. |
prize | decimal string | The prize on offer to this player right now, not the prize configured |
currency | string | USD |
status | string | calibrating, live, suspended or closed |
prize_structure | string | threshold or top_n |
threshold | number or null | The bar. Null while calibrating. |
min_calibration_n | number | The attempt floor this Challenge calibrates against |
calibration_source | string | observed, listened, partner_seeded or rung_derived |
metric_name | string | The metric, in your game's vocabulary |
metric_unit | string | What it is measured in |
rung | number | How far up a ladder this sits. 1 for an ordinary Challenge. |
parent_challenge_id | uuid or null | The Challenge that must be claimed to unlock this one |
ramp | object | Present only while the go-live ramp is still capping the prize |
calibration | object | Present only when status is calibrating |
The ramp object carries active (always true when present), fullPrize as a decimal string, and attemptsSeen against attemptsRequired. The calibration object carries collected against required and playersCollected against playersRequired. See Calibration.
Three things are deliberately absent from the list:
- Challenges this player has already claimed. A Challenge pays one prize per player for life, so a claimed one cannot be attempted again and listing it would make you carry that rule.
- Locked rungs. A rung whose parent this player has not claimed is not attemptable yet. The island lists them, marked locked, because a locked rung is what a ladder sells; your own page is not Challenge's to put a "you cannot have this" card on. See The Challenge ladder.
- Anything the player is ineligible for, by jurisdiction, age, verification level or self-exclusion.
An unknown player_ref is not an error. Someone who has never played has claimed nothing, which claims none and locks every rung, and both of those are the true answer.
calibration_players, min_calibration_players and ramp_completed_at exist on the row and are stripped from the response. The first two are reported inside the calibration object instead; the third is internal bookkeeping that the ramp object says better.
Errors: 400 invalid_request when player_ref is missing, 401 unauthorized.
POST /v1/challenges/{id}/distribution
Skip the cold start by supplying percentiles from your own history. Requires Idempotency-Key.
You send percentiles rather than rows: that is all a threshold needs, it carries no personal data, and it comes out of one SQL query on your side.
| Field | Type | Required | Meaning |
|---|---|---|---|
distribution.p5 | number | yes | The 5th percentile of the metric, in the Challenge's own units |
distribution.p25 | number | yes | The 25th percentile |
distribution.p50 | number | yes | The median |
distribution.p75 | number | yes | The 75th percentile |
distribution.p95 | number | yes | The 95th percentile |
sample_size | integer | yes | How many rounds those percentiles came from. At least 500. |
Percentiles must rise from p5 to p95. Percentiles that do not ascend are not percentiles, and accepting them would interpolate nonsense.
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/challenges/<<YOUR_CHALLENGE_ID>>/distribution \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f2b1c47-6a0e-4f39-9d21-3b7c5e08a412" \
-d '{
"distribution": { "p5": 61, "p25": 48, "p50": 41, "p75": 35, "p95": 27 },
"sample_size": 12400
}'{
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"threshold": 28,
"target_pass_rate": 0.1,
"sample_size": 12400,
"calibration_source": "partner_seeded",
"provisional": true,
"note": "Live now, but capped at 20% of your tier ceiling until observed attempts confirm this distribution. The cap lifts automatically."
}201. The Challenge is live immediately, at a prize capped to 20% of your tier ceiling until the drift monitor confirms the claim against real paid attempts. Supplying the distribution means setting the threshold, which sets the pass rate, which sets Challenge's expected cost per attempt, so the cap is what makes an unverified claim safe to act on.
Errors: 400 invalid_request for a bad or non-ascending distribution, a sample_size under 500, a Challenge that is not calibrating (a live one would move the bar under players mid-flight; a suspended one is not reinstated this way), a Challenge that is a rung of a ladder (a rung's bar is measured from the players who claimed the rung below it and cannot be supplied), and for a challenge id this program does not own. 401 unauthorized. 409 on idempotency conflicts.
Players
The guide is Server-created attempts.
POST /v1/players
Link one of your player identifiers to an existing Challenge account. Without this, nothing can create the row that player_ref resolves through, so no server-created attempt can be made.
| Field | Type | Required | Meaning |
|---|---|---|---|
player_ref | string | yes | Your own identifier for this player |
email | string | yes | The email on the player's existing Challenge account |
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/players \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "player_ref": "player_42", "email": "player@example.com" }'{
"player_ref": "player_42",
"linked": true,
"created_at": "2026-08-24T17:04:11.882Z"
}201. No Idempotency-Key: the link upserts on the program and the ref, so repeating the call is naturally safe and returns the same answer. Idempotency belongs where repetition is unsafe, not on every write.
There is no auto-provisioning on first attempt, deliberately. A player_ref is a string you chose, and treating it as authority to create a Challenge account would mint accounts that hold money with no email, no age check and no consent. The player signs up with Challenge; you link the account they already have.
Errors: 400 invalid_request when either field is missing. 404 not_found when no Challenge account matches the email, phrased as a plain absence rather than "no such user": confirming which emails have accounts would let any partner enumerate Challenge's players. 401 unauthorized.
Attempts
The guides are Start a paid attempt and Report the score from your server.
POST /v1/attempts
Create an attempt and charge the fee in the same transaction. Requires Idempotency-Key.
| Field | Type | Required | Meaning |
|---|---|---|---|
challenge_id | uuid | one of two | Play a Challenge for a fixed prize Challenge funds |
tournament_round_id | uuid | one of two | Buy a place in that round's rank table |
player_ref | string | yes | Your own identifier for this player, up to 255 characters |
metadata | object | no | Opaque to Challenge, stored and returned on reads of the attempt |
Send exactly one parent. Sending both, or neither, is 400 invalid_request. The two are not interchangeable: one is judged against a frozen bar for a fixed prize, the other buys a place in a round that pays by rank. A request naming both has not said which deal it wants, and picking one would be picking how a player's money is used. The same rule is enforced by a check constraint on the table.
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/attempts \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2c8e4d10-77a1-4d0f-b1c3-9e5a6f2b0d84" \
-d '{
"challenge_id": "<<YOUR_CHALLENGE_ID>>",
"player_ref": "player_42",
"metadata": { "session": "s_9931" }
}'{
"id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"tournament_round_id": null,
"player_ref": "player_42",
"status": "open",
"fee_charged": "1.00",
"created_at": "2026-08-24T17:09:52.310Z",
"metadata": { "session": "s_9931" },
"outcome": null
}Entering a tournament round is the same call with the other parent:
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/attempts \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6b1f9c33-05de-4a72-8f44-c02e17b95a6d" \
-d '{
"tournament_round_id": "<<YOUR_ROUND_ID>>",
"player_ref": "player_42"
}'{
"id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4",
"challenge_id": null,
"tournament_round_id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"player_ref": "player_42",
"status": "open",
"fee_charged": "1.00",
"created_at": "2026-08-24T17:11:03.774Z",
"metadata": {},
"outcome": null
}201 in both cases. Exactly one of challenge_id and tournament_round_id is ever non-null, on this response and on every later read of the attempt, and which one it is says whether the attempt is playing for a fixed prize or for a rank.
A replay returns the original body with Idempotency-Replayed: true. It charges nothing further, and on a tournament round it consumes no second slot against the round's attempt cap.
Errors:
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | Both parents, neither parent, no player_ref, or a missing Idempotency-Key |
401 | unauthorized | Any key failure |
402 | insufficient_funds | The player cannot fund the fee. Nothing was charged. |
404 | not_found | No such challenge in this program, or no such round |
409 | challenge_not_available | Suspended, closed, or a prize over your integrity tier ceiling |
409 | challenge_already_claimed | This player has been paid this Challenge. Permanent for the pair. |
409 | rung_locked | A rung whose parent this player has not claimed. Clears when they claim it. |
409 | attempt_cap_reached | The Challenge's rolling daily cap, or the round's per-player cap |
409 | entries_closed | The round is not open, or it is inside the ten minutes before it closes when re-buys stop |
409 | idempotency_key_reused / idempotency_in_progress | See the idempotency table above |
No 409 charges anything.
POST /v1/attempts/{attempt_id}/outcome
Report what the player achieved. Requires Idempotency-Key.
| Field | Type | Required | Meaning |
|---|---|---|---|
score | number | yes | The measured result, in the Challenge's own units |
tiebreak | number | on tournament attempts | The tournament's declared tiebreak metric for this attempt, e.g. completion time for a move-count game. Required when the attempt belongs to a tournament round whose tournament declares one; the report is refused with tiebreak_required (422) without it, and the attempt stays open for a correct report. Ignored on Challenge attempts. |
evidence | object | no | Whatever proves the score: a replay trace, a signed device payload, a CI run id. Stored verbatim and replayable in a dispute. |
Note what this endpoint does not accept: whether the player won. That is Challenge's determination, applied against the threshold frozen onto the attempt at its creation. You supply a measurement; Challenge applies the standard. It is also why generous reporting cannot inflate payouts.
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/attempts/<<YOUR_ATTEMPT_ID>>/outcome \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: b7d3f2a9-4e61-4c08-a5f7-31d0b96e28c1" \
-d '{
"score": 24,
"evidence": { "replay": "r_7731", "engine": "1.4.2" }
}'{
"attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"score": 24,
"met_threshold": true,
"threshold_applied": 27,
"recorded_at": "2026-08-24T17:14:36.201Z",
"receipt_id": "d81c6a45-3f97-4be2-8a05-72e1c4b09f36",
"award": {
"id": "9a4e17c0-6d5b-4832-b7f1-08c3e5d2a641",
"amount": "4.50",
"payout_status": "paid",
"paid_at": "2026-08-24T17:14:36.318Z"
}
}| Field | Type | Meaning |
|---|---|---|
attempt_id | uuid | The attempt this settles |
score | number | What you reported, echoed |
met_threshold | boolean | Challenge's determination, not the reporter's |
threshold_applied | number or null | The bar in force when this attempt started. A later recalibration never changes whether a past attempt won. |
recorded_at | timestamp | When the outcome was written |
receipt_id | uuid | Signed record of the reported evidence, replayable in a dispute |
award | object or null | Present only when the player cleared the bar. Carries id, amount as a decimal string, payout_status, and paid_at. |
award.payout_status | string | paid when the money moved, or held when the win is recorded but withheld pending a review (a fraud or enforcement flag on the player, or a payout-velocity trip). A held award has paid_at: null and moved no money; the player cleared the bar (met_threshold is still true), and a reviewer releases or forfeits it. Reconcile paid winnings on payout_status: "paid", never on the presence of award. |
A tournament attempt settles through this same endpoint and gets a different shape, because there is no bar and no per-attempt prize on a rank table:
{
"attempt_id": "c4a71e08-92d6-4f5b-8e33-2a7b0c916dd4",
"score": 24,
"met_threshold": false,
"threshold_applied": null,
"recorded_at": "2026-08-24T17:15:02.884Z",
"receipt_id": "5f0b8d21-97ac-4e63-b118-2d4a7c60e839",
"award": null
}met_threshold: false here is not a loss. It is the honest answer to a question that does not apply: the score has been entered on the round's board, and the round pays by rank at its close. award stays null because a tournament pays through settlement, not through the outcome. receipt_id is written exactly as it is on a Challenge.
Errors:
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | score is not a finite number, or Idempotency-Key is missing |
401 | unauthorized | Any key failure |
404 | not_found | No such attempt, or it belongs to another program |
409 | attempt_already_settled | This attempt already has an outcome |
409 | round_closed | The round stopped accepting scores. The attempt expires and its entry is refunded by the settlement sweep, so a late report costs the player nothing. |
422 | score_out_of_range | The score lies outside the plausibility window the Challenge declares. Nothing settles and no fee is charged back; the attempt is left to expire and refund. This fires only on a Challenge that has declared a bound, and it is a refusal of a malformed measurement, not a held win. |
On attempt.settled, re-fetch rather than reconstruct: call GET /v1/attempts/{id} and trust that.
GET /v1/attempts/{attempt_id}
Read one attempt and its outcome. The authoritative answer for what an attempt did.
# The key lives on your server. It never reaches the browser.
curl https://api.withchallenge.com/v1/attempts/<<YOUR_ATTEMPT_ID>> \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"challenge_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
"tournament_round_id": null,
"player_ref": "player_42",
"status": "settled",
"fee_charged": "1.00",
"created_at": "2026-08-24T17:09:52.310Z",
"metadata": { "session": "s_9931" },
"outcome": {
"attempt_id": "0f5d2a63-1b84-4c7e-9a10-6d3f8e2c4b57",
"score": 24,
"met_threshold": true,
"threshold_applied": 27,
"recorded_at": "2026-08-24T17:14:36.201Z",
"receipt_id": "d81c6a45-3f97-4be2-8a05-72e1c4b09f36",
"award": {
"id": "9a4e17c0-6d5b-4832-b7f1-08c3e5d2a641",
"amount": "4.50",
"paid_at": "2026-08-24T17:14:36.318Z"
}
}
}| Field | Type | Meaning |
|---|---|---|
status | string | open, settled, expired or voided |
fee_charged | decimal string | What this attempt actually cost, frozen when it was paid for |
metadata | object | Whatever you sent at creation. {} when you sent none. |
outcome | object or null | Null while the attempt is still open |
An attempt with no outcome after an hour expires and the fee is refunded, which is status: "expired" here and an attempt.refunded webhook.
Errors: 401 unauthorized, 404 not_found for an unknown attempt or one belonging to another program.
Tournaments
The guide is Tournaments.
GET /v1/tournaments
This program's tournaments, each with the round it is running.
| Parameter | In | Required | Meaning |
|---|---|---|---|
status | query | no | Omit for the player-facing list, which is active tournaments only. Pass all for your own view, which includes paused and closed ones. |
There is no player_ref here, and the absence is deliberate. A Challenge list is filtered per player because eligibility decides what may be offered. A tournament round is one public board, identical for everyone, so asking for a player identifier to return a list that does not depend on it would be collecting an identifier Challenge has no use for.
# The key lives on your server. It never reaches the browser.
curl https://api.withchallenge.com/v1/tournaments \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"data": [
{
"id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46",
"name": "Daily Sprint",
"description": "Fastest clear of the day.",
"metric_name": "clear time",
"metric_unit": "seconds",
"threshold_direction": "lower_is_better",
"entry_fee": "1.00",
"attempt_cap": 10,
"status": "active",
"created_at": "2026-07-02T09:00:00.000Z",
"current_round": {
"id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46",
"tournament_name": "Daily Sprint",
"status": "open",
"opens_at": "2026-08-24T00:00:00.000Z",
"closes_at": "2026-08-25T00:00:00.000Z",
"entries_close_at": "2026-08-24T23:50:00.000Z",
"settled_at": null,
"entry_fee": "1.00",
"funding": "house",
"purse_amount": "100.00",
"purse_table": [
{ "rank": 1, "amount": "51.00" },
{ "rank": 2, "amount": "31.00" },
{ "rank": 3, "amount": "18.00" }
],
"pool_amount": "0.00",
"rollover_in": "0.00",
"entrant_count": 37,
"attempt_cap": 10,
"min_entrants": 24
}
}
]
}The tournament object:
| Field | Type | Meaning |
|---|---|---|
id | uuid | The tournament |
name | string | What the player reads |
description | string or null | Optional longer line |
metric_name | string | What is measured, in your game's vocabulary |
metric_unit | string | What it is measured in |
threshold_direction | string | higher_is_better or lower_is_better. Which end of the scale wins the round. |
entry_fee | decimal string | Charged per attempt, flat. An entry and a re-buy cost the same. |
attempt_cap | integer | Paid attempts one player may buy in one round |
status | string | active, paused or closed |
created_at | timestamp | When the tournament was created |
current_round | object or null | See below |
current_round semantics. It is the open round when there is one, and otherwise the most recent round there has been, so a tournament between rounds still renders with the board its last round finished on. It is null only before the first round has ever spawned. Read current_round.id to enter, and check current_round.status first: only an open round accepts entries.
The round object, which appears here and on the leaderboard response:
| Field | Type | Meaning |
|---|---|---|
id | uuid | The value you send as tournament_round_id |
tournament_id | uuid | Its parent |
tournament_name | string or null | Denormalised, so a round renders without a second lookup |
status | string | open, settling, settled or cancelled |
opens_at | timestamp | When entries began |
closes_at | timestamp | Scores are accepted until this moment |
entries_close_at | timestamp | When re-buys stop, ten minutes before closes_at |
settled_at | timestamp or null | When standings became final |
entry_fee | decimal string | Frozen at spawn. Editing the tournament never changes a running round. |
funding | string | Always "house" |
purse_amount | decimal string | The fixed purse this round pays, frozen at spawn |
purse_table | array | The literal frozen split, as { rank, amount } rows with amount a decimal string |
pool_amount | decimal string | Always "0.00" |
rollover_in | decimal string | Always "0.00" |
entrant_count | integer | Distinct players who have bought at least one attempt |
attempt_cap | integer | Paid attempts one player may buy in this round |
min_entrants | integer | The field this round needs to run |
min_scored_attempts | integer | The scored attempts this round needs to run. 0 means no attempts gate. |
funding is always "house": Challenge funds a fixed purse, announced before the round opened and identical at any field size. pool_amount and rollover_in are legacy fields from the retired pooled model. They are always "0.00" and are kept only so parsers written against the older shape do not break. Do not compute a prize from them; purse_amount and purse_table are the numbers that pay.
min_entrants is a condition of the contest, not an internal threshold, and is worth showing to players: below it at close, the round cancels and every entry is refunded in full. That refund is how a fixed purse is underwritten.
settling is the intermediate status between open and settled: the round has closed and standings are being computed. A round in settling accepts no entries and its final_rank fields are not yet written.
Errors: 401 unauthorized.
GET /v1/tournaments/rounds/{round_id}/leaderboard
The rank table for one round. Live standings while it is open, the final table once it has settled.
The board is enter to see. Ranks and scores are returned only for a player who has paid for at least one attempt in this round, so player_ref says who you are rendering for. Name a player who has entered and you get the board below. Send no player_ref, an unknown one, or one who has not entered this round, and you get the masked shape instead: a 200 with locked: true and no data. A valid API key is not a player, so a call with no player_ref is always masked.
A live board is a neighborhood, not the whole table. While the round is running, data carries only the named player's own row with one rank above and one below: the race they are actually in. There is no always-visible top; the leading rows come into view only as the player climbs next to them. A player who has paid but not yet scored gets an empty data array. scored_players on the response says how long the full table is, so "rank 6 of 41" is renderable without the rest of it, and the rank numbers say where the visible rows sit. Once the round is settled or cancelled, data is the complete final table. Render what you receive: the rows are the board that player is allowed to see, and padding it out from your own records would defeat the point of the cut.
| Parameter | In | Required | Meaning |
|---|---|---|---|
round_id | path | yes | The round to read |
player_ref | query | no | The player you are rendering for. Ranks and scores are returned only when this player has paid for an attempt in this round. |
# The key lives on your server. It never reaches the browser.
curl "https://api.withchallenge.com/v1/tournaments/rounds/<<YOUR_ROUND_ID>>/leaderboard?player_ref=player_42" \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"round": {
"id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805",
"tournament_id": "b60f4d92-8c31-4a75-9e02-1f7d5a3c8b46",
"tournament_name": "Daily Sprint",
"status": "settled",
"opens_at": "2026-08-23T00:00:00.000Z",
"closes_at": "2026-08-24T00:00:00.000Z",
"entries_close_at": "2026-08-23T23:50:00.000Z",
"settled_at": "2026-08-24T00:02:14.663Z",
"entry_fee": "1.00",
"funding": "house",
"purse_amount": "100.00",
"purse_table": [
{ "rank": 1, "amount": "51.00" },
{ "rank": 2, "amount": "31.00" },
{ "rank": 3, "amount": "18.00" }
],
"pool_amount": "0.00",
"rollover_in": "0.00",
"entrant_count": 41,
"attempt_cap": 10,
"min_entrants": 24
},
"scored_players": 37,
"data": [
{
"rank": 1,
"player_ref": "player_42",
"best_score": 18.4,
"best_score_at": "2026-08-23T19:41:07.552Z",
"paid_attempts": 6,
"final_rank": 1,
"payout_amount": "51.00",
"payout_status": "paid"
},
{
"rank": 2,
"player_ref": "player_318",
"best_score": 18.9,
"best_score_at": "2026-08-23T21:03:55.118Z",
"paid_attempts": 4,
"final_rank": 2,
"payout_amount": "31.00",
"payout_status": "paid"
}
]
}| Field | Type | Meaning |
|---|---|---|
rank | integer | Position right now. Final once the round's status is settled. |
player_ref | string | Your own identifier for this player. Never the Challenge user id and never the Challenge username: this is the only identity you can reconcile against your side. |
best_score | number | Their best settled score in this round, in the tournament's units |
best_tiebreak | number or null | The tiebreak value of the run holding best_score. Null on tournaments with no declared tiebreak metric. |
best_score_at | timestamp | When that score was posted |
paid_attempts | integer | How many attempts they bought |
final_rank | integer or null | Written at settlement. Null while the round is still running. |
payout_amount | decimal string or null | Null while no payout decision has been made for this row |
payout_status | string | none, paid, held or refunded |
Only players who have posted a score appear.
Ties break on play, then on the clock: the better score, then the better best_tiebreak on the tournament's declared tiebreak metric (null ranks worse than any value), then the earlier best_score_at, then the entry id. The tiebreak metric is what keeps a round alive on a metric with a reachable best: a player who matches the leading score wins by beating their tiebreak, right now, in the game, instead of having lost to an earlier post. The timestamp and the entry id are last-resort determinism, so two computations of the same board always agree. best_tiebreak is the tiebreak of the run that holds best_score: one performance, ranked as a pair. On tournaments predating the tiebreak rule every best_tiebreak is null and ties fall to the clock as before.
Before settlement, final_rank is null, payout_amount is null and payout_status is none. That combination says the table is not final yet. Null is not zero, and this field never confuses the two: null means no decision has been made, "0.00" would mean a decision was made and it pays nothing.
After settlement, payout_status is paid for a rank that has been paid, held for one behind an open investigation, and refunded for every entrant of a round cancelled for too small a field.
The masked shape, returned when the caller has not named a player who has entered this round:
{
"locked": true,
"reason": "enter_to_see",
"round": { "id": "3e9a5b71-c284-4d16-90fa-77b1e6d3c805", "status": "open", "...": "the same round object as above" },
"entrant_count": 41,
"scored_players": 37,
"min_entrants": 12,
"min_scored_attempts": 70,
"purse_amount": "1000.00",
"purse_table": [
{ "rank": 1, "amount": "510.00" },
{ "rank": 2, "amount": "310.00" },
{ "rank": 3, "amount": "180.00" }
],
"closes_at": "2026-08-24T00:00:00.000Z"
}| Field | Type | Meaning |
|---|---|---|
locked | boolean | Always true. Absent on a full board, so body.locked is the branch. |
reason | string | Always "enter_to_see" |
round | object | The same round object as above, complete. Nothing in it is a rank or a score. |
entrant_count | integer | Distinct players who have bought at least one attempt |
scored_players | integer | Distinct players who have posted a score. A count, and nothing about who they are. |
min_entrants | integer | The field this round needs to run |
min_scored_attempts | integer | The scored attempts this round needs to run |
purse_amount | decimal string | The fixed purse |
purse_table | array | The frozen split, as { rank, amount } rows |
closes_at | timestamp | When the round ends |
There is no data array on a masked response. An empty array would be a claim that nobody has scored, and this response is not making one; scored_players is the honest count.
It is a 200 rather than a refusal on purpose. Not having entered yet is the ordinary state of a player who has not entered yet, so the response keeps its shape and every field on it is something you would render on the pre-entry screen. Branch on locked, not on a status code.
Live scores were visible to any caller before 26 August 2026 and are not any more. Under a house-funded purse what underwrites the prize is turnout, so a player deterred by the board does not shrink the purse, they raise the chance the round cancels and refunds. Everything the round promises stays visible; the field's scores are behind the fee.
Errors: 401 unauthorized. 404 not_found for an unknown round and for a round belonging to another program: tenancy is applied to the query, so another partner's round is absent rather than forbidden. Tenancy is checked before the board rule, so another partner's round is not_found rather than masked.
Integrations
An integration is how Challenge and your systems exchange outcomes. Creating one from the API is what makes the descriptor architecture usable without hand-written SQL.
Two things happen at save time that cannot happen later. The descriptor is validated, so a mistyped path is caught while you configure rather than when your first live outcome silently fails to map and a player goes unpaid. And a pull endpoint is checked for safety at save time as well as at fetch time, so an internal URL cannot sit in the database waiting for the poller.
| Transport | What it means |
|---|---|
push_webhook | Your system posts outcomes to Challenge. Gets a signing secret. |
pull_poll | Challenge polls an endpoint you host, on an interval you set |
sdk_direct | Your server calls /v1 directly. No transport configuration and no secret. |
All five routes answer only four codes: invalid_request (400), unauthorized (401), not_found (404) and internal_error (500). None of them takes an Idempotency-Key.
POST /v1/integrations
| Field | Type | Required | Meaning |
|---|---|---|---|
transport | string | yes | push_webhook, pull_poll or sdk_direct |
descriptor | object | no | How to read your payloads. Validated here. Defaults to {}. |
pull_interval_seconds | integer | no | For pull_poll. At least 30. |
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/integrations \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transport": "push_webhook", "descriptor": {} }'{
"id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f",
"transport": "push_webhook",
"status": "draft",
"descriptor": {},
"has_signing_secret": true,
"pull_interval_seconds": null,
"last_polled_at": null,
"next_poll_at": null,
"consecutive_failures": 0,
"last_poll_error": null,
"created_at": "2026-08-24T17:20:44.019Z",
"signing_secret": "<<SIGNING_SECRET>>",
"webhook_url": "/webhooks/integrations/a1c73f65-9082-4be7-8d41-5c26e0b93a7f/outcome",
"signing_instructions": "Send X-Challenge-Signature: sha256=<hex HMAC-SHA256 of the raw request body>. Store this secret now; it cannot be retrieved again."
}201. The secret is 64 hexadecimal characters. It is shown exactly once, in this response, and is never returned again by any route. A secret readable on demand is a secret that leaks through every subsequent bug: a logged response, an over-broad admin view, a support screenshot. Store it now.
Only push_webhook gets a secret, along with webhook_url and signing_instructions. Generating one for every transport would create secrets nobody uses and everyone has to protect.
Every integration is created draft. One that went live on creation would start accepting money-moving callbacks before anyone had reviewed it.
X-Challenge-Signature is the header you send Challenge on a push webhook. It is not the same as X-Webhook-Signature, which is the header Challenge sends you on an outbound event. See Webhooks.
Errors: 400 invalid_request for an unknown transport, a descriptor that fails validation, a pull_interval_seconds under 30 or not an integer, or a pull endpoint that is not a safe URL.
GET /v1/integrations
Every integration on this program, newest first.
# The key lives on your server. It never reaches the browser.
curl https://api.withchallenge.com/v1/integrations \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"data": [
{
"id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f",
"transport": "push_webhook",
"status": "active",
"descriptor": {},
"has_signing_secret": true,
"pull_interval_seconds": null,
"last_polled_at": null,
"next_poll_at": null,
"consecutive_failures": 0,
"last_poll_error": null,
"created_at": "2026-08-24T17:20:44.019Z"
}
]
}| Field | Type | Meaning |
|---|---|---|
id | uuid | The integration |
transport | string | push_webhook, pull_poll or sdk_direct |
status | string | draft, active or suspended |
descriptor | object | How Challenge reads your payloads |
has_signing_secret | boolean | Enough to answer "is it configured" without disclosing anything |
pull_interval_seconds | integer or null | For pull_poll |
last_polled_at | timestamp or null | The last poll attempt |
next_poll_at | timestamp or null | When the next poll is due |
consecutive_failures | integer | Reset on the first success |
last_poll_error | string or null | The most recent poll failure |
created_at | timestamp | When it was created |
has_signing_secret is the only thing said about the secret anywhere after creation.
PATCH /v1/integrations/{id}
| Field | Type | Required | Meaning |
|---|---|---|---|
descriptor | object | no | Replaces the stored descriptor. Validated. |
status | string | no | draft, active or suspended |
pull_interval_seconds | integer | no | At least 30 |
# The key lives on your server. It never reaches the browser.
curl -X PATCH https://api.withchallenge.com/v1/integrations/<<YOUR_INTEGRATION_ID>> \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'The response is the same public view as the list, with the update applied. Only the fields you send are changed.
Errors: 400 invalid_request for a status outside the three, a descriptor that fails validation, a bad interval, an unsafe pull endpoint, and for activating a push_webhook that has no signing secret. 404 not_found for an unknown id or one on another program.
POST /v1/integrations/{id}/rotate-secret
Issue a new signing secret while the old one keeps verifying.
# The key lives on your server. It never reaches the browser.
curl -X POST https://api.withchallenge.com/v1/integrations/<<YOUR_INTEGRATION_ID>>/rotate-secret \
-H "X-API-Key: $CHALLENGE_API_KEY"{
"id": "a1c73f65-9082-4be7-8d41-5c26e0b93a7f",
"transport": "push_webhook",
"status": "active",
"descriptor": {},
"has_signing_secret": true,
"pull_interval_seconds": null,
"last_polled_at": null,
"next_poll_at": null,
"consecutive_failures": 0,
"last_poll_error": null,
"created_at": "2026-08-24T17:20:44.019Z",
"signing_secret": "<<SIGNING_SECRET>>",
"note": "The previous secret still verifies until you retire it. Deploy this one, then call DELETE /v1/integrations/{id}/previous-secret."
}The rotation overlaps on purpose. A hard cutover drops every delivery already in flight, which is the reason partners avoid rotating at all, and a secret nobody rotates is worse than an awkward rotation. Deploy the new secret, confirm traffic verifies against it, then retire the old one.
This is the second and last time a secret is returned, and it is a different secret from the first.
Errors: 400 invalid_request when the integration is not a push_webhook, since only those have a signing secret. 404 not_found for an unknown id or one on another program.
DELETE /v1/integrations/{id}/previous-secret
Retire the old secret. After this, only the current one verifies.
# The key lives on your server. It never reaches the browser.
curl -X DELETE https://api.withchallenge.com/v1/integrations/<<YOUR_INTEGRATION_ID>>/previous-secret \
-H "X-API-Key: $CHALLENGE_API_KEY"The response is the public view. Safe to call when there is no previous secret; it is a no-op.
Errors: 404 not_found for an unknown id or one on another program.
What is not in this API
- There is no
POST /v1/challenges. Creating a Challenge is a pricing decision Challenge participates in, made in the Developer Portal. See Challenges. - There is no endpoint that sets a threshold, a fee or a prize. The bar is measured, and the fee and prize follow from it. See Calibration.
- There is no endpoint that reports a win. The outcome call accepts
score, neverwon. - There is no way to read a signing secret after the response that created or rotated it.
- There is no self-serve live-key rotation. Live keys are issued at approval. Test keys can be regenerated from the Developer Portal.
Related
- Report the score from your server: the guide this page is the reference for
- Server-created attempts: the
player_refpath for games with no browser client - Webhook events: every event Challenge emits, with payloads
- Errors: every code, with cause and fix
- Programs and keys: what each key may touch
Source: https://www.withchallenge.com/docs/api.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.