---
title: "Integrate Challenge in one pass"
description: "The self-contained guide an AI coding agent should read first: preflight values to ask the developer for, the whole canonical integration with complete server templates, a numbered verification sequence, the error catalog, what not to build, and a paste block of invariants for the developer's own AGENTS.md."
url: https://www.withchallenge.com/docs/integrate
canonical_markdown: https://www.withchallenge.com/docs/integrate.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# Integrate Challenge in one pass

The whole integration in one page, written to be executed rather than browsed. Follow it top to bottom and you need no other page.

This page is self-contained. Every value, file path, request and response you need is inline. The links at the end are depth, not prerequisites.

## Preflight

You cannot start without four things, plus one more that depends on which path the developer wants. Two of the four are values only the developer has, and neither can be derived, guessed or looked up.

| What | Form | Where it goes |
|---|---|---|
| `PROGRAM_ID` | An opaque string from the [Developer Portal](https://app.withchallenge.com) | The browser. It is public |
| `CHALLENGE_API_KEY` | `sk_test_...` in sandbox, `sk_live_...` in production | A server environment variable, and nowhere else, ever |
| Metric name | The game's own word for what it measures, for example `moves`, `solve time`, `combo length`. 40 characters or fewer | Both halves of the integration |
| Direction | `higher_is_better` or `lower_is_better` | The Challenge, in the portal |

**If you do not have these, STOP and ask the user for them. Do not invent placeholder values. Do not proceed with `your-program-id`, a plausible-looking UUID, or an `sk_test_` string you made up.** A program ID that is not the developer's mounts an island that offers nothing, and a fabricated key answers `401` on every request. Both failures look like a broken integration rather than a missing value, which is why guessing costs more than asking.

The program ID is on the developer dashboard in the portal, in the integration snippet, one per game. The key is on the Games page, per game, behind a reveal control.

`GET /v1/challenges` does not return the direction on a `calibrating` Challenge, so it cannot be read back from the API before a bar exists. It comes from the developer or from the Challenge's row in the portal, and it appears on the wire as `thresholdDirection` only once an attempt has started.

### Then one more thing, depending on the path

There are two ways to reach a settled attempt, and they need different things. **Ask the developer which one they want before you start.** The choice is theirs, not yours: one of them spends real money.

| Path | What it additionally needs |
|---|---|
| **Paid path.** Take a Challenge live and run a real charged attempt | Five percentiles of real player scores, `p5`, `p25`, `p50`, `p75` and `p95`, ascending, plus a `sample_size` of at least 500. Seeding is one way, cannot be undone, and takes that Challenge live for real players, so it needs the developer's explicit go-ahead first |
| **Money-free loop.** Settle a free attempt on a `calibrating` Challenge | An email address that already has a Challenge player account. `POST /v1/players` links an existing account and never creates one, so an unregistered email answers `404 not_found`. The developer signs up once as a player at [app.withchallenge.com](https://app.withchallenge.com) |

Neither value can be guessed. If the developer has neither, the integration still ships: steps 1 and 2 are safe to land on their own, and the paid loop is turned on later.

Set the key where the server will read it:

```bash
# The key lives on your server. It never reaches the browser.
export CHALLENGE_API_KEY=sk_test_...
```

Throughout this page, `<<YOUR_PROGRAM_ID>>` and `<<YOUR_CHALLENGE_ID>>` are placeholders. Replace them with the real values. The key is never written literally anywhere: it is read from the environment.

## What you are building

Challenge sells a **Challenge**: a player pays a fee to attempt a fixed standard in the game, and Challenge pays a fixed prize when they beat it. Challenge funds the prize, announces it before the player pays, and never pools one player's fee into another player's winnings.

Your half is small and it is entirely this:

1. Two script tags and one `Challenge.init({ program })` call.
2. One `Challenge.record(metric, score)` line at the end of every round.
3. One `await Challenge.startAttempt()` at the start of a paid round.
4. One `POST` from your server reporting the measured score.

Challenge's half is everything else: accounts, sign-in, age and identity checks, deposits, balances, the fee confirmation, the prize, the verdict, the result screen, refunds, and the ledger. You render none of it.

Two rules decide the shape of everything below.

- **The key never reaches the browser.** A key can settle an attempt, and settling an attempt pays a prize.
- **You report a measurement, never a verdict.** The outcome endpoint accepts `score`. It has no `won` field. Challenge applies the bar it froze onto the attempt when it charged the fee.

## Step 1. Add Challenge.js and initialise it

Put both tags in the page that runs the game, in this order.

```html
<script src="https://api.withchallenge.com/widget/dist/island-stub.js"></script>
<script async src="https://api.withchallenge.com/widget/dist/island.js"></script>
```

The first is a small synchronous stub that paints the island immediately. The second loads the rest of the SDK asynchronously and takes over. Calls made before it arrives are queued, so nothing is lost.

There is no npm package, for the browser or for the server. Script tags are the whole distribution. Do not add a bundler dependency, and do not `import` anything from Challenge.

Then, once, wherever the game boots:

```javascript
Challenge.init({ program: '<<YOUR_PROGRAM_ID>>' })
```

`program` is the only required option. The API base is detected from the script origin, so nothing else is configured in production.

Until the program has a live Challenge, the island still draws: a small dormant pill reading **Coming soon**, in the anchor corner, visible to every player on the page. It offers nothing and charges nothing, but it is not invisible. Decide deliberately whether to ship it before a live Challenge exists, or hold the two script tags until there is one.

The options worth knowing:

| Option | Type | Default | What it does |
|---|---|---|---|
| `program` | string | required | The program ID |
| `anchor` | string | `'bottom-right'` | `bottom-right`, `bottom-left`, `bottom-center`, `top-right`, `top-left`, `top-center`, or a CSS selector for an inline mount |
| `size` | `'regular'` or `'compact'` | `'regular'` | How large the pill draws |
| `theme` | `'light'` or `'dark'` | `'light'` | The palette for every island surface |
| `draggable` | boolean | `false` | Lets the player move the island and remembers where |
| `challenge` | string | `null` | Pin the island to one Challenge id. The key is literally `challenge`, not `challenge` |
| `apiBase` | string | script origin | Local development only |

### Local and self-hosted sandboxes

If the sandbox is a Challenge backend running somewhere other than `api.withchallenge.com`, a local one on port 4837 for instance, then both script tags load from that origin and `apiBase` names it too:

```html
<script src="http://localhost:4837/widget/dist/island-stub.js"></script>
<script async src="http://localhost:4837/widget/dist/island.js"></script>
```

```javascript
Challenge.init({
  program: '<<YOUR_PROGRAM_ID>>',
  apiBase: 'http://localhost:4837',
})
```

Change both together. A page that loads the bundle from one origin and talks to another is the one configuration the island cannot detect its way out of. Swap `https://api.withchallenge.com` for that same base in every `curl` on this page.

The same backend also serves these docs: every page is at `{your API base}/api/docs/<slug>.md`, for example `http://localhost:4837/api/docs/integrate.md`. The agent index sits beside them under the same `/api/docs` path, named `llms.txt`.

In a single-page app, call `init()` on every route change: it is safe, the element and the player's armed session are reused, and a round in flight is kept — a route change **mid-round keeps the live pill live** and its score still settles, so you never guard `init()` against a round in progress. `init()` never calls `destroy()`. Call `Challenge.destroy()` only when the game leaves the page for good.

```jsx
import { useEffect } from 'react'

export function ChallengeProvider({ programId, children }) {
  useEffect(() => {
    // Safe on every route change. The element and the armed session are reused.
    window.Challenge.init({ program: programId })

    return () => {
      // Only on real unmount. destroy() also forgets the player's armed session.
      window.Challenge.destroy()
    }
  }, [programId])

  return children
}
```

## Step 2. Record every round

One line at the end of every round the game already has. Paid or free, signed in or not, practice included.

```javascript
Challenge.record('moves', 34)
```

Use the developer's actual metric name and their actual number, not the example's. The string must match the `metric_name` on the Challenge exactly, because it is the key the sample is stored under. Names are trimmed, lowercased and internally whitespace-collapsed, so `'Moves'` and `'moves'` are one metric.

**A mismatched metric name is a silent failure, and it is the easiest way to lose a day here.** Nothing rejects it. `record()` still resolves `{ recorded: true }`, the server still answers `202`, the network tab still looks correct, and the samples pile up under a metric no Challenge is reading. Nothing anywhere surfaces the discrepancy.

The only defence is the check: compare the string you pass to `record()` against the `metric_name` field returned by `GET /v1/challenges?status=all`, character for character, after trimming and lowercasing both. Do that before you believe any recorded round counted. Step 3 is where you read that field.

In place:

```javascript
async function playRound() {
  const result = await runTheRound()

  // Every round. Not awaited, not branched on, not conditional on a Challenge
  // existing or a player being signed in.
  Challenge.record('moves', result.moves)

  showRoundSummary(result)
}
```

`record()` never rejects and never blocks a round. It resolves `{ recorded: true }` or `{ recorded: false }`, throttles to one request per second per metric without dropping rounds, queues anything reported before `init()` has finished, and drops with a `console.warn` only past 50 queued rounds on one metric. It charges nothing and renders nothing.

**What it does and does not feed.** Recorded rounds build a **listening sample** for a metric. When a Challenge is later created on a metric that already has 5,000 recorded rounds from at least 200 distinct player refs, its bar is computed and the Challenge goes live in the same request.

Recorded rounds **never move an existing Challenge's calibration counter**. That counter is adjudicated attempts only, and it is reported as `calibration.collected`. Recorded rounds are also never merged into a later recalibration and never feed the drift monitor. They place a bar once, at Challenge creation, and only there. Any code or copy that treats `record()` as a way to advance a calibrating Challenge is wrong.

Keep the line in forever regardless. It is what prices every future Challenge on that metric.

## Step 3. Get a Challenge with a bar

Do not assume one exists. List them first, and read the answer:

```bash
# The key lives on your server. It never reaches the browser.
curl -s "https://api.withchallenge.com/v1/challenges?player_ref=integration-check&status=all" \
  -H "X-API-Key: $CHALLENGE_API_KEY"
```

`status=all` is the partner view. Without it the answer is live Challenges only, and a new Challenge is `calibrating`.

**If `data` comes back empty, STOP and ask the developer to create a Challenge in the [Developer Portal](https://app.withchallenge.com).** There is no `POST /v1/challenges`. A Challenge cannot be created from the API on any key, so there is nothing to retry and nothing to work around. On the Challenges page, **Add challenge** asks for a name, the metric name, the metric unit and the direction. It does not ask for a fee or a prize, because Challenge prices every Challenge. A bar the developer chose would set the pass rate, which would set what Challenge expects to pay.

From a non-empty answer, take two fields off the row:

- `id`. That is `<<YOUR_CHALLENGE_ID>>` below.
- `metric_name`. **Record under exactly that string.** Copy it out of the response and pass that value to `Challenge.record()` in step 2. Do not retype it from the developer's description of it, and do not use the example's `'moves'`.

A `calibrating` Challenge has no bar, no fee and no prize, and the island will not offer it. It leaves calibration after 1,000 adjudicated attempts from at least 200 distinct players. You are not filling that by hand today.

**The fastest path to a live Challenge is to seed the distribution.** If the developer already knows how their players score, hand Challenge five percentiles and the Challenge goes live in the same request:

```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": "...",
  "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 ascend 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. Seeding is one way. It cannot be undone by seeding again, and it takes that Challenge live for real players, so seed the Challenge you meant to seed with numbers the developer stands behind.

A bar placed this way is provisional: the prize is capped at 20% of the program's integrity tier ceiling until observed attempts confirm the claim. The cap lifts on its own.

**If the developer has no distribution to hand**, the money-free loop runs through the server door instead, and the island is not involved at all. Two calls, on a `calibrating` Challenge:

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

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

The email must belong to a Challenge account that already exists: `POST /v1/players` links, it never creates. Have the developer sign up once as a player at [app.withchallenge.com](https://app.withchallenge.com). A calibrating attempt is free by database constraint, settles `met_threshold: false` with `threshold_applied: null`, and exercises the outcome call, the idempotency key and the settlement webhook with no money anywhere near it.

**Sandbox is a key, not a copy.** `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. What differs is that a live key requires an approved program and a test key does not, and that no Challenge is priced yet, so `fee_charged` and `prize_offered` settle as `0.00` and `award` comes back `null` even on a met threshold.

## Step 4. Start the paid attempt in the browser

**The player arms first, and your code is not in that step.** They tap the island, see the fee, the prize and the bar they would have to beat, and confirm. That confirmation is arming. Sign-up, the age check and a deposit, if one is needed, all happen inside the same flow on Challenge's surface.

There is no API that arms on a player's behalf, on any key, and there will not be one. Do not look for one.

Once a player is armed, start the paid round:

```javascript
// The absolute URL of your own round-finished handler. A statically served
// game cannot reach a server on another origin with a relative path, so name
// the origin here. Replace it with the deployed URL before you ship:
//   Express template:  http://localhost:4310/round-finished
//   Supabase template: https://<project-ref>.supabase.co/functions/v1/round-finished
// Only if your own server serves this page too, use '/round-finished'.
const ROUND_FINISHED_URL = 'http://localhost:4310/round-finished'

async function playPaidRound() {
  let attempt
  try {
    attempt = await Challenge.startAttempt()
  } catch (error) {
    handleStartFailure(error)
    return
  }

  // { attemptId, feeCharged, prizeOffered, threshold, thresholdDirection }
  const moves = await runTheRound({
    target: attempt.threshold,
    direction: attempt.thresholdDirection,
  })

  // Send the server whatever it needs to derive the score. Not the score.
  await fetch(ROUND_FINISHED_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ attemptId: attempt.attemptId, moves }),
  })
}
```

`startAttempt()` charges the fee, copies the bar, its direction and the prize onto the attempt, gives the attempt a one-hour expiry, and resolves only once the money has moved.

| Field | Type | Meaning |
|---|---|---|
| `attemptId` | string | The id the server reports the outcome against |
| `feeCharged` | number | What this player actually paid, frozen |
| `prizeOffered` | number | What this attempt pays on a win, frozen. `0` on a calibrating Challenge |
| `threshold` | number or null | The bar this attempt is judged against |
| `thresholdDirection` | string or null | `'higher_is_better'` or `'lower_is_better'` |

These five are this attempt's terms. If the game shows a target during the round, read `attempt.threshold` and `attempt.thresholdDirection` and nothing else. Never read a threshold from a Challenge list fetched earlier: the two can differ, and only the frozen pair decides the outcome.

Branch on `error.code`, never on the message. The island has already told the player whatever they need to know, so most branches are a log line and a return:

```javascript
function handleStartFailure(error) {
  switch (error.code) {
    case 'not_armed':
      // The player has not agreed to a Challenge. Run the round for free.
      playFreeRound()
      return

    case 'insufficient_funds':
      // Nothing was charged. The island is already offering a deposit.
      return

    case 'blocked':
      // A responsible gaming limit the player set for themselves. Never work
      // around this and never offer an alternative paid path.
      return

    case 'challenge_not_available':
      // Suspended, closed, or the island and the server disagree about what is
      // armed. The island resyncs itself. Offer a free round instead.
      playFreeRound()
      return

    case 'attempt_in_progress':
      console.warn('Challenge: an attempt is already running')
      return

    case 'not_initialised':
      console.error('Challenge: init() must run before startAttempt()')
      return

    case 'unauthorized':
      // The player's session expired. The island puts a sign-in in front of them.
      return

    case 'network':
      // The request never reached Challenge. Nothing was charged.
      return

    default:
      // 'unknown', and any newer code the server adds. Always keep this branch.
      console.warn('Challenge: attempt not started:', error.code)
      playFreeRound()
  }
}
```

## Step 5. Report the outcome from your server

One HTTPS request, from a server holding the key.

```
POST https://api.withchallenge.com/v1/attempts/{attempt_id}/outcome
```

| Header | Value |
|---|---|
| `X-API-Key` | `sk_test_...` in sandbox, `sk_live_...` in production. Server side only, always |
| `Idempotency-Key` | Required. Use the attempt id |
| `Content-Type` | `application/json` |

```json
{ "score": 34 }
```

An attempt has exactly one outcome, forever, so the attempt id is the natural idempotency key and there is nothing to generate or store. That is what makes a blind retry the correct response to a timeout.

**Where the handler runs decides two details below.** If the game is served statically, by `npx serve`, a CDN or any host that only returns files, then the handler runs on its own origin and a different port. The browser must post to its **absolute** URL, and the handler must answer CORS preflight, which is why both templates carry it. If the game's own server hosts the handler on the same origin, the relative path `/round-finished` works and the CORS middleware is unnecessary: delete it.

Pick one of the two templates below. Both are complete and runnable as written.

### Supabase edge function

The fastest server is one you do not run. Create `supabase/functions/round-finished/index.ts` with exactly this content:

```typescript
// The key lives on your server. It never reaches the browser.
const CHALLENGE_API = Deno.env.get('CHALLENGE_API_BASE') ?? 'https://api.withchallenge.com'

// Your game's origin. A browser calls this function directly, so it needs one.
// Replace '*' with your own origin before you go live.
const CORS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, content-type',
  'Content-Type': 'application/json',
}

Deno.serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: CORS })

  const { attemptId, moves } = await req.json()

  if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) {
    return new Response(JSON.stringify({ error: 'invalid_request' }), {
      status: 400,
      headers: CORS,
    })
  }

  // Derive the score from what only this server knows: the seed you dealt, the
  // session it belongs to, the player who is signed in. Never take a verdict.
  const score = moves

  const settled = await fetch(
    `${CHALLENGE_API}/v1/attempts/${attemptId}/outcome`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': Deno.env.get('CHALLENGE_API_KEY')!,
        // An attempt has exactly one outcome, so the attempt id is the key.
        'Idempotency-Key': attemptId,
      },
      body: JSON.stringify({ score }),
    }
  )

  // Challenge applies the bar it froze onto the attempt when it took the fee.
  // Pass its answer through, or keep it: the island in the player's browser
  // hears the settlement on its own either way.
  return new Response(await settled.text(), { status: settled.status, headers: CORS })
})
```

Deploy it:

```bash
# The key lives on your server. It never reaches the browser.
supabase secrets set CHALLENGE_API_KEY=sk_test_...
supabase functions deploy round-finished
```

The deployed URL is `https://<project-ref>.supabase.co/functions/v1/round-finished`. That is the value of `ROUND_FINISHED_URL` in step 4, unless you are proxying the function from your own origin.

### Express

If the developer already runs a backend, create `server.js` with exactly this content:

```javascript
// The key lives on your server. It never reaches the browser.
const express = require('express')

const CHALLENGE_API = process.env.CHALLENGE_API_BASE || 'https://api.withchallenge.com'

// 4310 stays clear of the ports a game's own dev server takes: 3000 for npx
// serve and Next, 5173 for Vite, 8080 for a static server.
const PORT = Number(process.env.PORT || 4310)

// The origin the game is served from. A statically served game calls this
// handler cross-origin, so it needs CORS. Set GAME_ORIGIN to that exact origin
// before you go live. The '*' default is for local development only.
const GAME_ORIGIN = process.env.GAME_ORIGIN || '*'

const app = express()
app.use(express.json())

// Drop this middleware if your own server serves the game on this same origin.
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', GAME_ORIGIN)
  res.setHeader('Access-Control-Allow-Headers', 'content-type')
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
  if (req.method === 'OPTIONS') return res.sendStatus(204)
  next()
})

app.post('/round-finished', async (req, res) => {
  const { attemptId, moves } = req.body ?? {}

  // Validate the round before it is worth anything. This is where a real game
  // checks it against what it knows: the seed it dealt, the session it belongs
  // to, the player it belongs to. A move count is a count.
  if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) {
    return res.status(400).json({ error: 'invalid_request' })
  }

  // THE SCORE IS DERIVED HERE, never taken from the client as a verdict.
  const score = moves

  const settled = await fetch(
    `${CHALLENGE_API}/v1/attempts/${attemptId}/outcome`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': process.env.CHALLENGE_API_KEY,
        // One outcome per attempt, so the attempt id is the natural key. A
        // retry replays the original answer instead of settling twice.
        'Idempotency-Key': attemptId,
      },
      body: JSON.stringify({ score }),
    }
  )

  return res.status(settled.status).json(await settled.json())
})

app.listen(PORT, () => console.log(`Partner server on http://localhost:${PORT}`))
```

Run it with the key in the environment and nowhere else:

```bash
# The key lives on your server. It never reaches the browser.
export CHALLENGE_API_KEY=sk_test_...
export GAME_ORIGIN=http://localhost:3000   # where the game itself is served
node server.js
```

The handler is then at `http://localhost:4310/round-finished`. That absolute URL is what the browser in step 4 posts to.

### The response

`200`, with the settlement itself:

```json
{
  "attempt_id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
  "score": 34,
  "met_threshold": true,
  "threshold_applied": 36,
  "recorded_at": "2026-08-24T12:00:31.000Z",
  "receipt_id": null,
  "award": {
    "id": "b1d8e3a0-5c72-4f19-9e3b-0a7c4d2f8e11",
    "amount": "9.00",
    "paid_at": "2026-08-24T12:00:31.000Z"
  }
}
```

Money in `/v1` is always a decimal string, never a float. In sandbox the fee and the prize are `0.00` and `award` is `null` even when `met_threshold` is `true`, because Challenge has not priced the Challenge yet.

Replay semantics, which is what makes retrying safe:

| Situation | Answer |
|---|---|
| Same key, same body, first call completed | The original result, with `Idempotency-Replayed: true`. Semantically identical, though JSON key order may differ, so never assert on the raw body |
| Same key, same body, first call still in flight | `409 idempotency_in_progress`. Retry shortly |
| Same key, different body | `409 idempotency_key_reused`. Use a new key for a new operation |
| No `Idempotency-Key` header | `400 invalid_request` |
| A different key on an attempt that already settled | `409 attempt_already_settled`. Treat as success |

Records are retained for 24 hours. Only `2xx` responses are stored, so a failed call is simply retried under the same key. Retry on a timeout or a `5xx` under the **same** key. That is what the key is for.

**There is no step 6.** Nothing is relayed back to the game and the game is never asked to display a verdict. The island hears the settlement on its own, over a WebSocket on the `attempt:{attemptId}` channel raced against a three-second poll, and renders the win or the miss itself.

If nothing is ever reported, the attempt expires one hour after creation, the fee is refunded in full, and the island tells the player.

### Before this handles real money

The template above is the integration. Challenge bounds what a cheat can cost but does not yet catch a fabricated score for you, so the two checks below are yours and neither is optional. What Challenge guarantees is a ceiling: the tier caps every prize (100 dollars at the entry tier), a player wins a given Challenge only once, a young bar pays half prize for its first 200 attempts, and withdrawals hold for review. Those bound a single cheat; they do not stop one.

**You do not need your own player login. Challenge owns the player.** The player authenticated to Challenge through the island, not to your game, and the `attemptId` belongs to that player: it is an opaque value only they ever hold. Do NOT gate reporting behind a game session or a per-player "does this caller own this attempt" check. Requiring a player to sign in to your game to settle defeats the point of Challenge, and it is not what keeps a report honest anyway. Two things you already control do that. First, **only your SERVER holds the secret key**, so only your server can report an outcome. Keep `sk_...` server-side, never in the browser; a client cannot forge a report. Second, **the score you report is one you can stand behind** — the next two checks. (The attack this paragraph used to chase — one player posting a round for another — gains the attacker nothing: the prize accrues to the attempt's owner, not the caller, and the caller would need the victim's opaque attempt id, which is never exposed. The score you actually need to defend against is a fabricated one on the caller's OWN attempt, and that is checks two and three, not a login.)

**Always: bound the number.** Reject a score your game cannot produce: above the maximum a round can reach, below zero, from a round shorter than a round can be. A client that can post `{ "score": 1 }` on a lower-is-better Challenge has already won. One line of arithmetic, and it is what turns an unbounded fabricated score into at worst a plausible one.

**As prizes grow: derive rather than receive.** The two checks above are the floor. Challenge's caps make a single cheat survivable at the entry tier but do not detect one, so as the prize on the line grows, move toward a number your server derived: the round length you observed, the moves you accepted, and, if your engine is deterministic, re-deriving the score from a seed you dealt. At tier A this leaves your hands entirely: those programs settle only on independently verified sources, never on partner reports.

**Log the `attempt_id`, the `Idempotency-Key`, the HTTP status and the `receipt_id`, on success and on failure. Never log the key.**

### Optional: webhooks

Register an endpoint in the Developer Portal if the developer's own systems need to hear about settlement without polling. Events: `attempt.settled`, `attempt.refunded`, `challenge.calibrated`, `challenge.suspended`, `settlement.paid`, `tournament.settled`, `tournament.cancelled`.

- Verify `X-Webhook-Signature: sha256=<hmac>` over the **raw bytes** of the body, using the `whsec_...` secret, before parsing or trusting anything.
- Deduplicate on `X-Webhook-Delivery-Id`, which is stable across retries.
- Answer `2xx` within **5 seconds** and queue the work. Delivery is **3 attempts in total**, with retries due 1 minute and then 5 minutes after a failure. After the third the delivery is marked failed and is not retried.
- On `attempt.settled`, **re-fetch, do not reconstruct**: call `GET /v1/attempts/{id}` and trust that. Webhook amounts are JSON numbers while `/v1` money is decimal strings, and deliveries carry no ordering guarantee.

## Verify the integration

Run these in order. Each fails in a different place, so a sequence tells you where you are and a batch does not.

### 1. The key works and the program answers

```bash
# The key lives on your server. It never reaches the browser.
curl -i "https://api.withchallenge.com/v1/challenges?player_ref=integration-check&status=all" \
  -H "X-API-Key: $CHALLENGE_API_KEY"
```

**Expected:** `HTTP/1.1 200 OK` and a JSON body with a `data` array. Each row carries `id`, `name`, `fee`, `prize`, `status`, `metric_name` and either a `threshold` (live) or a `calibration` object (calibrating).

**`401` with `{ "error": { "code": "unauthorized", ... } }`** means the key is wrong, malformed, for the other environment, or on a program not approved for live use. All of those answer identically on purpose. Stop here and get the right key from the user. Nothing downstream can work.

### 2. Challenge.js is loaded and initialised

Open the game in a browser and run this in the console:

```javascript
typeof window.Challenge          // 'object'
window.Challenge.version         // a version string
typeof window.Challenge.startAttempt  // 'function'
document.querySelector('challenge-island')  // an element, not null
```

**Expected:** all four answer as annotated, and the console has no errors from Challenge. `undefined` for `window.Challenge` means the script tags did not load: check the URLs and any content-security-policy on the page. A console error naming `program` means `init()` ran with a missing or non-string program ID.

The island pill is visible in the anchor corner. If the program has no live Challenge yet, the pill is there but dormant: a small **Coming soon** pill that offers nothing. That is correct, not a failure. It does mean players see it, so the developer should decide deliberately whether the script tags ship before a live Challenge exists.

### 3. A recorded round lands

Still in the console, with the developer's real metric name, the one you read off `metric_name` in step 3:

```javascript
await Challenge.record('moves', 34)
```

**Expected:** `{ recorded: true }`. In the network tab it is one `POST /api/island/record` answered `202`. That route is the SDK's own internal endpoint, not part of `/v1`, so it takes no API key and appears in no reference table. Seeing a `202` on it is the expected signal, not a sign that something is calling the wrong API.

`{ recorded: false }` means the metric name was over 40 characters, the score was not a finite number, or the network call failed. All three also write a console line saying which.

**A `202` proves the round was stored, not that it was stored where a Challenge will read it.** The metric string is not validated against your Challenges. Re-run the check from step 2: trim and lowercase both the string you passed and the `metric_name` from `GET /v1/challenges?status=all`, and confirm they are the same characters.

### 4. A Challenge is live

Seed the distribution (step 3), then re-run the curl from check 1.

**Expected:** the Challenge's `status` is `"live"` and it carries a numeric `threshold` instead of a `calibration` object. Reload the game: the island now shows the Challenge with its fee, prize and bar.

### 5. A full paid round settles

Tap the island, confirm the Challenge to arm it, then play one round through the real game loop.

**Expected:** `startAttempt()` resolves `{ attemptId, feeCharged: 0, prizeOffered: 0, threshold: <number>, thresholdDirection: '...' }` in sandbox. Your server's `POST .../outcome` answers `200` with `attempt_id`, `score`, `met_threshold` and `threshold_applied` set to the frozen bar. Within a few seconds the island renders the result on its own, with no further call from your game.

`met_threshold` can be `true` in sandbox once a real bar is frozen. `award` stays `null`, because a zero prize pays nothing.

### 6. The settlement is durable

```bash
# The key lives on your server. It never reaches the browser.
curl -s "https://api.withchallenge.com/v1/attempts/<<YOUR_ATTEMPT_ID>>" \
  -H "X-API-Key: $CHALLENGE_API_KEY"
```

**Expected:**

```json
{
  "id": "...",
  "challenge_id": "...",
  "tournament_round_id": null,
  "player_ref": "...",
  "status": "settled",
  "fee_charged": "0.00",
  "created_at": "2026-08-24T11:59:12.000Z",
  "metadata": {},
  "outcome": {
    "attempt_id": "...",
    "score": 34,
    "met_threshold": false,
    "threshold_applied": 57.5,
    "recorded_at": "2026-08-24T12:00:00.000Z",
    "receipt_id": null,
    "award": null
  }
}
```

`"status": "settled"` with a non-null `outcome` is the integration working end to end. `"status": "open"` with `"outcome": null` means the outcome call never landed: check the server logs and re-run check 5 watching the network tab.

This is also the call to make whenever you want the truth about an attempt.

### 7. Send the outcome call twice

Re-run your server's report for the same attempt, with the same `Idempotency-Key`.

**Expected:** the same settlement, semantically identical, with an `Idempotency-Replayed: true` response header. Compare the parsed fields, never the raw body: key order is not part of the contract. A `409 attempt_already_settled` instead means a different key was sent the second time, which is a bug in how you derive the key: it must be the attempt id.

## Error catalog

Codes are a closed set. **Branch on the code, never on the message.** The wording is not part of the contract.

### Challenge.js

Rejections from `startAttempt()` are an `Error` with `name: 'ChallengeAttemptError'` and a `.code`. `Challenge.record()` never rejects at all.

#### not_initialised
`init()` has not run, or the island was destroyed mid-call. Call `Challenge.init({ program })` before any attempt, and move `destroy()` out of per-route teardown.

#### not_armed
Nothing is armed for this player. Not an error: run the round for free and still call `record()` at the end.

#### attempt_in_progress
A paid round is already open. Wait for settlement or the one-hour expiry. If it happens without a second round, your server is not reporting outcomes.

#### insufficient_funds
The balance does not cover the fee. Nothing was charged. Do nothing: the island offers a deposit.

#### challenge_not_available
The Challenge is suspended, closed, over the tier ceiling, or the server and island disagree about what is armed. Do not run a paid round. The island resyncs itself.

#### blocked
A responsible gaming break or self-exclusion the player set for themselves, or a state where this player may not play this Challenge for money. Do nothing, ever. Never offer an alternative paid path.

#### unauthorized
The player's own session is invalid or expired. Do nothing: the island puts sign-in up itself.

#### network
The request never reached Challenge, after its retries. Nothing was charged. Let the round run unpaid and log it.

#### client_reporting_removed
Something called `Challenge.reportScore()`, which was removed. Delete the call and report from your server instead.

#### unknown
A code newer than the bundle. Log `.message` and fall back to a free round. Always keep this branch.

Server-side conflict codes pass through with their own names: `challenge_already_claimed`, `rung_locked`, `attempt_cap_reached`, `eligibility_incomplete`, `entries_closed`.

### API

Every `/v1` failure is `{ error: { code, message } }` with a fixed HTTP status.

#### invalid_request
**400.** A malformed request: a missing or over-long `Idempotency-Key`, both or neither of `challenge_id` and `tournament_round_id`, a missing `player_ref`, a `score` that is not a finite number, or a distribution whose percentiles do not ascend or whose `sample_size` is under 500. Read `message`, which names the field.

#### unauthorized
**401.** One code for every key failure: missing header, wrong prefix, unknown key, right key for the wrong environment, a live key on an unapproved program, a suspended program. They answer identically so a guess cannot be narrowed. Check the header, the prefix and the program's approval, in that order.

#### insufficient_funds
**402.** The player cannot fund the fee. Nothing was charged and no attempt was created. On the browser path the island offers a deposit; on the server path send the player to their Challenge account.

#### not_found
**404.** No such resource, or it belongs to another program. A `403` is deliberately never returned, because it would confirm the resource exists. On `POST /v1/players` it means the email has no Challenge account: there is no auto-provisioning.

#### challenge_not_available
**409.** The Challenge is suspended, closed, or its prize is above your integrity tier ceiling. Read its `status` from `GET /v1/challenges?status=all`. A suspended Challenge is reinstated by review, not by another call.

#### challenge_already_claimed
**409.** This player has already been paid this Challenge. Permanent for the pair. Build the menu from `GET /v1/challenges?player_ref=...`, which already omits claimed Challenges.

#### rung_locked
**409.** A ladder rung whose parent Challenge this player has not claimed. It clears when they claim the parent. `GET /v1/challenges` already omits locked rungs.

#### attempt_cap_reached
**409.** Ten paid attempts on this Challenge today, or a tournament round's own per-player cap. It clears as the day or the round rolls forward. Free calibration attempts never count.

#### entries_closed
**409.** The tournament round is closed or inside the ten minutes before close when re-buys stop. Read `entries_close_at` and stop offering entry before it.

#### round_closed
**409.** A tournament outcome reported after the round stopped accepting scores. The entry is refunded by the sweep, so the player pays nothing, but the score does not count. Report at the end of each round, not in a batch.

#### attempt_already_settled
**409.** This attempt already has an outcome. Almost always a retry sent with a fresh `Idempotency-Key`. Send the same key and get the replay instead. If unsure whether the first call landed, `GET /v1/attempts/{id}`.

#### idempotency_key_reused
**409.** The same key on this endpoint with a different body. Use a new key for a new operation, and derive it from the attempt rather than from a player or round id.

#### idempotency_in_progress
**409.** A request with this key is still running. Retry shortly with the same key. A record stuck over 60 seconds is treated as dead.

#### internal_error
**500.** Something failed on Challenge's side. Retry with the **same** key: nothing durable is stored for a non-2xx.

#### client_reporting_removed
**410**, on `POST /api/attempts/{id}/outcome`, which is not a `/v1` route. Something is still calling the retired browser-side reporting endpoint, usually a cached old bundle. Report through `/v1` from your server.

#### Rate limiting
**429.** `/v1` allows 300 requests per minute per API key. The body is the limiter's own shape rather than the error envelope, so a parser assuming `error.code` on every failure breaks here. Back off for `retry_after` seconds. If you hit this in normal operation you are polling `GET /v1/challenges` per render: cache it and use webhooks for settlement.

### Webhook delivery

| What Challenge saw | Cause | Fix |
|---|---|---|
| Connection refused or DNS failure | The URL is wrong or not public | `localhost` and private ranges are unreachable. Use a tunnel in development |
| `3xx` | Your endpoint redirects | 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. The signature is the authentication |
| `404` | The path or method does not exist | The request is a `POST` |
| Other `4xx` | Signature verification rejected it, or the body failed to parse | Verify over the raw bytes, before any parsing or re-serialising |
| `5xx` | Your handler threw | Answer `2xx` first, do the work afterwards |
| TLS handshake failure | Expired, self-signed or incomplete chain | Challenge does not skip verification. Serve intermediates |
| Timeout | The handler took longer than 5 seconds | Acknowledge immediately and queue the work |
| Delivered but duplicated | A retry after a response Challenge never saw | Deduplicate on `X-Webhook-Delivery-Id` |

## Do not build any of this

Agents over-build here, and every item below is already built, rendered by Challenge, and will conflict with the real surface if you duplicate it.

- **No sign-in, sign-up or account screens.** The island runs the whole flow.
- **No deposits, no payment forms, no Stripe or card fields.** Challenge holds the money.
- **No age check, identity check or KYC flow.** Challenge runs verification.
- **No balance display, no wallet UI, no transaction list.** The island's account panel is that.
- **No fee, prize or bar of your own drawing.** Challenge quotes them on the arming card, and the numbers a player agreed to are Challenge's.
- **No win screen, lose screen, prize animation or result toast.** The island renders the outcome by itself within seconds of settlement.
- **No `won`, `passed`, `result` or `winner` field anywhere in a request to Challenge.** You report a measurement. Challenge decides.
- **No client-side settle call of any kind, and no API key in browser code.** Not in `NEXT_PUBLIC_*`, not in a `VITE_*` variable, not in a mobile binary, not in a committed file.
- **No arming endpoint.** There is no API that arms a Challenge for a player. Arming is the player's own act on Challenge's surface.
- **No `Challenge.reportScore()`.** It was removed and throws `client_reporting_removed`.
- **No `Challenge.gameEnded()`, `sdk.settleMatch()`, `sdk.settleDraw()`, `sdk.reportWinner()`, `Challenge.settle()`, `mode: "versus"`, `settlement: "consensus"`, `entryFee`, `matchmaking` or an 85/10.5/4.5 split.** Those belong to a retired model, were removed, and any material describing them is stale. Ignore it.
- **No npm package.** `npm install challenge` installs somebody else's package. The distribution is script tags.
- **No polling loop for settlement.** Use the webhook, or nothing at all: the island already handles the player's side.

## When the browser path does not apply

**Read this section only if the game has no browser client at all.** A native mobile game with no web view, a console title, a headless simulation. If the game runs in a browser, use step 4 and skip this entirely. Almost every integration should.

Without Challenge.js there is no island, so nothing renders accounts, verification, deposits, balances or results inside the game. The player does all of that in the [Player Portal](https://app.withchallenge.com), and getting them there is your problem. There is still no arming endpoint: the server door charges a fee against an account the player already funded.

Two calls replace `startAttempt()`.

```bash
# The key lives on your server. It never reaches the browser.
curl -i -X POST "https://api.withchallenge.com/v1/players" \
  -H "X-API-Key: $CHALLENGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "player_ref": "user_8134", "email": "player@example.com" }'
```

`player_ref` is your own identifier for the player, up to 64 characters, and it is the only identity that ever crosses back to you. This links an account that already exists; it never creates one. An email with no Challenge account answers `404 not_found`, worded so nobody can enumerate who has an account. Repeating the call with the same pair is safe and needs no idempotency key.

```bash
# The key lives on your server. It never reaches the browser.
curl -i -X POST "https://api.withchallenge.com/v1/attempts" \
  -H "X-API-Key: $CHALLENGE_API_KEY" \
  -H "Idempotency-Key: 3f0b7c21-9d84-4a6e-bb15-72c0e9d84a11" \
  -H "Content-Type: application/json" \
  -d '{ "challenge_id": "<<YOUR_CHALLENGE_ID>>", "player_ref": "user_8134" }'
```

```json
{
  "id": "7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40",
  "challenge_id": "a4e1c8b7-6f20-4d93-8c15-3b7e0a2f9d64",
  "tournament_round_id": null,
  "player_ref": "user_8134",
  "status": "open",
  "fee_charged": "2.00",
  "created_at": "2026-08-24T11:59:12.000Z",
  "metadata": {},
  "outcome": null
}
```

Exactly one of `challenge_id` and `tournament_round_id`. Both, or neither, answers `400 invalid_request`. `Idempotency-Key` is required, and a replay returns the same attempt rather than creating a second one.

Reporting the outcome is identical to step 5. There is one outcome endpoint and it does not care which door the attempt came through.

This door also accepts a `calibrating` Challenge, which the island will not arm. That is the money-free loop from step 3.

## For depth

Each of these is one page, and none of them is needed to finish the integration above.

- [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md): the whole lifecycle once, in order, as a sequence diagram.
- [The trust model](https://www.withchallenge.com/docs/trust-model.md): why the browser may start an attempt and may never end one.
- [Calibration](https://www.withchallenge.com/docs/calibration.md): the three ways a bar is measured, the provisional 20% ceiling, the 200-attempt half-prize ramp, and the drift monitor that suspends a Challenge.
- [Sandbox](https://www.withchallenge.com/docs/sandbox.md): the free loop in full, and exactly what differs from production.
- [Go-live checklist](https://www.withchallenge.com/docs/go-live.md): everything that must be true before `sk_test_` becomes `sk_live_`.
- [Webhooks](https://www.withchallenge.com/docs/webhooks.md) and [Webhook events](https://www.withchallenge.com/docs/webhook-events.md): handler, signature verification and every payload.
- [Challenge.js](https://www.withchallenge.com/docs/challenge-js.md) and [API](https://www.withchallenge.com/docs/api.md): the two references.
- [Errors](https://www.withchallenge.com/docs/errors.md): the same catalog as above, with more on each code.
- [Tournaments](https://www.withchallenge.com/docs/tournaments.md) and [The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md): the other two things you can sell on the same attempts and the same key.

## Add this to your project's AGENTS.md or CLAUDE.md

Paste the block below into the developer's own agent instructions file so the invariants survive after this session ends.

```text
## Challenge integration (withchallenge.com)

- The unit is a Challenge: a fixed standard, a fixed fee, a fixed prize Challenge funds.
- The program ID is public and belongs in the browser. CHALLENGE_API_KEY (sk_test_ / sk_live_)
  is server-only: never in client JS, a bundle, a mobile binary, or a NEXT_PUBLIC_* variable.
- Never invent a program ID or a key. If one is missing, stop and ask.
- Report a measurement, never a verdict. POST /v1/attempts/{id}/outcome accepts { "score": n }.
  There is no "won" field. Challenge applies the bar it froze when it charged the fee.
- Always send Idempotency-Key on the outcome call, and use the attempt id as its value.
- Call Challenge.record(metric, score) at the end of every round, paid or free. Recorded rounds
  never move an existing Challenge's calibration counter; they price future Challenges on that metric.
- Browser path by default: Challenge.init({ program }), the player arms on the island,
  await Challenge.startAttempt(), then the server reports. POST /v1/players + POST /v1/attempts
  is only for a game with no browser client.
- Challenge.reportScore(), Challenge.gameEnded(), sdk.settleMatch(), mode: "versus" and
  settlement: "consensus" were all removed. Ignore any material that uses them.
- Do not build sign-in, deposits, identity checks, balances, or win and lose screens.
  Challenge renders all of it.
- Docs: https://www.withchallenge.com/llms.txt (index) and
  https://www.withchallenge.com/integrate.md (this guide).
```

---

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