Skip to content

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-Signature header

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: timingSafeEqual throws 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 ​

EventTrigger
challenge.activatedNew challenge purchased
challenge.passedChallenge passed; user must choose continue or refund
challenge.completedUser continued; Reward Account created
challenge.failedChallenge expired, drawdown, or insufficient activity
challenge.stake_refundedUser exited after passing; own stake to be refunded by the operator
challenge.bonus_voidedBonus voided (on refund)
funded_account.activatedReward Account created after the user continues
funded_account.revokedFunded account ended (drawdown, inactivity, missed first-payout deadline, cancellation or by the operator). Includes reason and finalBalance.
funded_account.payout_requestedA 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_rejectedA pending request was rejected, by the operator or because the account closed (reason: account_closed).
funded_account.payoutPayout 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_refundedOwn stake refunded during the funded phase
cooldown.triggeredUser hit daily loss limit
cooldown.liftedCooldown period ended
bet.validation_resultBet 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:

AttemptWhen
1Immediately
22 minutes later
38 minutes later
432 minutes later
52 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 eventId and 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.

BLACKLINE CONSULTING d.o.o.