Skip to content

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.

OutcomeChallenge statusWebhook(s)What you do
Passedpassed_pending_choicechallenge.passedShow the player the choice, then call continue or refund-stake
Continuedcompletedchallenge.completed, funded_account.activatedNothing to pay. The player now has a funded account.
Refundedrefundedchallenge.stake_refunded, challenge.bonus_voidedPay the player back their own stake (ownStake)
Failedfailedchallenge.failedNothing to pay. Tell the player why (reason).
Cancelledcancelledchallenge.cancelledNothing 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.

ErrorMeaning
400 confirmation_requiredconfirm: true missing (refund-stake)
400 missing_idempotency_keyX-Idempotency-Key header missing (refund-stake)
404 no_pending_choiceNo challenge for this user is waiting for a choice
402 withdrawal_declinedYour 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/validate returns 404 ("No active challenge or funded account for user"), and events sent to POST /v1/bets are 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:

reasonWhen
max_drawdown_exceededA losing settlement takes total losses to the ruleset's totalDrawdownPercent of the starting balance
insufficient_active_daysThe challenge reached expiresAt without enough active days
expiredThe 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, with cooldownUntil in 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_exists until that account closes.

BLACKLINE CONSULTING d.o.o.