Challenge
Docs>Get started>Quickstart

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, 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:

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

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

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.

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:

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

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

{ "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:

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:

// 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:

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

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

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

{ "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:

{ "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:

# 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"
{ "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 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 covers the status machine you just walked through, and The Challenge ladder 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 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.
  • 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 has the current version of it.

If you want the whole loop explained rather than built, How a round works tells it once in order, and the trust model 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

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.