Withdrawals
- 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-requestsopens a four-eyes request that another person approves (wallet:operation:intervene).
A user can move their balance out in two ways:
| Path | Destination | Operation type |
|---|---|---|
| Bank withdrawal | The user's own verified IBAN | WITHDRAW_BANK |
| Cash-out | Cardless cash via ATM or QR | Cash-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
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}| Field | Type | Required | Description |
|---|---|---|---|
endUserId | string | ✅ | The end user you act for; required on all three steps |
sourceWalletAccountId | string | ✅ | Account to debit; must belong to endUserId |
beneficiaryName | string | ✅ | Account holder's name |
iban | string | ✅ | The user's own IBAN |
bankCode | string | — | May influence which bank integration is used |
amount, currency | string | ✅ | |
paymentPurpose | string | — |
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
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.| Status | Meaning | What you do |
|---|---|---|
BANK_PROCESSING | Instruction is with the bank | Wait; a webhook will follow |
AWAITING_RECON | Outcome uncertain | Do not retry — wait for reconciliation |
COMPLETED | Funds left to the bank | bankRef appears on the receipt |
FAILED | The bank rejected it | Reservation 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)
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| Field | Type | Required | Description |
|---|---|---|---|
channel | string | ✅ | ATM or QR |
programId | string | ✅ | |
endUserId | string | ✅ | |
sourceWalletAccountId | string | ✅ | |
amount, currency | string | ✅ | |
trustedActionToken | string | — | 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 decision | Result |
|---|---|
| Approve | Code issued, funds blocked |
| Review | The operation becomes HELD — funds stay blocked but the code cannot be used at the ATM |
| Reject | No 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
| Event | When |
|---|---|
wallet.withdrawal.processing | Instruction in flight on the bank side |
wallet.withdrawal.completed | Withdrawal completed |
wallet.withdrawal.failed | Withdrawal failed, reservation released |
wallet.withdrawal.awaiting_recon | Outcome uncertain, sent to reconciliation |
wallet.withdrawal.reversed | A completed withdrawal was reversed |
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
Withdrawal stuck in AWAITING_RECON | The outcome on the bank side is unresolved | Waiting is correct; do not retry |
| Balance visible but not withdrawable | Part of the balance is blocked by another operation | Inspect open operations with GET /wallet/operations |
| Cash code not issued | KYC level too low or limit exhausted | KYC Levels · Limits |
Cash code locked (WALLET_CASHOUT_CODE_LOCKED) | The code was entered wrongly 5 times; the amount was returned | Ask the user to issue a new code |