Appearance
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
| Action | Endpoint | Approval type | X-Fast-Event |
|---|---|---|---|
| Buy a challenge | POST /v1/challenges/purchase | purchase | approval.purchase |
| Withdraw funded-account profit | POST /v1/users/:userId/withdraw | withdrawal | approval.withdrawal |
| Refund the own stake after a pass | POST /v1/users/:userId/challenge/refund-stake | withdrawal | approval.withdrawal |
| Refund the own stake in the funded phase | POST /v1/users/:userId/funded/refund-stake | withdrawal | approval.withdrawal |
| Cancel a challenge | POST /v1/users/:userId/challenge/cancel | cancel | approval.cancel |
| Cancel a funded account | POST /v1/users/:userId/funded/cancel | cancel | approval.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 URL | Started by your backend (key-…) | Started from the widget (pk_… + user token) |
|---|---|---|
| Set | FAST calls it | FAST calls it |
| Not set, staging tenant | Proceeds | Proceeds (auto-approved, so demos work) |
| Not set, production tenant | Proceeds | Refused: 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:
type | Extra fields |
|---|---|
purchase | tierId, 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 |
cancel | amount: 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 refundedownStake) when the call succeeds. Thefunded_account.payout,challenge.stake_refundedandfunded_account.stake_refundedwebhooks 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.
Related
- Challenge Purchase
- Withdrawals
- Challenge Outcome
- Webhook Handling: same signature scheme