Webhooks
How your own systems hear that an attempt settled, a fee came back, or a Challenge was pulled, without polling for it.
Runs on your server
Get started
- Write a handler at a public HTTPS URL that verifies the signature, answers
2xximmediately, and does its real work afterwards. - Register it in the Developer Portal and copy the signing secret it gives you into your server's environment.
- Send a test event from the portal and confirm your handler answers
200. - On
attempt.settled, re-fetch. CallGET /v1/attempts/{attempt_id}and trust that, rather than reconstructing state from the payload.
Webhooks are optional. A settled attempt is already rendered to the player by Challenge.js on its own, and the outcome call you made returns the settlement synchronously. Add webhooks when something on your side needs to know: your own ledger, an analytics pipeline, a support tool, an email.
The full list of events and their payloads lives on Webhook events. This page is about delivery.
Create a handler
The signature is computed over the raw request body, so your handler has to see the bytes before any JSON parser has touched them. In Express that means express.raw, not express.json, on this route.
Create webhooks.js with exactly this content:
// The key lives on your server. It never reaches the browser.
const express = require('express')
const crypto = require('crypto')
const app = express()
app.post(
'/webhooks/challenge',
// RAW, not JSON. The signature is over the bytes Challenge sent.
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Webhook-Signature') || ''
const eventType = req.header('X-Webhook-Event')
const deliveryId = req.header('X-Webhook-Delivery-Id')
if (!verify(req.body, signature, process.env.CHALLENGE_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'bad_signature' })
}
const payload = JSON.parse(req.body.toString('utf8'))
// ANSWER FIRST. Anything slow here is a delivery that times out and
// retries, which is how one slow email turns into three of them.
res.status(200).json({ received: true })
// Then do the work, off the request.
setImmediate(() => {
handleEvent(eventType, deliveryId, payload).catch((err) => {
console.error('challenge webhook failed', deliveryId, err)
})
})
}
)
function verify(rawBody, header, secret) {
if (!header.startsWith('sha256=')) return false
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const given = header.slice('sha256='.length)
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(given, 'utf8')
// Constant time, and length-checked first because timingSafeEqual throws on
// a length mismatch rather than answering false.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const seen = new Set()
async function handleEvent(eventType, deliveryId, payload) {
// Deliveries retry, so the same delivery id can arrive more than once.
// In production this belongs in your database, not in memory.
if (seen.has(deliveryId)) return
seen.add(deliveryId)
if (eventType === 'attempt.settled') {
// RE-FETCH, DO NOT RECONSTRUCT. This is the authoritative record.
const fresh = await fetch(
`https://api.withchallenge.com/v1/attempts/${payload.attempt_id}`,
{ headers: { 'X-API-Key': process.env.CHALLENGE_API_KEY } }
).then((r) => r.json())
await recordSettlement(fresh)
}
}
app.listen(3000, () => console.log('Webhook handler on http://localhost:3000'))Set both secrets in the environment and nowhere else:
# The key lives on your server. It never reaches the browser.
export CHALLENGE_API_KEY=sk_test_...
export CHALLENGE_WEBHOOK_SECRET=whsec_...Test your handler
Two ways, in this order.
From the portal. Open app.withchallenge.com/developer/webhooks and use the send control on the endpoint. It delivers a webhook.test event, signed exactly like a real one, and records the result in that endpoint's delivery log:
{
"event": "webhook.test",
"timestamp": "2026-08-24T12:00:00.000Z",
"data": { "message": "This is a test webhook from Challenge" }
}Confirm three things: your handler answered 200, the delivery log shows delivered, and your signature check actually ran. A handler that accepts everything passes this test too, so flip one character of the secret and confirm you get a 401.
From a real sandbox round. Run the Quickstart loop with your sk_test_ key and watch attempt.settled arrive. Sandbox deliveries carry an extra "test": true in the payload, which is the flag to branch on if your handler writes to a production ledger.
A local handler is not reachable from Challenge's servers. Put a tunnel in front of it while you develop, and register the tunnel's URL as a second endpoint.
Register your endpoint
In the Developer Portal, Webhooks in the developer sidebar, then Add Endpoint.
| Field | Notes |
|---|---|
| URL | Public HTTPS. This is what receives the POST |
| Events | Pick the ones you handle. An empty list means every event |
| Game | Optional. Scope the endpoint to one program, or leave it off for all of them |
| Description | Yours, for the list view |
Each endpoint gets its own signing secret, shaped whsec_ followed by 64 hex characters. Copy it into your environment when you create it.
The row also carries the controls you will want later: an on/off toggle that stops delivery without deleting the endpoint, a rotate control that issues a new secret, a send control for test events, and a delivery log with the status, the HTTP code and the first part of your response body for every attempt.
Rotating a secret takes effect immediately and there is no overlap window, so deploy the new secret first, or accept a short gap where deliveries fail their signature check and retry.
Secure your endpoint
Every delivery carries three headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with that endpoint's secret |
X-Webhook-Event | The event type, for example attempt.settled |
X-Webhook-Delivery-Id | A UUID identifying this delivery. Constant across retries of the same delivery |
Verify the signature before you read the payload, and compare in constant time. Your endpoint is public: without this check, anyone who learns the URL can tell you an attempt was won.
Node
// The key lives on your server. It never reaches the browser.
const crypto = require('crypto')
function verifyChallengeSignature(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== 'string' || !signatureHeader.startsWith('sha256=')) {
return false
}
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const given = signatureHeader.slice('sha256='.length)
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(given, 'utf8')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Python
# The key lives on your server. It never reaches the browser.
import hashlib
import hmac
import os
def verify_challenge_signature(raw_body: bytes, signature_header: str) -> bool:
secret = os.environ["CHALLENGE_WEBHOOK_SECRET"]
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
given = signature_header[len("sha256="):]
return hmac.compare_digest(expected, given)In Flask, request.get_data() gives you the raw bytes. In Django, request.body. In both cases read them before anything parses the JSON.
Debug
The delivery log on each endpoint records the HTTP status, the first 1,000 characters of your response body, and the error message when there was no response at all. Start there.
| Symptom | What Challenge recorded | Likely cause | Fix |
|---|---|---|---|
| Connection failure | No status, an error message | DNS does not resolve, the port is closed, or the host is behind a firewall | Confirm the URL resolves and answers from the public internet, not just from your VPC |
| TLS failure | No status, a TLS error message | Expired certificate, missing intermediate chain, or a hostname the certificate does not cover | Fix the chain. Challenge does not skip verification, in sandbox or in production |
| Timeout | No status, an abort error | Your handler did the work before answering | Answer 2xx inside 5 seconds and process afterwards |
3xx | The status at the end of the redirect chain | The endpoint moved | Redirects are followed, but register the final URL. A chain that ends on a non-2xx is a failure |
4xx | 400, 401, 403, 404, 422 | Signature check failing, route not mounted, a body parser rejecting the raw payload, or your own auth middleware in front of the route | A 401 from your own handler is usually the wrong secret or a JSON parser that reserialised the body before you signed over it |
5xx | 500, 502, 503 | Your handler threw, or the process is down | Catch inside the handler and still answer 2xx. A crash costs you the delivery |
Two specific traps worth naming:
- A JSON body parser upstream of your route. Once
express.json()has parsed and your framework has reserialised, key order and whitespace can differ and the HMAC will not match. Use the raw body. - CSRF middleware. A webhook is not a browser form post and carries no CSRF token. Exempt the route.
Delivery behaviors
Published, so you can size your own retry and alerting around them.
| Behavior | Value |
|---|---|
| Method | POST, Content-Type: application/json |
| Success | Any 2xx. Everything else is a failure, including 4xx |
| Timeout | 5 seconds from request to response |
| Delivery attempts | 3 in total, the first plus two retries |
| Retry schedule | The next attempt is due 1 minute after the first failure, then 5 minutes after the second |
| Retry sweep | Due retries are picked up every 30 seconds, so an attempt fires at or shortly after its due time |
| After the third failure | The delivery is marked failed and never retried. The endpoint stays active |
| Ordering | Not guaranteed. Do not rely on it |
| Duplicates | Possible. The same X-Webhook-Delivery-Id can arrive more than once |
Ordering is not guaranteed and the reason is structural: each delivery is dispatched independently, and a delivery that failed once is retried minutes after later deliveries have already landed. Two events about the same attempt can reach you in either order. This is exactly why the answer to attempt.settled is to re-fetch rather than to apply a diff.
An endpoint is never disabled automatically for failing. If it is down for an hour, the deliveries in that hour are lost after their third attempt and there is no backfill, so treat repeated failed rows in the log as an alert on your side.
Every event is offered to every active endpoint of yours that subscribes to it. An endpoint scoped to one program is offered only that program's events.
Best practices
Dedupe by X-Webhook-Delivery-Id. It is stable across retries of one delivery, so a table keyed on it, written inside the same transaction as your side effect, makes your handler idempotent. Nothing else in the payload is a safe dedupe key.
Answer 2xx fast, work afterwards. The budget is 5 seconds end to end. Anything that talks to a payment provider, sends an email or runs a report belongs on a queue, not in the request.
Subscribe to what you handle, not to everything. An empty event list means every event, including ones you will ignore, which costs you deliveries to answer and log noise to read. Name the events you act on.
Exempt the route from CSRF protection, and from any global auth middleware. The signature is the authentication. A framework-level guard in front of the route turns every delivery into a 403 that retries twice and then dies.
On attempt.settled, re-fetch rather than reconstruct. Call GET /v1/attempts/{attempt_id} with your key and write what it returns. That call is the authoritative record of an attempt: it carries the frozen fee, the bar that was applied, the score, and the award if there was one. Rebuilding that state from a payload that may be out of order, duplicated, or one of several is how ledgers drift.
Never log the payload with the secret, and never log the secret. Log the delivery id, the event type and the attempt id.
Verify in sandbox before you swap keys. The signature scheme, the headers and the retry schedule are identical in both environments, so an endpoint that verifies correctly against sk_test_ traffic will verify correctly against live traffic.
Next
- Webhook events lists every event with its payload and trigger condition.
- Real-time settlement covers the browser side, and why a partner server should use webhooks rather than the player-facing socket.
- Errors has the full catalog, including this page's debug table.
- Go-live checklist includes webhook verification.
Source: https://www.withchallenge.com/docs/webhooks.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.