Skip to content

Operator Approvals ​

Some actions move money or cannot be undone: buying a challenge, withdrawing profit, refunding a stake, cancelling. You can have FAST ask your backend before it records any of them. You configure one approval URL. FAST POSTs a signed request to it, and goes ahead only if you answer { "approved": true } within 5 seconds.

Approvals are different from webhooks. A webhook tells you something has already happened. An approval call asks permission before FAST records the action. Most integrations use both.

When FAST calls you ​

ActionEndpointApproval typeX-Fast-Event
Buy a challengePOST /v1/challenges/purchasepurchaseapproval.purchase
Withdraw funded-account profitPOST /v1/users/:userId/withdrawwithdrawalapproval.withdrawal
Refund the own stake after a passPOST /v1/users/:userId/challenge/refund-stakewithdrawalapproval.withdrawal
Refund the own stake in the funded phasePOST /v1/users/:userId/funded/refund-stakewithdrawalapproval.withdrawal
Cancel a challengePOST /v1/users/:userId/challenge/cancelcancelapproval.cancel
Cancel a funded accountPOST /v1/users/:userId/funded/cancelcancelapproval.cancel

Continuing a passed challenge into the funded account (POST /v1/users/:userId/challenge/continue) never asks for approval. It is the player's decision. See Challenge Outcome.

Whether the call happens depends on whether a URL is set and who started the action:

Approval URLStarted by your backend (key-…)Started from the widget (pk_… + user token)
SetFAST calls itFAST calls it
Not set, staging tenantProceedsProceeds (auto-approved, so demos work)
Not set, production tenantProceedsRefused: 403 approval_required

Once a URL is set, FAST calls it for every action in the table, including the ones your own backend starts with the server key. If your backend has already decided, your handler can approve those straight away. The userId and type tell you which action it is.

Configure the URL ​

In the backoffice: Settings → Approval URL. Or with your server key:

bash
curl -X PUT https://api.fundedfast.eu/v1/admin/settings/approval-urls \
  -H "X-API-Key: $FAST_API_KEY" -H "Content-Type: application/json" \
  -d '{"approvalUrl": "https://your-backend.example.com/fast/approve"}'
# -> 200 { "approvalUrl": "https://your-backend.example.com/fast/approve", "correlationId": "…" }

Send {"approvalUrl": null} to turn approvals off. The SDK equivalent is fast.admin.settings.setApprovalUrl(url).

The URL must be https:// on the standard port (443), use a domain name rather than an IP address, and resolve to a public address. FAST rejects anything else with 400 validation_error when you save it, and declines the action if the check fails when it calls. FAST does not follow redirects, so point the URL at the final endpoint.

The URL is copied to your production tenant when it is created from staging. Production has its own webhook secret, though, so your handler has to verify with the production secret there.

The request ​

http
POST /fast/approve HTTP/1.1
Content-Type: application/json
X-Fast-Event: approval.purchase
X-Webhook-Signature: 3f1c…   (64 hex chars)

{"type":"purchase","userId":"player-42","tenantId":"…","tierId":"…","tierName":"Starter","purchasePrice":50,"currency":"EUR","timestamp":"2026-10-06T12:00:00.000Z"}

Every body has type, userId, tenantId, currency and timestamp. On a purchase currency is the tier's currency; on a withdrawal, refund or cancel it is currently always EUR, whatever the tier's currency. The rest depends on the action:

typeExtra fields
purchasetierId, tierName, purchasePrice (the tier's own stake: what you collect from the player)
withdrawal (profit withdrawal)grossAmount (the gross profit that will leave the balance: the grossAmount sent, or on the last payout the whole gross profit), amount (the user's share: the profit split of grossAmount, rounded down to the cent, within the caps), operatorShare (grossAmount - amount), ownStakeReturn (own stake returned on the first payout, else 0), rewardBonus (payout bonus reward paid on top, else 0), totalPayout (amount + ownStakeReturn + rewardBonus: what you will actually pay), currentBalance (ledger balance)
withdrawal (stake refund)amount: the own stake to be refunded
cancelamount: always 0, since a cancellation refunds nothing

Verify the signature ​

X-Webhook-Signature is the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. It is the same scheme FAST uses for webhooks. Compute it over the bytes you received, before any JSON parsing, and compare in constant time:

typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyFastSignature(rawBody: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  if (!signature || signature.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}

The SDK exports the same check as verifyWebhookSignature(rawBody, signature, secret) from @fundedfast/sdk/webhooks. It is async.

The response ​

Answer with a 2xx and a JSON body, within 5 seconds:

json
{ "approved": true }
json
{ "approved": false, "reason": "Deposit limit reached" }

Anything other than "approved": true (the boolean) is a decline. So are a timeout, a non-2xx status, a body that is not JSON, and a connection error. FAST fails safe and does not retry.

When you decline, the original request fails with 402 and an error code of <type>_declined. Your reason becomes the message:

json
{ "error": "purchase_declined", "message": "Deposit limit reached", "correlationId": "…" }

For a refund, the code is withdrawal_declined, because refunds use type: "withdrawal".

Example handler ​

typescript
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
const SECRET = process.env.FAST_WEBHOOK_SECRET!;

app.post('/fast/approve', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString('utf8');
  const sig = String(req.headers['x-webhook-signature'] ?? '');
  const expected = createHmac('sha256', SECRET).update(raw).digest('hex');
  if (sig.length !== expected.length ||
      !timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) {
    return res.status(401).json({ approved: false, reason: 'bad signature' });
  }

  const body = JSON.parse(raw);
  switch (body.type) {
    case 'purchase':
      // Collect body.purchasePrice from the player's wallet before approving.
      return res.json(await collectStake(body.userId, body.purchasePrice));
    case 'withdrawal':
      // KYC, limits, and fraud checks for a payout or stake refund.
      return res.json(await authorizePayout(body.userId, body.totalPayout ?? body.amount));
    case 'cancel':
      return res.json({ approved: true });
    default:
      return res.json({ approved: false, reason: 'unknown action' });
  }
});
// collectStake / authorizePayout are yours: each returns { approved, reason? }.

Keep the handler fast. The 5-second budget includes network time, and the player is waiting for the result.

Approval is not the payout ​

Approving tells FAST to record the action, and nothing more. The money still moves on your side:

  • purchase: collect the stake in your approval handler, or before your backend calls purchase. FAST never charges the player.
  • withdrawal: pay totalPayout (or the refunded ownStake) when the call succeeds. The funded_account.payout, challenge.stake_refunded and funded_account.stake_refunded webhooks confirm what FAST recorded.

If your handler approves but the action then fails inside FAST (for example, the amount is above what can be taken now, or below the minimum payout), FAST returns an error to the caller and records nothing. Reverse anything you reserved in the handler when you see that error, or reserve only once the webhook arrives.

BLACKLINE CONSULTING d.o.o.