---
title: "Errors"
description: "Every error code with its cause and fix, grouped by surface: the codes Challenge.js throws, the closed /v1 code set with HTTP statuses, and the webhook delivery debug table."
url: https://www.withchallenge.com/docs/errors
canonical_markdown: https://www.withchallenge.com/docs/errors.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# Errors

Every code Challenge can hand you, what caused it, and what to do about it.

The guides that raise these are [Start a paid attempt](https://www.withchallenge.com/docs/start-attempt.md), [Report the score from your server](https://www.withchallenge.com/docs/report-score.md) and [Webhooks](https://www.withchallenge.com/docs/webhooks.md).

Codes are 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.

## The ones you will actually meet

| Code | Surface | What it usually means |
|---|---|---|
| `unauthorized` | API | The key is missing, malformed, for the wrong environment, or on a program that is not approved |
| `invalid_request` | API | A missing `Idempotency-Key`, both parents on `POST /v1/attempts`, or a `score` that is not a number |
| `not_found` | API | A resource that belongs to another program, or an id that does not exist |
| `insufficient_funds` | Both | The player cannot fund the fee. Nothing was charged. |
| `attempt_already_settled` | API | Your outcome call ran twice without an `Idempotency-Key` |
| `challenge_already_claimed` | API | This player has already been paid this Challenge. Permanent for the pair. |
| `blocked` | Both | This player may not play for money: their own responsible gaming decision, or a state Challenge does not serve |
| `insider_entry` | API | The account behind this `player_ref` is linked to your developer account, so it cannot enter your own live tournament |
| `not_armed` | Challenge.js | `startAttempt()` ran before the player armed anything |
| `client_reporting_removed` | Both | Something is still calling the retired browser-side reporting path |

---

## Challenge.js

Rejections from `Challenge.startAttempt()` are an `Error` with `name: 'ChallengeAttemptError'` and a `.code`. The removed `Challenge.reportScore()` throws the same shape. `Challenge.record()` never rejects at all. See [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md).

Most of these are already handled for the player: the island shows a note, offers a deposit or re-syncs itself. Your branch usually only needs to not start the round.

### not_initialised

**Cause.** `startAttempt()` was called before `Challenge.init()`, or the island was destroyed while the request was in flight.

**Fix.** Call `Challenge.init({ program })` once when your page or component mounts, and make sure your round loop cannot start an attempt before it. In a single-page app, this code also fires when a route change ran `destroy()` mid-round, which is a sign the teardown is happening in the wrong place.

### not_armed

**Cause.** Nothing is armed for this player, neither a Challenge nor a tournament round, and no `{ tournamentRound }` was named.

**Fix.** Nothing, in most integrations. Arming is the player's act on the island, and there is no `arm()` for a game to call on their behalf. Treat this as "this round is not for money" and run it as an ordinary round, calling `Challenge.record()` at the end as usual.

### attempt_in_progress

**Cause.** A paid round is already live for this player. An attempt ends when your server reports its score, or when it expires after an hour.

**Fix.** Do not start a second attempt until the first has settled. If you are seeing this without a second round having started, your server is not reporting outcomes: check that `POST /v1/attempts/{id}/outcome` is being called and is answering `200`.

### client_reporting_removed

**Cause.** `Challenge.reportScore()` was called. It was removed and does not exist any more.

**Fix.** Delete the call. Report the score from the server that holds your secret key, with `POST /v1/attempts/{id}/outcome`. The island hears the settlement on its own socket and renders the result with nothing further for your game to call. See [The trust model](https://www.withchallenge.com/docs/trust-model.md).

### network

**Cause.** The request never reached Challenge, after its retries.

**Fix.** Let the round run unpaid rather than blocking on it, and log the failure. Nothing was charged. Persistent occurrences point at a content-security-policy or an ad blocker in the player's browser rather than at Challenge.

### unauthorized

**Cause.** The player's own session is not valid: signed out, or expired.

**Fix.** Nothing. The island puts the sign-in surface up itself. Do not build a sign-in of your own.

### insufficient_funds

**Cause.** The player's balance does not cover the fee. Nothing was charged.

**Fix.** Nothing. The island shows the message and offers a deposit.

### challenge_not_available

**Cause.** The Challenge is suspended, closed, or its prize exceeds your integrity tier ceiling. It is also what the server answers when it and the island disagree about what is armed.

**Fix.** Nothing at the call site. If it is persistent, check the Challenge's `status` with `GET /v1/challenges?status=all` and read [Calibration](https://www.withchallenge.com/docs/calibration.md) for what suspends one. For a ceiling refusal, see [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md).

### blocked

**Cause.** A responsible gaming decision the player made about themselves: a deposit limit, a cool-off, a self-exclusion.

**Fix.** Nothing, ever. The island shows the player's own words back to them verbatim. Do not offer a way around it and do not retry.

### unknown

**Cause.** The server refused with a code this list does not name. Server-side conflict codes pass through unchanged, so `challenge_already_claimed`, `rung_locked`, `attempt_cap_reached` and `entries_closed` arrive here with their own codes rather than as `unknown`; a true `unknown` is something newer than your bundle.

**Fix.** Read `.message`, log it, and let the round run unpaid. Nothing was charged.

---

## API

Every `/v1` failure is `{ error: { code, message } }` with an HTTP status fixed per code. See [API reference](https://www.withchallenge.com/docs/api.md).

### invalid_request

**HTTP 400.**

**Cause.** The request was malformed. The cases you will actually hit:

- `Idempotency-Key` missing on `POST /v1/attempts`, `POST /v1/attempts/{id}/outcome` or `POST /v1/challenges/{id}/distribution`, or longer than 255 characters.
- Both `challenge_id` and `tournament_round_id` on `POST /v1/attempts`, or neither.
- `player_ref` missing on any route that takes one.
- `score` missing or not a finite number on the outcome call.
- On `POST /v1/challenges/{id}/distribution`: a percentile that is not a number, percentiles that do not ascend from `p5` to `p95`, a `sample_size` under 500, a Challenge that is not `calibrating`, a Challenge that is a rung of a ladder, or a challenge id this program does not own.
- On `/v1/integrations`: an unknown transport, a descriptor that fails validation, a `pull_interval_seconds` under 30 or not an integer, an unsafe pull endpoint, activating a `push_webhook` with no signing secret, or asking to rotate a secret on an integration that is not a `push_webhook`.

**Fix.** Read `message`, which names the specific field. Note that a seeding refusal on a challenge this program does not own is `invalid_request` rather than `not_found`, unlike everywhere else.

### unauthorized

**HTTP 401.**

**Cause.** One code for every key failure: no `X-API-Key` header, a key that is neither `sk_test_` nor `sk_live_`, an unknown key, a test key used where a live one was expected or the reverse, a live key on a program that is not approved, a suspended or closed program, and an account scheduled for deletion.

**Fix.** They answer identically on purpose, because distinguishing them would tell an attacker which half of a guess was right. Work through the list yourself: confirm the header is present, confirm the prefix matches the environment you meant, and confirm the program's approval status in the Developer Portal. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md).

### insufficient_funds

**HTTP 402.**

**Cause.** The player cannot fund the fee. Nothing was charged and no attempt was created.

**Fix.** On the browser path, the island offers a deposit and you need do nothing. On the server path there is no surface to offer: send the player to the island, or to their Challenge account, before retrying.

### blocked

**HTTP 403.**

**Cause.** This player may not play for money, for a reason that is about them rather than about your request. A responsible gaming decision they made about themselves, or a state Challenge does not serve for paid play. Nothing was charged and no attempt was created.

Paid attempts are checked for this on every door, the server path included. A player's location is judged live against the current list rather than at signup, so a player who could enter last month may be refused today.

**Fix.** Nothing, ever. Do not retry, and do not offer an alternative paid path. Run the round unpaid instead. There is deliberately no endpoint that tells you in advance which of your players is eligible: that is a list of your players' locations and self-exclusions, and it is not yours to hold.

### insider_entry

**HTTP 403.**

**Cause.** The account behind this `player_ref` is linked to your own developer account: it is your developer login's player account, a member of your team, or one of your sandbox test accounts. It cannot enter a live tournament on your own program. Nothing was charged.

Your game reports the scores that decide the ranks, and a house-funded purse is Challenge's money, so an account on your side of that cannot also be a competitor for it.

**Fix.** Test tournaments with your test key against the sandbox twin, where entry is free, nothing is charged and the same board, ranks and settlement all run. See [Sandbox](https://www.withchallenge.com/docs/sandbox.md).

### score_out_of_range

**HTTP 422.**

**Cause.** The reported `score` lies outside the plausibility window the Challenge declares. The request was well formed and well typed; the value it carries cannot be a real measurement of this game. Nothing settles and no fee is charged back, so the attempt is left to expire and refund. Fires only on a Challenge that has declared a bound.

**Fix.** This is a malformed report, not a held win. It usually means the score you sent is in the wrong unit, or your own plausibility check is missing. Bound the score on your server before reporting it: reject anything above the maximum a round can reach or below the minimum, and report only a number your game could actually have produced.

### not_found

**HTTP 404.**

**Cause.** No such resource, **or it belongs to another program**. An unknown challenge id, attempt id, round id or integration id, and an email with no Challenge account on `POST /v1/players`, all answer this.

**Fix.** Confirm you are using the key for the program that owns the resource. A resource that exists but is not yours answers `not_found` rather than a 403, because a 403 would confirm it exists. The 403 codes above are about the player and the product, never about ownership. On `POST /v1/players`, this means the player has not signed up with Challenge yet: there is no auto-provisioning, by design.

### challenge_not_available

**HTTP 409.**

**Cause.** The Challenge is suspended, closed, or its prize is above your integrity tier ceiling. Nothing was charged.

**Fix.** Read the Challenge's `status` from `GET /v1/challenges?status=all`. A suspended Challenge was pulled by the drift monitor and is reinstated by review, not by another call. A ceiling refusal is a configuration limit and not a fault: your tier caps the prize.

### challenge_already_claimed

**HTTP 409.**

**Cause.** This player has already been paid this Challenge's prize. A Challenge pays one fixed prize per player for life.

**Fix.** Stop offering it to that player. `GET /v1/challenges` already omits claimed Challenges for the `player_ref` you name, so hitting this means you are building a menu from something other than that list. The refusal is permanent for the pair and no later attempt changes it. Free calibration play never causes it.

### rung_locked

**HTTP 409.**

**Cause.** This Challenge is a rung of a ladder and this player has not claimed the rung below it, which is the only thing that unlocks one.

**Fix.** Temporary, unlike a claimed Challenge: it clears the moment they claim the Challenge named in `parent_challenge_id`. You should not meet it in normal use, because `GET /v1/challenges` omits locked rungs for the player it was asked about. The island lists them, marked locked, which is the one surface that does. See [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md).

### attempt_cap_reached

**HTTP 409.**

**Cause.** This player has bought their allowance. On a Challenge that is the rolling daily cap of paid attempts per player per Challenge. On a tournament round it is the round's per-player attempt cap, which is what keeps a rank table a skill contest rather than a spending contest.

**Fix.** Nothing to retry now. The Challenge cap clears on its own as the day rolls forward; the round cap clears when the next round opens. Free calibration attempts never count against either.

### entries_closed

**HTTP 409.**

**Cause.** The tournament round is no longer open, or it is inside the ten minutes before its close when re-buys stop.

**Fix.** Read `entries_close_at` on the round and stop offering entry before it. That window exists because scores are visible live: without it, the optimal play is to sit out and buy exactly enough attempts at the death.

### round_closed

**HTTP 409.**

**Cause.** You reported an outcome for an attempt in a tournament round that has stopped accepting scores. Standings are computed from what was in at the bell, so a late report cannot move a rank the field may already have been paid on.

**Fix.** Report faster. The attempt expires and its entry is refunded by the settlement sweep, so a late report costs the player nothing, but it also does not count. If this is frequent, your outcome call is running too long after the round ends: report at the end of each round rather than in a batch.

### attempt_already_settled

**HTTP 409.**

**Cause.** This attempt already has an outcome.

**Fix.** Almost always a retry sent without an `Idempotency-Key`, or with a fresh one. Send the same key you sent the first time and the replay returns the original response instead of this. If you genuinely do not know whether the first call landed, call `GET /v1/attempts/{id}` and read the outcome.

### idempotency_key_reused

**HTTP 409.**

**Cause.** This `Idempotency-Key` was already used on this endpoint with a **different** request body.

**Fix.** Use a new key for a new operation. Refusing is safer than guessing which of the two requests you meant. The common source is a key derived from something not unique per operation, such as a player id or a round id; derive it from the attempt instead, or use a UUID.

### idempotency_in_progress

**HTTP 409.**

**Cause.** A request with this key is still being processed. Two of your workers raced on the same operation.

**Fix.** Retry shortly with the same key: once the original finishes you get its response with `Idempotency-Replayed: true`. A record stuck in flight for over 60 seconds is treated as dead and the next retry takes over.

### internal_error

**HTTP 500.**

**Cause.** Something failed on Challenge's side. A raw database message is never leaked into the envelope, so the detail is in Challenge's logs rather than in your response.

**Fix.** Retry with the **same** `Idempotency-Key`. Nothing durable is stored for a non-2xx response, so the retry runs the operation properly. If it persists, capture the request id and the timestamp and contact support.

### client_reporting_removed

**HTTP 410**, on `POST /api/attempts/{id}/outcome`, which is not a `/v1` route.

**Cause.** Something is still calling the retired browser-side reporting endpoint. Usually a cached old bundle.

**Fix.** Report from your server through `/v1`. The route is mounted deliberately so a stale bundle gets an explanation rather than a 404 it would read as an outage. It carries no auth and no idempotency, so it costs the caller nothing.

### Rate limiting

`/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, so a parser that assumes `error.code` on every failure will fail on this one:

```json
{
  "error": "Too many requests, please try again later.",
  "retry_after": 37,
  "limit": 300,
  "reset_at": "2026-08-24T18:22:41.006Z"
}
```

**Fix.** Back off for `retry_after` seconds. If you are near the limit in normal operation, you are probably polling `GET /v1/challenges` per render: cache it, and use webhooks rather than polling for settlement.

---

## Webhook delivery

These are not codes. They are what Challenge records when it could not deliver an event to your endpoint, and what to check for each. A delivery is attempted 3 times in total, the retries due 1 minute and then 5 minutes after each failure. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md) and [Webhook events](https://www.withchallenge.com/docs/webhook-events.md).

| What Challenge saw | Likely cause | Fix |
|---|---|---|
| Connection refused or DNS failure | The URL is wrong, the host is not public, or the service is down | Confirm the endpoint resolves and answers from the open internet. `localhost` and private ranges are unreachable from Challenge; use a tunnel in development. |
| `3xx` | Your endpoint redirects, commonly http to https or a trailing-slash rule | Redirects are not followed. Register the final URL. |
| `401` or `403` | Your own auth middleware is in front of the handler | Exempt the webhook path from session auth. The signature is the authentication. |
| `404` | The path does not exist, or the method is not allowed | The request is a `POST`. Confirm the route accepts one. |
| Other `4xx` | Signature verification rejected the request, or the body failed to parse | Verify over the **raw bytes** before any parsing or re-serialising. A framework that parses JSON and hands you an object has already destroyed the bytes you need. |
| `5xx` | Your handler threw | Answer `2xx` first and do the work afterwards. Retries will arrive while a slow handler is still working. |
| TLS handshake failure | An expired, self-signed or incomplete certificate chain | Challenge does not skip verification. Serve a valid chain, intermediates included. |
| Timeout | The handler took longer than 5 seconds | Acknowledge immediately and queue the work. A payout that finishes before responding is a payout that gets a retry on top of it. |
| Delivered but duplicated | A retry after a response Challenge never saw | Deduplicate on `X-Webhook-Delivery-Id`, which is stable across every retry of a delivery. |

After 3 failed attempts the delivery is marked failed and is not retried again. The event is not lost from Challenge's side: `GET /v1/attempts/{id}` remains the authoritative record, which is why the rule is re-fetch, never reconstruct.

---

## Related

- [Challenge.js reference](https://www.withchallenge.com/docs/challenge-js.md): the browser surface these codes come from
- [API reference](https://www.withchallenge.com/docs/api.md): the endpoint that raises each API code
- [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): payloads and delivery semantics
- [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): the branches to have in place before real money

---

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