Challenge
Docs>Reference>Errors

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

CodeSurfaceWhat it usually means
unauthorizedAPIThe key is missing, malformed, for the wrong environment, or on a program that is not approved
invalid_requestAPIA missing Idempotency-Key, both parents on POST /v1/attempts, or a score that is not a number
not_foundAPIA resource that belongs to another program, or an id that does not exist
insufficient_fundsBothThe player cannot fund the fee. Nothing was charged.
attempt_already_settledAPIYour outcome call ran twice without an Idempotency-Key
challenge_already_claimedAPIThis player has already been paid this Challenge. Permanent for the pair.
blockedBothThis player may not play for money: their own responsible gaming decision, or a state Challenge does not serve
insider_entryAPIThe account behind this player_ref is linked to your developer account, so it cannot enter your own live tournament
not_armedChallenge.jsstartAttempt() ran before the player armed anything
client_reporting_removedBothSomething 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-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.

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 sawLikely causeFix
Connection refused or DNS failureThe URL is wrong, the host is not public, or the service is downConfirm the endpoint resolves and answers from the open internet. localhost and private ranges are unreachable from Challenge; use a tunnel in development.
3xxYour endpoint redirects, commonly http to https or a trailing-slash ruleRedirects are not followed. Register the final URL.
401 or 403Your own auth middleware is in front of the handlerExempt the webhook path from session auth. The signature is the authentication.
404The path does not exist, or the method is not allowedThe request is a POST. Confirm the route accepts one.
Other 4xxSignature verification rejected the request, or the body failed to parseVerify 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.
5xxYour handler threwAnswer 2xx first and do the work afterwards. Retries will arrive while a slow handler is still working.
TLS handshake failureAn expired, self-signed or incomplete certificate chainChallenge does not skip verification. Serve a valid chain, intermediates included.
TimeoutThe handler took longer than 5 secondsAcknowledge immediately and queue the work. A payout that finishes before responding is a payout that gets a retry on top of it.
Delivered but duplicatedA retry after a response Challenge never sawDeduplicate 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


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.