Appearance
Challenge Outcome
Every challenge ends in one of four ways: passed, failed (which includes expiry), cancelled, or, after a pass, refunded. Only a pass needs a decision from you. FAST never completes a passed challenge by itself.
FAST moves no money in any of these cases. It records the outcome, sends a webhook, and leaves any payout or refund to your backend.
| Outcome | Challenge status | Webhook(s) | What you do |
|---|---|---|---|
| Passed | passed_pending_choice | challenge.passed | Show the player the choice, then call continue or refund-stake |
| Continued | completed | challenge.completed, funded_account.activated | Nothing to pay. The player now has a funded account. |
| Refunded | refunded | challenge.stake_refunded, challenge.bonus_voided | Pay the player back their own stake (ownStake) |
| Failed | failed | challenge.failed | Nothing to pay. Tell the player why (reason). |
| Cancelled | cancelled | challenge.cancelled | Nothing to pay. No refund is due. |
Webhook payloads use the usual envelope (eventId, eventType, userId, timestamp, data) and carry an X-Webhook-Signature header. See Webhook Handling.
Passed
A challenge passes when, after a bet event, all three are true:
- the balance is at or above
targetBalance - accepted bets ≥ the ruleset's
minBetsRequired - distinct days with an accepted bet ≥ the ruleset's
minActiveDays
FAST checks this after every bet event for the user, and once a minute for challenges past their expiry.
When it passes, the challenge moves to passed_pending_choice and stays there. No funded account is created and nothing is paid. FAST sends:
json
{
"eventId": "…",
"eventType": "challenge.passed",
"userId": "player-42",
"timestamp": "2026-10-06T12:00:00.000Z",
"data": {
"challengeId": "…",
"programBalance": 110.00,
"refundableOwnStake": 10.00
}
}and GET /v1/users/:userId/progress returns type: "challenge_passed":
json
{
"type": "challenge_passed",
"challengeId": "…",
"programBalance": 110.00,
"targetBalance": 110.00,
"ownStake": 10.00,
"bonusAmount": 90.00,
"refundableOwnStake": 10.00,
"choices": ["continue", "refund"],
"status": "passed_pending_choice",
"currency": "EUR",
"selfExcluded": false,
"correlationId": "…"
}Note the field names. A passed challenge has programBalance, not currentBalance. Code that reads progress.currentBalance without checking progress.type gets undefined here.
The player chooses
The choice belongs to the player. Show both options in your UI and send whichever they pick. The embedded widget shows the choice itself and makes the same calls.
Continue into the funded account. No body, no approval call:
bash
curl -X POST https://api.fundedfast.eu/v1/users/player-42/challenge/continue \
-H "X-API-Key: $FAST_API_KEY"
# -> 200 { "fundedAccountId": "…", "message": "Funded account created", "correlationId": "…" }The challenge becomes completed, and a funded account opens with the tier's fundedAccountBalance. The own stake and bonus carry over into it. FAST sends challenge.completed (challengeId, finalBalance) and funded_account.activated (fundedAccountId, fundedAmount, challengeId, profitSplitPercent, payoutCapPercent). Progress now returns type: "funded_account". Withdrawals from here on are covered in Withdrawals.
Exit and take back the own stake. This is irreversible. The bonus is voided and the challenge ends. The request must carry confirm: true and an X-Idempotency-Key header:
bash
curl -X POST https://api.fundedfast.eu/v1/users/player-42/challenge/refund-stake \
-H "X-API-Key: $FAST_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{"confirm": true}'
# -> 200 { "ownStake": 10, "bonusAmount": 90, "message": "Stake refund signalled to operator; bonus voided", "correlationId": "…" }The challenge becomes refunded. FAST sends challenge.stake_refunded (challengeId, ownStake) and challenge.bonus_voided (challengeId, bonusAmount). You pay the player ownStake, because FAST does not move the money. Reuse the same idempotency key if you retry the same request.
If you have an approval URL configured, FAST calls it with type: "withdrawal" before recording the refund, and a decline returns 402 withdrawal_declined.
| Error | Meaning |
|---|---|
400 confirmation_required | confirm: true missing (refund-stake) |
400 missing_idempotency_key | X-Idempotency-Key header missing (refund-stake) |
404 no_pending_choice | No challenge for this user is waiting for a choice |
402 withdrawal_declined | Your approval endpoint said no (refund-stake) |
With the SDK:
typescript
const progress = await fast.users.getProgress('player-42');
if (progress.type === 'challenge_passed') {
// Player picked "continue":
const { fundedAccountId } = await fast.users.continueChallenge('player-42');
// ...or player picked "refund" (the SDK sends confirm: true and an idempotency key):
// const { ownStake } = await fast.users.refundChallengeStake('player-42');
}While the choice is pending
FAST waits for the choice indefinitely. Nothing times out or resolves on its own. Until the player chooses:
- New purchases are refused with
409 challenge_awaiting_choice. - Bets do not count toward the challenge. The pass is locked in:
POST /v1/bets/validatereturns404("No active challenge or funded account for user"), and events sent toPOST /v1/betsare recorded without touching the passed challenge. That includes settlements of bets placed before the pass. Use your own judgement on whether to let the player keep betting on challenge funds in this state; the simplest UX is to ask for the choice first. - The player can still cancel (see below), which forfeits the stake like any cancellation.
Failed
A challenge fails when:
reason | When |
|---|---|
max_drawdown_exceeded | A losing settlement takes total losses to the ruleset's totalDrawdownPercent of the starting balance |
insufficient_active_days | The challenge reached expiresAt without enough active days |
expired | The challenge reached expiresAt with enough active days, but without passing (target or minimum bets not met) |
There is no separate "expired" event: expiry is a failure with reason: "expired" or "insufficient_active_days". FAST checks for expired challenges once a minute, so the failure can arrive up to about a minute after expiresAt. A challenge that meets all pass conditions when it is evaluated at expiry passes rather than fails.
json
{ "eventType": "challenge.failed", "userId": "player-42",
"data": { "challengeId": "…", "reason": "expired" } }24 hours before expiry FAST sends challenge.expiring_soon (challengeId, hoursRemaining, currentBalance, targetBalance, betsPlaced, activeDays). It is a good moment to nudge the player.
After a failure, progress returns type: "none" and the challenge appears in GET /v1/users/:userId/history with its failureReason. Nothing is owed to the player.
Cancelled
The player can walk away from an active or passed_pending_choice challenge. There is no refund: the own stake is forfeited and the bonus voided. The request must carry confirm: true:
bash
curl -X POST https://api.fundedfast.eu/v1/users/player-42/challenge/cancel \
-H "X-API-Key: $FAST_API_KEY" -H "Content-Type: application/json" \
-d '{"confirm": true}'
# -> 200 { "cancelled": true, "message": "Challenge cancelled. No refund issued.", "correlationId": "…" }FAST sends challenge.cancelled (challengeId, ownStakeForfeited, bonusVoided, finalBalance). If you have an approval URL, FAST calls it first with type: "cancel". For a cancel started from the widget on a production tenant, the approval URL is required. See Operator Approvals.
Buying again
- After failed or cancelled, the player cannot buy a new challenge until 00:00 UTC the next day. Until then a purchase returns
429 purchase_cooldown, withcooldownUntilin the body. - After refunded, the player can buy again straight away.
- After continue, the player holds a funded account, and a purchase returns
409 active_funded_account_existsuntil that account closes.
Related
- User Progress: every progress
typein full - Webhook Events: payload reference
- Withdrawals: what happens after continue
- Operator Approvals: gating refunds and cancellations