---
title: "Quickstart"
description: "Six steps from an unmodified web game to a settled attempt in sandbox: two script tags, record, seed a bar, arm and startAttempt, report from a server, then verify with curl."
url: https://www.withchallenge.com/docs/quickstart
canonical_markdown: https://www.withchallenge.com/docs/quickstart.md
docs_index: https://www.withchallenge.com/llms.txt
api_version: v1
lastUpdated: 2026-09-22T16:56:18.000Z
---

# Quickstart

The shortest path from a web game you have not touched to a paid attempt settling against a bar, in sandbox, with nothing to undo afterwards.

You will add two script tags, one line at the end of your round loop, one line at the start of a paid round, and one HTTPS request from a server. Everything else, accounts, deposits, identity checks, the fee confirmation and the result screen, is Challenge's and is already built.

## Before you start

You need four things. Two of them only you can supply, and neither can be guessed or invented.

- **A developer account** in the [Developer Portal](https://app.withchallenge.com), with a program registered.
- **Your program ID.** It is public and goes in the browser. It appears in the integration snippet on the portal's developer dashboard, one snippet per game.
- **Your test key**, an `sk_test_...` string. It goes on your server and nowhere else. It is on the Games page in the portal, per game, behind a reveal control.
- **A web game you can edit**, with a round that ends and a number that describes how well it went. Moves, seconds, points, anything, as long as more or less of it is unambiguously better.

Set the key where your server code will read it, and never anywhere else:

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

Throughout this guide, `<<YOUR_PROGRAM_ID>>` is the program ID from the portal. Replace it. Do not substitute a plausible-looking UUID.

## 1. Add Challenge.js and initialise it

Put both script tags in the page that runs your game.

```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 and takes over. Calls made before it arrives are queued, so nothing is lost.

Then, once, wherever your game boots:

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

That is the whole browser configuration. The API base is detected from the script origin, so you set `apiBase` only for local development. Until your program has a live Challenge, the island draws a small dormant pill reading **Coming soon**: it offers nothing and charges nothing, but players do see it, so decide deliberately whether to ship it before a live Challenge exists.

## 2. Record every round

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

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

Use your own metric name and your own number. This is not part of the paid loop and it never charges anything: it is how Challenge learns what an ordinary round in your game looks like, which is what a bar is later measured from.

`record()` never rejects and never blocks a round. It resolves `{ recorded }`, throttles itself to one request per second per metric without dropping rounds, and queues anything reported before `init()` has finished.

## 3. Confirm you have a Challenge

Registering a program creates its first Challenge in the same request, so you probably already have one. Check the Challenges page in the portal, or ask the API:

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

`status=all` is the partner view. Without it you get only live Challenges, and a new one is `calibrating`.

To add another, use **Add challenge** on the Challenges page. It asks for four things: a name, the metric name, the metric unit, and the direction (`higher_is_better` or `lower_is_better`). It does not ask for a fee or a prize, because you do not set those. Challenge prices every Challenge, since a bar you chose would set the pass rate, which would set what Challenge expects to pay.

Note the Challenge's `id` from the response. The rest of this guide calls it the challenge id.

## 4. Give the Challenge a bar

A Challenge arrives `calibrating`: no bar, no fee, no prize, and not offered on the island. It leaves calibration when it has seen 1,000 adjudicated attempts from at least 200 distinct players, with no one player contributing more than 1% of them. Recorded rounds are a different sample: they price future Challenges on the metric, not this one.

You are not going to wait for that today. If you already know how your players score, hand Challenge the distribution and the Challenge goes live at once:

```bash
# The key lives on your server. It never reaches the browser.
curl -s -X POST \
  "https://api.withchallenge.com/v1/challenges/CHALLENGE_ID/distribution" \
  -H "X-API-Key: $CHALLENGE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: quickstart-seed-1" \
  -d '{
        "distribution": { "p5": 22, "p25": 30, "p50": 38, "p75": 47, "p95": 61 },
        "sample_size": 5000
      }'
```

Five percentiles and a sample size, which is all a threshold needs and which comes out of one query on your side. The percentiles must rise from `p5` to `p95`, and `sample_size` must be a whole number of at least 500. Challenge interpolates the bar from those five anchors at the percentile its target pass rate implies: a 10% target on a higher-is-better metric wants the 90th percentile, which sits three quarters of the way from `p75` to `p95`.

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

A bar placed this way is provisional: supplying the distribution means setting the pass rate, which sets what Challenge expects to pay, so the prize is capped at 20% of your tier ceiling until observed attempts confirm the claim. The cap lifts on its own.

The alternative, and the better one for production, is to record ordinary rounds from step 2 for a while and let the bar be measured from real play. A Challenge created for a metric you have already been recording can be given its bar in the same request that creates it, so no player ever meets it calibrating.

Seeding works only on a `calibrating` Challenge, and only on one that is not a rung of a ladder.

## 5. Arm the Challenge, then start the attempt

Reload your game and tap the island. It now shows the Challenge, with the fee, the prize and the bar. Confirm, and the Challenge is armed.

Arming is the player's act. There is no API that does it for them, and your code is not in this step at all. Sign-up, the age check and a deposit, if one is needed, all happen inside that same flow.

Now start the paid round from your game:

```javascript
async function playPaidRound() {
  let attempt
  try {
    attempt = await Challenge.startAttempt()
  } catch (error) {
    // The island already told the player whatever they need to know.
    console.warn('Attempt not started:', error.code)
    return
  }

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

  await fetch('/round-finished', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ attemptId: attempt.attemptId, moves })
  })
}
```

`startAttempt()` charges the fee, freezes the bar and the prize onto the attempt, and resolves only once the money has moved. Judge the round against `attempt.threshold` and `attempt.thresholdDirection` if you show a target, not against anything you read from the Challenge, because those two fields are this attempt's terms.

Rejections carry a `.code`: `not_armed`, `insufficient_funds`, `attempt_in_progress`, `challenge_not_available`, `blocked`, `not_initialised`, `unauthorized`, `network`, `unknown`.

In sandbox your Challenge is priced at zero until Challenge prices it, so `feeCharged` and `prizeOffered` come back as `0`. Every other part of the loop, the arming flow, the freeze, the reporting call, the idempotency key, the settlement event, behaves exactly as it will with money on it.

## 6. Report the outcome from your server

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.
Deno.serve(async (req: Request) => {
  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: { 'Content-Type': 'application/json' },
    })
  }

  // 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(
    `https://api.withchallenge.com/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 }),
    }
  )

  return new Response(await settled.text(), {
    status: settled.status,
    headers: { 'Content-Type': 'application/json' },
  })
})
```

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

If you already run a backend, the same handler in Express:

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

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

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

  if (typeof attemptId !== 'string' || !Number.isInteger(moves) || moves < 0) {
    return res.status(400).json({ error: 'invalid_request' })
  }

  const score = moves

  const settled = await fetch(
    `https://api.withchallenge.com/v1/attempts/${attemptId}/outcome`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': process.env.CHALLENGE_API_KEY,
        'Idempotency-Key': attemptId,
      },
      body: JSON.stringify({ score }),
    }
  )

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

app.listen(3000)
```

Notice what the endpoint does not accept: whether the player won. You supply the measurement, Challenge applies the bar it froze onto the attempt, so a generous report cannot inflate a payout.

There is no step 7. Nothing is relayed back to your game, and your game is never asked to display a verdict.

## Try it out

Three checks, in order. Each one fails in a different place, so run them in sequence rather than all at once.

### Your key works and your Challenge is live

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

Expect `HTTP/1.1 200 OK` and a body shaped like this:

```json
{
  "data": [
    {
      "id": "...",
      "name": "Fewest moves",
      "description": null,
      "fee": "0.00",
      "prize": "0.00",
      "currency": "USD",
      "status": "live",
      "prize_structure": "threshold",
      "threshold": 57.5,
      "min_calibration_n": 1000,
      "calibration_source": "partner_seeded",
      "metric_name": "moves",
      "metric_unit": "moves",
      "rung": 1,
      "parent_challenge_id": null
    }
  ]
}
```

A `401` with `{ "error": { "code": "unauthorized", ... } }` means the key is wrong for the environment. A key resolves to exactly one program, and the answer never hints at which half was wrong. A Challenge still showing `"status": "calibrating"` carries a `calibration` object instead of a threshold, and step 4 has not taken effect.

### Your reporting call settles the attempt

Play one paid round. The response your server relays back from Challenge is the settlement itself:

```json
{
  "attempt_id": "...",
  "score": 34,
  "met_threshold": false,
  "threshold_applied": 57.5,
  "recorded_at": "2026-08-24T12:00:00.000Z",
  "receipt_id": null,
  "award": null
}
```

`met_threshold` is Challenge's determination against `threshold_applied`, which is the bar frozen at step 5. `award` is `null` when the bar was missed and when the frozen prize was zero, and an object with an `amount` when a prize was paid.

Fetch it back to confirm it is durable, not just returned:

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

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

This is the call to make whenever you want the truth about an attempt. When a webhook later tells you one settled, re-fetch this rather than reconstructing state from the payload.

### The player sees the result without your help

Look at the island in the browser you played in. It renders the win or the miss on its own, within a few seconds, because Challenge broadcast the settled attempt on the `attempt:{attemptId}` channel to that player's client and Challenge.js polls the attempt every three seconds behind that. Whichever answers first draws it.

If you report nothing at all, the attempt expires an hour after it was created, the fee is refunded in full, and the island tells the player that too.

## You have a working loop

Everything from here is depth on a loop that already runs.

- **[Calibration](https://www.withchallenge.com/docs/calibration.md)** is what to read before your bar is real money's problem: the three ways a bar gets measured, the provisional 20% ceiling your seeded Challenge is under right now, the half-prize ramp over the first 200 paid attempts, and the drift monitor that suspends a Challenge whose realised pass rate leaves its band.
- **[Challenges](https://www.withchallenge.com/docs/challenges.md)** covers the status machine you just walked through, and **[The Challenge ladder](https://www.withchallenge.com/docs/challenge-ladder.md)** covers what happens after a player claims one: a harder rung at twice the prize, spawned and priced by Challenge, with nothing for you to configure.
- **[Tournaments](https://www.withchallenge.com/docs/tournaments.md)** are the other thing you can sell on the same attempts, the same reporting endpoint and the same key: house-funded fixed purses over 24-hour rounds, settled by rank.
- **Webhooks** are how your own systems hear about settlement. Register an endpoint, verify `X-Webhook-Signature: sha256=<hmac>` over the raw body before trusting a payload, and expect 3 delivery attempts in total, the retries due 1 minute and then 5 minutes after each failure, with no ordering guarantee. See [Webhooks](https://www.withchallenge.com/docs/webhooks.md).
- **Going live** is a checklist, not a switch: an approved program, `sk_test_` swapped for `sk_live_` in your server environment, no key anywhere in a client bundle, a webhook endpoint that verifies signatures, and a server that authenticates the caller so one player cannot post a round for another. [Sandbox](https://www.withchallenge.com/docs/sandbox.md) has the current version of it.

If you want the whole loop explained rather than built, [How a round works](https://www.withchallenge.com/docs/how-a-round-works.md) tells it once in order, and [the trust model](https://www.withchallenge.com/docs/trust-model.md) says why it is shaped that way.

---

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