---
title: "API"
description: "Reference for the /v1 partner API: X-API-Key auth, decimal-string money, idempotency semantics, the error envelope, and every endpoint for challenges, players, attempts, tournaments and integrations."
url: https://www.withchallenge.com/docs/api
canonical_markdown: https://www.withchallenge.com/docs/api.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# 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](https://www.withchallenge.com/docs/report-score.md).

## The contract

### Base URL

```
https://api.withchallenge.com/v1
```

Local development is `http://localhost:4837/v1`. There is no separate sandbox host: the key decides the environment. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md).

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.

```bash
# 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](https://www.withchallenge.com/docs/programs-and-keys.md).

### 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](https://www.withchallenge.com/docs/webhook-events.md).

### The error envelope

```json
{
  "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](https://www.withchallenge.com/docs/errors.md).

### 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:

```json
{
  "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`:

```json
{
  "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](https://www.withchallenge.com/docs/trust-model.md).

---

## Challenges

The guide is [Challenges](https://www.withchallenge.com/docs/challenges.md).

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

```bash
# 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"
```

```json
{
  "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](https://www.withchallenge.com/docs/calibration.md).

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](https://www.withchallenge.com/docs/challenge-ladder.md).
- **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.

```bash
# 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
  }'
```

```json
{
  "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](https://www.withchallenge.com/docs/server-attempts.md).

### 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 |

```bash
# 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" }'
```

```json
{
  "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](https://www.withchallenge.com/docs/start-attempt.md) and [Report the score from your server](https://www.withchallenge.com/docs/report-score.md).

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

```bash
# 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" }
  }'
```

```json
{
  "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:

```bash
# 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"
  }'
```

```json
{
  "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.

```bash
# 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" }
  }'
```

```json
{
  "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:

```json
{
  "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.

```bash
# 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"
```

```json
{
  "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](https://www.withchallenge.com/docs/tournaments.md).

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

```bash
# 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"
```

```json
{
  "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. |

```bash
# 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"
```

```json
{
  "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:

```json
{
  "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. |

```bash
# 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": {} }'
```

```json
{
  "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](https://www.withchallenge.com/docs/webhooks.md).

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

```bash
# 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"
```

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

```bash
# 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.

```bash
# 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"
```

```json
{
  "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.

```bash
# 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](https://www.withchallenge.com/docs/challenges.md).
- **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](https://www.withchallenge.com/docs/calibration.md).
- **There is no endpoint that reports a win.** The outcome call accepts `score`, never `won`.
- **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](https://www.withchallenge.com/docs/report-score.md): the guide this page is the reference for
- [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md): the `player_ref` path for games with no browser client
- [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): every event Challenge emits, with payloads
- [Errors](https://www.withchallenge.com/docs/errors.md): every code, with cause and fix
- [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): what each key may touch

---

Source: https://www.withchallenge.com/docs/api.md
Docs index for AI agents: https://www.withchallenge.com/llms.txt
