Wallet
Withdrawals

Withdrawals

Who calls these endpoints?External API (s2s)
Base path
/api/v1/wallet
Identity
Ed25519 request signature — X-Key-Id, X-Date, X-Nonce, Digest (when there is a body), X-Signature. ±5 min clock window, single-use nonce, optional per-key IP allow-list.
Authorization
The key must carry the wallet product and the wallet permission the call requires; a call without it gets 403 WALLET_INSUFFICIENT_SCOPE. Permissions per endpoint: Key permissions.
Cross-surface
These endpoints are on the s2s surface. The end-user side is /api/portal/v1/wallet/me/withdrawals/*. Intervening in a stuck withdrawal is panel-only: POST withdrawals/:id/intervention-requests opens a four-eyes request that another person approves (wallet:operation:intervene).

A user can move their balance out in two ways:

PathDestinationOperation type
Bank withdrawalThe user's own verified IBANWITHDRAW_BANK
Cash-outCardless cash via ATM or QRCash-out operation

Sending to a third party's IBAN is a transfer, not a withdrawal — see Transfers → IBAN transfer. They use separate limit buckets and separate fee schedules.

Bank withdrawal

Coming soon — requires a bank integration

The contract below is complete and will not change. To run in production your program needs a connected bank integration; that is how money leaves. While no integration is connected, these endpoints do not accept requests. Status: Roadmap.

The flow is the same three steps as a transfer:

POST /api/v1/wallet/withdrawals/bank/quote
POST /api/v1/wallet/withdrawals/bank
POST /api/v1/wallet/withdrawals/bank/{operationId}/complete
GET  /api/v1/wallet/withdrawals/{operationId}
FieldTypeRequiredDescription
endUserIdstring✅The end user you act for; required on all three steps
sourceWalletAccountIdstring✅Account to debit; must belong to endUserId
beneficiaryNamestring✅Account holder's name
ibanstring✅The user's own IBAN
bankCodestring—May influence which bank integration is used
amount, currencystring✅
paymentPurposestring—

The complete body carries endUserId, operationId (the same id as in the path), integrityHash and nonce from the initialize response, plus otpCode or trustedActionToken when required — exactly as in a transfer.

Permission: initialize and complete send money out of the platform, so the key needs wallet:bank-payout:operate, available on the open-loop (Growth) plan. The quote and GET /withdrawals/{operationId} need only a read permission. Without it the call returns 403 WALLET_INSUFFICIENT_SCOPE — see Key permissions.

Status flow

Bank withdrawal lifecycleState machine: a withdrawal opens, OTP is verified, ledger entries are written and the instruction goes to the bank. It completes on reconciliation, moves to reconciliation when the outcome is uncertain, and fails when the bank rejects it.OTPCONFIRMTO BANKUNCERTAINBANK REJECTEDINITIALIZEDCHALLENGEDPOSTINGBANK_PROCESSINGCOMPLETEDAWAITING_RECONFAILEDLEGENDWAITING / TERMINALNOT A FAILURE
AWAITING_RECON is not a failure; the outcome on the bank side is simply not resolved yet. Retrying such a withdrawal risks a double send — wait for reconciliation.
StatusMeaningWhat you do
BANK_PROCESSINGInstruction is with the bankWait; a webhook will follow
AWAITING_RECONOutcome uncertainDo not retry — wait for reconciliation
COMPLETEDFunds left to the bankbankRef appears on the receipt
FAILEDThe bank rejected itReservation released; reason in failureCode
⚠️

AWAITING_RECON is not terminal. Treating such a withdrawal as failed and restarting it risks a double payout. Show the user "your transaction is being verified".

IBAN ownership

Withdrawals target the user's own account. Whether IBAN ownership can be verified depends on the capability the bank integration offers; if the provider does not offer it, verification must happen in your own flow (for example a statement or confirmation from the user).

Cash-out (ATM / QR)

Coming soon — requires a cash network connection

The whole flow (limit check, KYC gate, risk evaluation, code generation, cancellation and reconciliation) is built; what is missing is the cash withdrawal network connection. While no network is connected, code generation is rejected. Status: Roadmap.

The user generates a withdrawal code in the app and collects cardless cash at an ATM or point of service.

Generate a code

POST /api/panel/v1/wallet/cash-out
FieldTypeRequiredDescription
channelstring✅ATM or QR
programIdstring✅
endUserIdstring✅
sourceWalletAccountIdstring✅
amount, currencystring✅
trustedActionTokenstring—Step-up verification

When the code is issued the amount is blocked together with the operation's fee and tax — the user cannot spend the same money elsewhere. The fee and tax are collected from this block when the cash is dispensed.

The user collects the cash

The user enters the secret code from the issue response (plaintext) at the ATM or agent. The network passes this code along with the dispense confirmation; the code is verified on the server and cash is dispensed only if it matches. Once the dispense confirmation is processed, the operation completes.

  • If the code does not match, the dispense is refused (WALLET_CASHOUT_CODE_INVALID) and one of the code's attempts is used up.
  • Each code allows 5 wrong attempts. When they run out the code is locked (WALLET_CASHOUT_CODE_LOCKED) and the full blocked amount (including fee and tax) is returned to the user; the code can no longer be used even with the right value, so a new code must be issued.
  • A code that has expired, or whose amount has not been blocked yet, is never dispensed.

If it is never used

When the code expires the block is released; the full blocked amount (including fee and tax) becomes available again. The user can also cancel the code early.

Risk decisions and HELD

Cash is the hardest money movement to claw back, so it goes through risk evaluation.

Risk decisionResult
ApproveCode issued, funds blocked
ReviewThe operation becomes HELD — funds stay blocked but the code cannot be used at the ATM
RejectNo code, no block

Your operations team resolves a HELD operation from the panel: approving makes the code usable, rejecting returns the money to the user. On timeout it is returned automatically.

⚠️

Do not treat HELD as a failure. Tell the user "your transaction is under review" — telling them it failed makes them retry, creating a second block.

Limit and KYC gates

Cash-out has its own limit bucket and KYC level threshold. If your program has no cash-out KYC threshold configured, the gate does not engage — enabling cash-out without setting a threshold means allowing cash out with no level check.

Webhooks

EventWhen
wallet.withdrawal.processingInstruction in flight on the bank side
wallet.withdrawal.completedWithdrawal completed
wallet.withdrawal.failedWithdrawal failed, reservation released
wallet.withdrawal.awaiting_reconOutcome uncertain, sent to reconciliation
wallet.withdrawal.reversedA completed withdrawal was reversed

Common mistakes

SymptomCauseFix
Withdrawal stuck in AWAITING_RECONThe outcome on the bank side is unresolvedWaiting is correct; do not retry
Balance visible but not withdrawablePart of the balance is blocked by another operationInspect open operations with GET /wallet/operations
Cash code not issuedKYC level too low or limit exhaustedKYC Levels · Limits
Cash code locked (WALLET_CASHOUT_CODE_LOCKED)The code was entered wrongly 5 times; the amount was returnedAsk the user to issue a new code