Appearance
Webhook Handling
FAST dispatches HMAC-SHA256 signed webhooks for all state changes. Your sportsbook must expose an HTTPS endpoint to receive these events.
Requirements
Your webhook endpoint must:
- Accept POST requests with a JSON body
- Return a 2xx status within 5 seconds (see Timeouts)
- Verify the HMAC-SHA256 signature in the
X-Webhook-Signatureheader
Payload Structure
json
{
"eventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"eventType": "challenge.completed",
"userId": "user-001",
"timestamp": "2026-01-15T10:00:00.000Z",
"data": {
"challengeId": "uuid",
"finalBalance": 105.50
}
}Signature Verification
FAST signs the exact request body bytes with your tenant's webhook secret using HMAC-SHA256. The hex-encoded signature is sent in the X-Webhook-Signature header. The secret is a 64-character hex string; use it as-is as the HMAC key (do not hex-decode it).
Always verify the signature before processing the event:
- HMAC the raw body as received. Do not parse and re-serialise it with
JSON.stringify(req.body): key order and number formatting can differ from what FAST sent (retries in particular are re-serialised from storage), and the signature will not match. - Use a constant-time comparison, and check the lengths first:
timingSafeEqualthrows on buffers of different length.
Node.js Example
javascript
const crypto = require("crypto");
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.FAST_WEBHOOK_SECRET;
function verifySignature(rawBody, signature) {
const expected = Buffer.from(
crypto.createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex")
);
const received = Buffer.from(String(signature));
return (
received.length === expected.length &&
crypto.timingSafeEqual(received, expected)
);
}
// express.raw keeps the body as the exact bytes FAST signed
app.post("/webhooks/fast", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-webhook-signature"];
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).send("Invalid signature");
}
const { eventId, eventType, userId, data } = JSON.parse(req.body.toString("utf8"));
switch (eventType) {
case "challenge.activated":
// New challenge started - show challenge UI to user
break;
case "challenge.passed":
// Challenge passed - prompt the user to continue or refund their own stake
break;
case "challenge.completed":
// User continued - Reward Account created, enable funded features
break;
case "challenge.failed":
// User failed - notify and allow re-purchase (own stake lost, bonus voided)
break;
case "challenge.stake_refunded":
// User exited after passing - refund only their own stake (data.ownStake)
break;
case "challenge.bonus_voided":
// Bonus voided (on refund) - remove any bonus display
break;
case "funded_account.activated":
// Reward Account created - enable funded features
break;
case "funded_account.revoked":
// Funded account ended - remove access. data.reason says why (drawdown,
// inactivity, first_payout_deadline, cancellation, admin); data.finalBalance
// is what was on it.
break;
case "funded_account.payout_requested":
// A payout request is waiting for your decision (data.payoutRequestId,
// data.amount = user's share, data.grossAmount = what leaves the balance,
// data.isMandatory, data.isFinal). Betting is frozen until you decide.
break;
case "funded_account.payout_rejected":
// The request was rejected, by you or because the account closed
// (data.reason = 'account_closed'). Nothing on the account changed.
break;
case "funded_account.payout":
// Payout executed - record in your system.
// data.grossAmount = gross profit taken off the balance;
// data.userPayout = the user's split of it; data.ownStakeReturned = own stake returned
// (non-zero only on the first payout); data.rewardBonus = payout bonus reward paid on top;
// data.totalPayout = userPayout + ownStakeReturned + rewardBonus.
// data.accountClosed = true when the max-payout lifecycle ends the account.
break;
case "funded_account.stake_refunded":
// Own stake refunded during the funded phase - refund data.ownStake
break;
case "cooldown.triggered":
// Daily loss limit hit - block betting UI
break;
case "cooldown.lifted":
// Cooldown ended - re-enable betting UI
break;
case "bet.validation_result":
// Bet validated - update bet status
break;
}
// Return 200 immediately - process business logic async
res.status(200).send("OK");
});
app.listen(3000);Event Types
| Event | Trigger |
|---|---|
challenge.activated | New challenge purchased |
challenge.passed | Challenge passed; user must choose continue or refund |
challenge.completed | User continued; Reward Account created |
challenge.failed | Challenge expired, drawdown, or insufficient activity |
challenge.stake_refunded | User exited after passing; own stake to be refunded by the operator |
challenge.bonus_voided | Bonus voided (on refund) |
funded_account.activated | Reward Account created after the user continues |
funded_account.revoked | Funded account ended (drawdown, inactivity, missed first-payout deadline, cancellation or by the operator). Includes reason and finalBalance. |
funded_account.payout_requested | A payout request is waiting for the operator's decision; betting is frozen until it is decided. Includes payoutRequestId, amount (the user's share), grossAmount, operatorShare, isFinal, isMandatory. |
funded_account.payout_rejected | A pending request was rejected, by the operator or because the account closed (reason: account_closed). |
funded_account.payout | Payout executed. Includes grossAmount (taken off the balance), userPayout (the user's split of it), ownStakeReturned (own stake back on first payout, 0 after), rewardBonus (payout bonus reward, paid on top), totalPayout, operatorShare, accountClosed (true if max payouts reached and the account is now closed). |
funded_account.stake_refunded | Own stake refunded during the funded phase |
cooldown.triggered | User hit daily loss limit |
cooldown.lifted | Cooldown period ended |
bet.validation_result | Bet validated (accepted or rejected) |
Retry Logic
A delivery counts as failed on any non-2xx response, a network error, or a timeout. FAST makes at most 5 attempts per event, each delay measured from the previous attempt:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 2 minutes later |
| 3 | 8 minutes later |
| 4 | 32 minutes later |
| 5 | 2 hours later |
Retries are picked up by a worker that runs once a minute, so each delay can run up to a minute long. After the fifth failed attempt the delivery is marked failed and moved to a dead letter queue. Failed deliveries are visible in the operator backoffice webhook log and can be retried manually.
Timeouts
The first attempt waits up to 10 seconds for your response; retries wait only 5. Respond within 5 seconds so every attempt succeeds.
Deduplication
A retry carries the same eventId in the body as the original delivery. Deduplicate on the body's eventId, not on a header: the X-Event-Id header is sent on the first attempt only.
Best Practices
- Return 200 immediately. Queue heavy processing for async execution in your system.
- Store the body's
eventIdand skip events you have already processed. FAST retries a delivery your endpoint processed but did not acknowledge with a 2xx in time. - Handle all event types, even if some require no action. Unknown event types should be logged and acknowledged with 200.
- Store the raw payload for debugging before processing.