Sandbox
The whole integration, running end to end, against a Challenge that cannot charge and with a key nobody has to approve.
Sandbox is not a second API and not a second integration to write. Same host, same paths, same payloads, same error codes, same webhooks. What differs is which key you send and what your Challenges are worth while you are testing.
What a test key gives you
Every program is created with a test key, integrity tier C and approved: false. The test key works from that moment. Sandbox keys skip the approval check deliberately, which is the whole point of a sandbox; an sk_live_ key on an unapproved program answers 401.
# The key lives on your server. It never reaches the browser.
export CHALLENGE_API_KEY=sk_test_...The key is on the program in the Developer Portal, behind a reveal control, and rotating it is self-serve. Where each value is allowed to appear, and why every key failure answers one indistinguishable 401, is in Programs and keys.
A key resolves to exactly one program, so nothing else about your code changes between environments. You swap one environment variable.
Sandbox is a key, not a copy
Worth knowing before you plan around it. sk_test_ and sk_live_ are two values on one program record. They resolve the same program, the same Challenges, the same linked players and the same attempts. There is no separate sandbox data set, and nothing gets reset.
Two consequences follow:
- What you do with a test key is done to your real program. Seeding a distribution takes that Challenge live for everyone, and it cannot be undone by seeding again.
- The fee is a fact about the Challenge, not about the key. A test-key attempt on a Challenge that carries a fee charges that fee. If you want somewhere permanently free to experiment after you are live, register a second program and leave its Challenges calibrating.
A calibrating Challenge is free by database constraint
This is the part worth understanding, because it is what makes a money-free loop possible without a simulator.
A Challenge is created calibrating, with no threshold, a fee of 0 and a prize of 0. Free is not a convention here, it is a table constraint:
CONSTRAINT challenges_calibrating_is_free
CHECK (status <> 'calibrating' OR fee = 0)A calibrating Challenge and a non-zero fee cannot coexist in the database. Its sibling constraint is the other half of the same idea: a Challenge cannot be live with a null threshold. Between them, a bar that has not been earned cannot charge, and a Challenge that charges cannot be unjudgeable.
Attempt creation follows the same rule from the other side. A calibrating attempt freezes prize_offered at 0, and because the fee is zero the four paid-attempt guards are all skipped: the rung lock, the one-prize-per-player win lock, the ten-paid-attempts-a-day cap on a Challenge and any responsible gaming break on the account. One player_ref can run the loop as many times as your test needs.
None of this is a sandbox mode. It is the model's own cold start, and it behaves identically in production. A calibrating Challenge is how a bar gets measured in the first place. See Calibration.
The free loop runs through the server door
Runs on your server.
The island offers live Challenges only. The bootstrap sends live rows, and the state machine filters for live again before it draws a card, because a cached payload must never put an offer in your page that the server would refuse. A calibrating Challenge therefore cannot be armed, and Challenge.startAttempt() with nothing armed rejects with not_armed rather than starting a free attempt.
The door that does accept a calibrating Challenge is POST /v1/attempts, which permits live and calibrating by name. So the money-free loop is the server-created attempt path, in three calls.
1. Link the player
# The key lives on your server. It never reaches the browser.
curl -s -X POST https://api.withchallenge.com/v1/players \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "player_ref": "sandbox-player-1", "email": "player@example.com" }'{
"player_ref": "sandbox-player-1",
"linked": true,
"created_at": "2026-08-24T11:58:02.000Z"
}The email has to belong to a Challenge account that already exists. This endpoint links, it never creates: a player_ref is a string you chose, and treating it as authority to open an account that holds money would mint accounts with no email, no age check and no consent. An unmatched email answers not_found, worded so that nobody can enumerate who has an account.
Sign up as a player once at app.withchallenge.com and use that account for your sandbox runs.
2. Create the attempt
# The key lives on your server. It never reaches the browser.
curl -s -X POST https://api.withchallenge.com/v1/attempts \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2f6f1a0c-9d5b-4a2e-8c31-7b0f4d9a1e55" \
-d '{ "challenge_id": "<<YOUR_CHALLENGE_ID>>", "player_ref": "sandbox-player-1" }'{
"id": "9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042",
"challenge_id": "<<YOUR_CHALLENGE_ID>>",
"tournament_round_id": null,
"player_ref": "sandbox-player-1",
"status": "open",
"fee_charged": "0.00",
"created_at": "2026-08-24T11:59:12.000Z",
"metadata": {},
"outcome": null
}Idempotency-Key is required, and a replay returns the same attempt with Idempotency-Replayed: true rather than creating a second one. Use a fresh key per attempt.
3. Report the outcome
The same call your production server will make, with the same key header and the same idempotency rule. See Report the score from your server.
# The key lives on your server. It never reaches the browser.
curl -s -X POST \
https://api.withchallenge.com/v1/attempts/9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042/outcome \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042" \
-d '{ "score": 34 }'{
"attempt_id": "9c2b6f81-0a44-4d2a-bd7e-1f5c8a3e9042",
"score": 34,
"met_threshold": false,
"threshold_applied": null,
"recorded_at": "2026-08-24T12:00:00.000Z",
"receipt_id": null,
"award": null
}A calibrating attempt always settles met_threshold: false with threshold_applied: null. There was no bar to judge it against, and settling it cleanly as not met is deliberate: judging a score against a null would compare against NaN and return false by luck rather than by decision. The score still counts toward calibration.
attempt.settled fires on your registered endpoint, signed exactly as it will be in production. That is the whole loop: your key, your idempotency keys, your handler, your signature verification, all exercised, with no money anywhere near it.
Watch the calibration fill
# The key lives on your server. It never reaches the browser.
curl -s "https://api.withchallenge.com/v1/challenges?player_ref=sandbox-player-1&status=all" \
-H "X-API-Key: $CHALLENGE_API_KEY"status=all is the partner view. Without it you get live Challenges only, and a calibrating one is invisible. Each calibrating row carries its progress instead of a threshold:
"calibration": {
"collected": 412,
"required": 1000,
"playersCollected": 96,
"playersRequired": 200
}Both minimums are real and they are bought with different currencies: 1,000 adjudicated attempts and 200 distinct players. You are not going to fill either by hand today, which is what the next section is for.
Getting a live Challenge in sandbox
To exercise the island path, arming and Challenge.startAttempt(), the Challenge has to be live. Hand Challenge the distribution you already have and it goes live in the same request.
Runs on your server.
# The key lives on your server. It never reaches the browser.
curl -s -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: 4a7d2c10-6e83-4f19-9b52-c0d81e37a664" \
-d '{
"distribution": { "p5": 22, "p25": 30, "p50": 38, "p75": 47, "p95": 61 },
"sample_size": 5000
}'{
"challenge_id": "<<YOUR_CHALLENGE_ID>>",
"threshold": 57.5,
"target_pass_rate": 0.1,
"sample_size": 5000,
"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."
}The rules it holds to: the percentiles must rise from p5 to p95, sample_size must be a whole number of at least 500, the Challenge must be calibrating, and a rung of a ladder cannot be seeded at all. A live Challenge is refused, because reseeding would move the bar under players mid-flight, and a suspended one is refused, because a new distribution is not a way to reset the breaker that pulled it. Seeding is one way, so seed the Challenge you meant to seed.
Now reload your game. The island shows the Challenge, the player can arm it, and Challenge.startAttempt() resolves against a frozen threshold. See Start a paid attempt.
const attempt = await Challenge.startAttempt()
// { attemptId, feeCharged: 0, prizeOffered: 0, threshold: 57.5, thresholdDirection: 'higher_is_better' }The fee and the prize come back as 0 because Challenge prices a Challenge and has not priced this one. Everything else is the shape you will ship: the arming gesture, the freeze, the reporting call, the settlement event, the island rendering the result on its own.
With a real threshold frozen on the attempt, met_threshold can now come back true. The award stays null, because a zero prize pays nothing. An award object is the one part of the loop sandbox cannot show you today.
Sandbox and production differences
Sandbox (sk_test_) | Production (sk_live_) | |
|---|---|---|
| Approval | None. The key works the moment the program exists. | Required. An unapproved program answers 401 on a live key. |
| Host, paths, payloads, error codes | Identical | Identical |
| Program and Challenge data | The same records. Sandbox is a key, not a copy. | The same records. |
| Fee and prize | 0.00 on every Challenge, because none is priced yet | Set by Challenge, capped by your integrity tier |
| Money movement | None. A zero fee takes nothing and a zero prize pays nothing. | Real balances, real payouts |
| Awards | award is always null, even on a met threshold | An object with an amount when the bar is met |
| Players | Real Challenge accounts, linked through POST /v1/players | The same |
| Player portal | Out of reach for a sandbox test account. The island's Profile, History and Withdraw links are greyed out. | Live. The three links open the player's portal in a new tab. |
| Paid-attempt guards | Skipped while the fee is 0: win lock, daily cap, rung lock, responsible gaming | All four apply |
| Webhooks | Same endpoints, same sha256 signature, same 3-attempt delivery with retries due at 1m and 5m, no test marker on the payload | The same |
| Drift monitor | Runs on any live Challenge. The fairness band can suspend a sandbox Challenge after 30 settled outcomes; the solvency check cannot, since it needs a fee above zero. | Both checks apply |
Two of those deserve a sentence more.
No test marker. A sandbox delivery is byte-for-byte the shape of a production one, and webhook endpoints are registered per developer account rather than per environment. Your handler cannot tell the two apart from the payload, so do not write one that tries.
No money simulator. Sandbox does not fake balances, deposits or payouts. It avoids money entirely, by running against Challenges that carry none. That is the honest version, and it is why the free loop is worth as much as it is: everything except the amount is real.
What to check before you move on
- One attempt created, reported and settled through the server door, with
attempt.settledreceived and its signature verified. - One attempt started from the island against a seeded Challenge, with the result rendering itself in the browser you played in.
- One attempt you deliberately never report. It expires an hour after it was created and the sweep picks it up within five minutes of that, refunding the fee and firing
attempt.refundedwithreason: "expired". Handle that event before you are live, not after. - A branch for every rejection code
startAttempt()can carry and every error code/v1can answer. See Errors. - Duplicate webhook deliveries deduplicated by
X-Webhook-Delivery-Id, which is stable across retries. See Webhooks.
Then work through the Go-live checklist, which is the rest of it: approval, the key swap, your tier ceiling, the prize ramp and what to watch once real attempts are landing.
Related
- Programs and keys: the two keys, where each may appear, approval, rotation and integrity tiers
- Calibration: what the free attempts you just ran are for, and the three ways a bar gets placed
- Server-created attempts: the same door in production, for games with no browser client
- Go-live checklist: everything that has to be true before the key changes
Source: https://www.withchallenge.com/docs/sandbox.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.