Report the score from your server
One HTTPS request, from a server you control, holding a key the browser never sees.
Runs on your server
This is the entire server half of the integration. There is no npm package and no SDK to install. It is one POST.
The request
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 |
{ "score": 34 }score must be a finite number. An optional evidence object is accepted alongside it and is written as a receipt before any verdict is reached, so a receipt can never be back-dated to fit a disputed result.
The attempt id is the idempotency key
An attempt has exactly one outcome, forever, so the attempt id is the natural key and there is nothing to generate or store:
Idempotency-Key: 7c1f0a92-4d6b-4b0e-9f3a-8b5c2d1e6f40That is not a convenience. It is what makes a blind retry the correct response to a timeout, rather than a risk.
Measurement, never verdict
Notice what the body does not accept: whether the player won. There is no won field, no result, no passed. You supply what was measured, Challenge applies the bar it froze onto the attempt when it took the fee, and decides.
That is the shape of the whole API and not an oversight. A partner who could report a verdict could pay themselves. The trust model states the rule and what else follows from it.
The response
200 with the settlement itself:
{
"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"
}
}| Field | Meaning |
|---|---|
met_threshold | Challenge's determination against the frozen bar |
threshold_applied | The bar this attempt was judged against. null on a calibrating or tournament attempt |
recorded_at | When the outcome was written |
receipt_id | The receipt for the evidence you sent, or null |
award | The prize paid, or null |
award is null when the bar was missed, and also when the frozen prize was zero, which is every calibrating attempt and every tournament attempt. Money in /v1 is always a decimal string, never a float: "9.00", not 9.
There is no step after this. Nothing is relayed back to your game, and your game is never asked to display a verdict. The player's island hears the settlement on its own and renders it. See Real-time settlement.
Idempotent replay
| Situation | Answer |
|---|---|
| Same key, same body, first call completed | The original result, with Idempotency-Replayed: true. The handler does not run again. 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 |
Idempotency records are retained for 24 hours. Keys are scoped per endpoint, so the same string on a different route is a different record, and they may be at most 255 characters.
Only 2xx responses are stored for replay. Replaying a 500 would turn a transient failure into a permanent one, so a failed call can simply be retried under the same key.
The other refusals
| Code | HTTP | Cause |
|---|---|---|
invalid_request | 400 | score is not a number, or the Idempotency-Key header is missing |
unauthorized | 401 | The key is wrong, revoked, or for the other environment |
not_found | 404 | No such attempt, or it belongs to another program |
attempt_already_settled | 409 | An outcome already exists for this attempt |
round_closed | 409 | A tournament attempt reported after its round closed. The attempt is refunded by the sweep, so the player pays nothing |
A foreign attempt id answers not_found rather than a forbidden, because a forbidden would confirm the attempt exists.
An attempt past its one-hour expiry is reaped and refunded on the spot and the call is refused, so a late report settles nothing and costs the player nothing.
The server, in full
Where the handler runs decides two details below. If your 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 your own server hosts the handler on the same origin as the game, the relative path /round-finished works and the CORS middleware is unnecessary: delete it.
Both templates below are complete and runnable. Pick one.
Supabase edge function
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.
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:
# The key lives on your server. It never reaches the browser.
supabase secrets set CHALLENGE_API_KEY=sk_test_...
supabase functions deploy round-finishedExpress
If you already run a backend, create server.js with exactly this content:
// 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:
# 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.jsThe handler is then at http://localhost:4310/round-finished. That absolute URL is what the browser posts to, unless the game is served from this same origin.
curl, to check the endpoint by hand
# The key lives on your server. It never reaches the browser.
curl -i -X POST \
"https://api.withchallenge.com/v1/attempts/<<YOUR_ATTEMPT_ID>>/outcome" \
-H "X-API-Key: $CHALLENGE_API_KEY" \
-H "Idempotency-Key: <<YOUR_ATTEMPT_ID>>" \
-H "Content-Type: application/json" \
-d '{"score": 34}'Run it twice. The second answer is the same settlement, semantically identical, and carries Idempotency-Replayed: true. Compare the parsed fields rather than the raw body: key order is not part of the contract.
Hardening before production
The templates above are the integration, not a sketch of one. Challenge bounds what a cheat can cost, but it does not yet catch a fabricated score for you, so the checks below are yours to make and the first two are not optional. What Challenge guarantees is a ceiling on the damage: your tier caps every prize (100 dollars at the entry tier), a player can win 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. The score you report is trusted, so make it one you can stand behind.
You do not need your own player login. Challenge owns the player. The player authenticated to Challenge through the island, not to your game, so do NOT require a game session to settle and do NOT add a per-player "does this caller own this attempt" check. Making a player log in to your game to report a score defeats the point of Challenge. The attemptId belongs to the player who armed it — an opaque value only they ever hold — and Challenge resolves the player on its own side.
What keeps a report honest is not a login. It is two things you already control. First, only your SERVER holds the secret key, so only your server can report an outcome: keep sk_... out of the browser and a client cannot forge one. Second, the score you report is one you can stand behind — the next two checks. The attack the old version of this section chased, 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 attempt id, which is never exposed. The score worth defending is a fabricated one on the caller's own attempt, and that is bounding and deriving, below.
Always: bound the number. Reject a score that your game cannot produce: above the maximum a round can reach, below zero, or from a round shorter than a round can be. One line of arithmetic, and it is the check that turns an unbounded fabricated score into, at worst, a plausible one. A client that can post { "score": 1 } on a lower-is-better Challenge is a client that has already won.
As your prizes grow: check the score against state only you hold. The two checks above are the floor, not the ceiling. Challenge's caps make a single cheat survivable at the entry tier, but they do not detect one, so as the prize on the line grows, move toward reporting a number your server derived rather than received: the round length you observed, the moves you accepted, and, if your engine is deterministic, re-deriving the score outright from a seed you dealt. At the top tier this question leaves your hands entirely: tier A programs do not settle on partner-reported scores at all, only on independently verified sources.
Log what you can correlate on. Log the attempt_id, the Idempotency-Key you used, the HTTP status, and the receipt_id from the response, on both the success and the failure path. Challenge does not currently return a per-request id header, so the attempt id is your correlation handle for a support conversation, and it is the id every one of Challenge's own records is keyed on. Never log the key itself.
Two more habits worth having:
- Treat
409 attempt_already_settledas success. It means the outcome landed on an earlier try. - Retry on a timeout or a
5xx, under the same key. That is what the key is for.
Next
- Webhooks is how your own systems hear about settlement, refunds and suspensions without polling.
- Server-created attempts is the second door, for games with no browser client at all.
- Go-live checklist is the list to walk before
sk_live_. - API reference has every
/v1endpoint and the full error contract.
Source: https://www.withchallenge.com/docs/report-score.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.