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, Report the score from your server and Webhooks.
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.
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.
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 for what suspends one. For a ceiling refusal, see Programs and keys.
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.
invalid_request
HTTP 400.
Cause. The request was malformed. The cases you will actually hit:
Idempotency-Keymissing onPOST /v1/attempts,POST /v1/attempts/{id}/outcomeorPOST /v1/challenges/{id}/distribution, or longer than 255 characters.- Both
challenge_idandtournament_round_idonPOST /v1/attempts, or neither. player_refmissing on any route that takes one.scoremissing 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 fromp5top95, asample_sizeunder 500, a Challenge that is notcalibrating, 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, apull_interval_secondsunder 30 or not an integer, an unsafe pull endpoint, activating apush_webhookwith no signing secret, or asking to rotate a secret on an integration that is not apush_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.
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.
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.
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:
{
"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 and Webhook events.
| 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: the browser surface these codes come from
- API reference: the endpoint that raises each API code
- Webhook events: payloads and delivery semantics
- Go-live checklist: 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
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.