---
title: "Sandbox"
description: "Testing with an sk_test_ key: why a calibrating Challenge is free by database constraint, how to run the whole loop through POST /v1/players, /v1/attempts and the outcome call, how to seed a distribution to exercise the island path, and exactly what differs from production."
url: https://www.withchallenge.com/docs/sandbox
canonical_markdown: https://www.withchallenge.com/docs/sandbox.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# 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.

```bash
# 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](https://www.withchallenge.com/docs/programs-and-keys.md).

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:

```sql
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](https://www.withchallenge.com/docs/calibration.md).

## 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

```bash
# 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" }'
```

```json
{
  "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](https://app.withchallenge.com) and use that account for your sandbox runs.

### 2. Create the attempt

```bash
# 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" }'
```

```json
{
  "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](https://www.withchallenge.com/docs/report-score.md).

```bash
# 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 }'
```

```json
{
  "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

```bash
# 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:

```json
"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.**

```bash
# 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
      }'
```

```json
{
  "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](https://www.withchallenge.com/docs/start-attempt.md).

```javascript
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.settled` received 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.refunded` with `reason: "expired"`. Handle that event before you are live, not after.
- A branch for every rejection code `startAttempt()` can carry and every error code `/v1` can answer. See [Errors](https://www.withchallenge.com/docs/errors.md).
- Duplicate webhook deliveries deduplicated by `X-Webhook-Delivery-Id`, which is stable across retries. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md).

Then work through the [Go-live checklist](https://www.withchallenge.com/docs/go-live.md), 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](https://www.withchallenge.com/docs/programs-and-keys.md): the two keys, where each may appear, approval, rotation and integrity tiers
- [Calibration](https://www.withchallenge.com/docs/calibration.md): what the free attempts you just ran are for, and the three ways a bar gets placed
- [Server-created attempts](https://www.withchallenge.com/docs/server-attempts.md): the same door in production, for games with no browser client
- [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): 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
