---
title: "Go-live checklist"
description: "Everything that has to be true before sk_test_ becomes sk_live_: approval, the key swap, no key in any client bundle, tier ceilings, the 200-attempt prize ramp, provisional caps, webhook signature verification, duplicate deliveries, expiry refunds, error branching and logging."
url: https://www.withchallenge.com/docs/go-live
canonical_markdown: https://www.withchallenge.com/docs/go-live.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# Go-live checklist

Everything that has to be true before you swap `sk_test_` for `sk_live_`, in the order it is worth checking.

There is one mistake on this page that is worse than all the others, so it goes first: **a live key in a browser bundle**. Anyone who opens devtools can then create attempts, report outcomes and move your players' money in your name, and a key that has been in a client bundle is public forever. Rotation is the only remedy and it is a remedy after the fact. Everything else here is a thing you fix; this is a thing you survive.

Going live is not a switch. It is approval, one environment variable, and a short list of behaviors your code has to already handle.

## The checklist

- [ ] **Your program is approved.**

  Approval is a manual review of your program by Challenge, not a form that returns instantly. It is what makes an `sk_live_` key work at all: a live key presented for an unapproved program answers `401` with "This program is not approved for live use." Real players, real money and the live key arrive together, and the same review is what raises your integrity tier. See [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md).

- [ ] **The live key is in your server environment config, and nowhere else.**

  One variable changes. The host, the paths, the payloads, the headers and the error codes are the ones you already tested. Set `CHALLENGE_API_KEY` to the `sk_live_` value in your server's environment, your serverless function's secret store or your CI secret store, and read it as `process.env.CHALLENGE_API_KEY` or `Deno.env.get('CHALLENGE_API_KEY')`. This is the moment the catastrophic mistake happens: a live key pasted into the wrong file is a live key on the internet, so put it where the test key already was and change nothing else.

- [ ] **No key appears in any client bundle, `NEXT_PUBLIC_` variable, or repository.**

  Grep the thing you are about to ship for `sk_live_` and for `sk_test_`. A key must never be in page JavaScript, a minified bundle, a mobile or desktop build, a `NEXT_PUBLIC_*` or `VITE_*` variable, a committed `.env`, a screenshot or a support ticket. Only the program ID goes in the browser, and it is public by design. If a live key has ever been in a client bundle, treat it as public and rotate it before you launch.

- [ ] **You know your integrity tier ceiling.**

  Your tier caps what a single prize may be: **C is $100, B is $1,000, A is $10,000**. Every program starts at C, and a tier is raised by review, never at signup. The ceiling is checked when a prize is paid rather than only when a Challenge is priced, so a prize over it answers `challenge_not_available` and names the limit. The same ceiling is what stops a [Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md) climbing, since each rung doubles the prize below it.

- [ ] **You know what the prize ramp will show your players.**

  A Challenge calibrated from observed play, and a rung priced from the players who claimed the Challenge below it (`calibration_source` of `observed` or `rung_derived`), pays **half its configured prize for the first 200 paid attempts** while the bar is confirmed against real money. Attempt 201 is the first at the full prize, and the change is automatic. Show the `prize` field exactly as the API returns it, or `prizeOffered` from `startAttempt()`. Never hardcode the number you saw in the portal: during a ramp the `ramp` object carries `{ active, fullPrize, attemptsSeen, attemptsRequired }`, and `fullPrize` is what it will become, not what to advertise now.

- [ ] **You know whether your Challenge is provisionally capped.**

  A bar that came from a claim rather than from observed play is provisional: `calibration_source` of `listened` or `partner_seeded` may offer at most **20% of your tier ceiling** until real attempts confirm it. Provisional and ramped are mutually exclusive, so a Challenge is on one list or the other and never both. The cap lifts on its own when the drift monitor sees the realised pass rate land inside its band. See [Calibration](https://www.withchallenge.com/docs/calibration.md).

- [ ] **Your webhook endpoint is registered and verifying signatures in production.**

  Register the production URL in the Developer Portal and select the events you consume. Every delivery carries `X-Webhook-Signature: sha256=<hmac>`, computed over the **raw request body**, plus `X-Webhook-Event` and `X-Webhook-Delivery-Id`. Compute the HMAC over the bytes you received, before any JSON parsing or reserialisation, and compare in constant time. A handler that trusts an unverified payload is a handler anyone on the internet can settle attempts through. Answer `2xx` within 5 seconds; a failed 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).

- [ ] **Duplicate deliveries are handled, and `attempt.settled` is re-fetched rather than reconstructed.**

  There is no ordering guarantee and no exactly-once guarantee. Dedupe on `X-Webhook-Delivery-Id`, which is stable across every retry of the same delivery, and make your handler idempotent on top of that. When `attempt.settled` arrives, call `GET /v1/attempts/{id}` and trust that, rather than rebuilding your own copy of the settlement from the payload fields. The fetched attempt is the truth; the event is only the news that there is something to fetch.

- [ ] **The expiry refund path is exercised.**

  An attempt with no outcome expires **one hour** after it was created, and the sweep that reaps it runs every five minutes. The fee is refunded in full and `attempt.refunded` fires with `reason: "expired"`. Reporting a score after that point answers `attempt_expired`. This is the branch that only fires when your server is already having a bad day, so run it in sandbox on purpose before you need it: start an attempt, report nothing, and watch the event arrive.

- [ ] **Every failure has a branch.**

  In the browser, a rejected `startAttempt()` carries a `.code`: `not_armed`, `insufficient_funds`, `attempt_in_progress`, `challenge_not_available`, `blocked`, `not_initialised`, `unauthorized`, `client_reporting_removed`, `network`, `unknown`. The island has already told the player whatever they need to know, so your job is not to draw a message, it is to not continue the round. On the server, every `/v1` error is `{ "error": { "code", "message" } }`, and the codes you branch on are the codes, never the message text. See [Errors](https://www.withchallenge.com/docs/errors.md).

- [ ] **Your logs keep the identifiers that let you reconstruct one attempt.**

  Log the attempt id on every call you make about it, the `Idempotency-Key` you sent, the `receipt_id` the outcome returned when you sent evidence with it, and the `X-Webhook-Delivery-Id` of every delivery you accepted or rejected. Challenge does not return a request id header today, so the attempt id is the join key between your logs, your webhook deliveries and anything support has to look up on our side. A settlement dispute that arrives a week later is answerable with those four values and not answerable without them.

## After you are live

Calibration does not stop when a Challenge goes live, because populations do not stop moving. Every live Challenge is checked against its last 500 settled outcomes, with no judgement made below 30 of them, on two separate questions: whether the realised pass rate is inside the band around its target, and whether the expected payout has overtaken the fee.

Fail either and the Challenge is set to `suspended` immediately. New attempts answer `challenge_not_available`, and `challenge.suspended` fires with the realised rate, the target and the reason. Attempts already open keep the bar frozen at their creation, so nothing changes retroactively. The response is suspension rather than an alert because an alert needs someone awake and a circuit breaker does not.

Pass both and an unconfirmed bar is promoted to `observed`, which is what lifts a provisional ceiling with no request to make.

Three places to watch it from:

- **`challenge.suspended` and `challenge.calibrated`**, if you want to know without looking. Subscribe to both; the first is the one that stops your revenue, the second is the one that starts it.
- **`GET /v1/challenges?player_ref=...&status=all`**, which is the partner view. The default list is player-facing and shows live Challenges only, so a suspended one simply vanishes from it. `status=all` is how you see the status, the `calibration_source` and the ramp position.
- **The Developer Portal**, which shows each Challenge's status, threshold and calibration progress on the program.

If a Challenge of yours is suspended, do not try to seed a new distribution over it. Seeding is refused on anything that is not `calibrating`, deliberately, because a new bar is not a way to reset the breaker that pulled it. Reinstatement is a review.

## Related

- [Sandbox](https://www.withchallenge.com/docs/sandbox.md): the money-free loop, and what does and does not differ between the two environments
- [Programs and keys](https://www.withchallenge.com/docs/programs-and-keys.md): approval, integrity tiers, rotation, and where each value may appear
- [Calibration](https://www.withchallenge.com/docs/calibration.md): the provisional ceiling, the ramp and the drift monitor in full
- [Webhooks](https://www.withchallenge.com/docs/webhooks.md): handler, registration, signature verification, retries and debugging
- [Errors](https://www.withchallenge.com/docs/errors.md): every code with its cause and its fix

---

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