Withdrawal methods
The screen that decides what payout details you collect from a customer — custom fields, processing time, fees and limits — and where those answers surface when you go to pay them.
A withdrawal method is the mirror of a manual deposit method, and it carries more weight, because it is the only place the platform ever asks a customer where to send their money. Get the fields wrong and every fiat payout becomes a support round-trip to obtain the IBAN, the account name or the routing number you forgot to ask for — while the customer's balance is already debited and waiting.
The screen is Admin → Finance → Withdrawal Management → Withdrawal Methods
(/admin/finance/withdraw/method). It is a full CRUD table.
| Action | Permission |
|---|---|
| Open the screen | access.withdraw.method |
| Open a row | view.withdraw.method |
| Create | create.withdraw.method |
| Edit, including switching a method on or off | edit.withdraw.method |
| Delete, single and bulk | delete.withdraw.method |
Fiat withdrawals debit immediately — this is not a deposit
POST /api/finance/withdraw/fiat locks the wallet row, debits the full
requested amount, and only then writes the transaction as PENDING. The
customer sees their balance drop the moment they submit.
That inverts the deposit rule you may be used to:
| Deposit | Withdrawal | |
|---|---|---|
| On request | Nothing moves | Wallet is debited |
| On approve | Wallet is credited amount − fee |
Status flips, fee is booked |
| On reject | Nothing moves | Wallet is refunded |
A rejected withdrawal refunds through walletService.credit under the
idempotency key withdraw_reject_<transactionId>, capped at what was actually
debited. So an unusable withdrawal request is recoverable — but it costs the
customer the round-trip, and their balance is frozen out of their hands until
you decide.
That is why the custom fields matter more here than anywhere else on the platform.
The fields on the record
| Field | Required | What it is |
|---|---|---|
title |
Yes | The name the customer picks |
processingTime |
Yes | Free text. Shown to the customer — see below |
instructions |
Yes | Free text, rendered on the withdrawal form |
image |
No | A logo |
fixedFee |
Yes | Flat fee, in the withdrawal currency |
percentageFee |
Yes | Percentage of the amount |
minAmount |
Yes | Floor. 0 means no floor |
maxAmount |
Yes in the payload, but nullable | Ceiling. 0 or null means no ceiling |
customFields |
No | The payout details you collect |
status |
Yes | Unlike a deposit method, the create form asks for it |
The create and edit dialogs group them as Basic Information (image, title, processing time, instructions, status), Fees limits and Custom Fields.
processingTime is a promise you are making
It is a plain string — "1-3 business days", "Within 24 hours" — and it is
displayed to the customer in three places: as a badge on the method card while
they choose, as the estimated time on the confirmation panel, and again on the
success screen after they submit. On the last two, a method with no value falls
back to "5-30 min", which is almost certainly not true of a bank transfer, so
always set it.
The Withdrawal Processing Time setting (withdrawProcessingTime, on by
default, under Admin → System → Platform Settings → Wallet → Transactions)
suppresses only the two estimated-time rows. The badge on the method card is
rendered with no settings check at all, so switching the setting off still shows
your processingTime on every method card.
Fees and limits
The fee is max(amount × percentageFee / 100 + fixedFee, 0), rounded to two
decimal places. The customer is debited the full amount and receives
amount − fee; the fee is booked as platform profit only when the withdrawal
settles, never at request time.
minAmount and maxAmount are enforced on the request with a message naming
the method — "Minimum withdrawal for <title> is <n> <currency>". A 0
or null bound means no limit. Fiat amounts are also rejected above 2 decimal
places, so no method can debit a value you cannot actually pay out.
Super Admin accounts are charged no fee at all, which makes a Super Admin test withdrawal a poor test of your fee configuration.
Custom fields: where the IBAN is captured
customFields is a JSON array of { name, title, type, required }. The editor
in the create/edit dialog is a table with a row per field: Name, Title, Type,
Required, delete.
| Type | Renders on the withdrawal form as |
|---|---|
input |
A single-line text box |
textarea |
A multi-line box, 3 rows |
file |
A plain text box — not an uploader |
image |
A plain text box — not an uploader |
| QR Code | Rejected by the API |
Three rules govern this editor, and all three surprise people:
-
You do not choose
name. The model's setter overwrites whatever you type withcamelCase(title). IBAN becomesiban, Account holder name becomesaccountHolderName. Two titles that camel-case to the same key collide, and a field with an empty title is dropped silently on save. -
requiredis browser-side only. It marks the label with an asterisk and blocks the submit button. Nothing on the server rejects a withdrawal for a missing custom field. -
The type list over-promises. The dropdown offers File Upload, Image Upload and QR Code.
qris not in the API's accepted enum (input,textarea,file,image) and a save containing it fails with a 400.fileandimageare accepted and stored, but the customer withdrawal form has no renderer for them — it handlestextarea, thenselect, then falls through to a text input for everything else. Useinputandtextarea.
Name three of your fields exactly like this
The withdrawal detail screen has a dedicated Withdrawal Details panel, and it
keys on exactly three metadata keys: bankName, accountNumber and address.
Nothing else gets a formatted row.
Because name is derived from title, you get that panel for free by titling
your fields:
| Field title | Stored as | Where it shows |
|---|---|---|
Bank Name |
bankName |
Withdrawal Details panel, formatted |
Account Number |
accountNumber |
Withdrawal Details panel, monospaced |
Address |
address |
Withdrawal Details panel, monospaced |
| anything else | its camelCase key | The raw metadata JSON only |
Everything you ask for is still recorded and still visible — just as a JSON blob
rather than a labelled row. For a bank payout, Bank Name, Account Number plus
input fields for the account holder, IBAN/SWIFT and branch is a good shape.
The withdrawal form applies a crypto-address sanitiser to any field whose name
contains the substring address, replacing the value with its letters and
digits only. That is correct for a wallet address and destructive for a postal
one: "12 Bank St, Lagos" is submitted as 12BankStLagos.
An IBAN or an account number is unaffected, because those are alphanumeric
anyway. But do not title a field Bank Address, Branch Address or anything
else that camel-cases to a key containing address unless the answer really is
a wallet address.
Every enabled method is offered for every fiat currency
The customer-facing lookup is withdrawMethod.findAll({ where: { status: true } })
— no currency filter. A method built for EUR SEPA transfers is offered under
every enabled fiat currency, with its fees and limits read in whichever currency
the customer chose.
If your corridors differ, create one method per corridor and put the currency in the title. Switching a method off removes it from the customer's list immediately; that is the safe way to retire one.
There is no status switch in the table. Unlike the deposit methods screen,
the status column here is a read-only Yes/No badge, and there is no
bulk enable/disable action either. To take a method off the customer's list you
open the row's Edit dialog and set Status there, under Basic
Information — the same edit.withdraw.method permission, and the same PUT that
saves every other field. The API does carry dedicated status endpoints, but
nothing on this screen calls them.
Where the collected values reach you
On submit, the answers are merged into the metadata of the debit transaction
that walletService.debit writes, alongside the method title and the fee
breakdown:
{
"method": "SEPA bank transfer",
"totalAmount": 250,
"netAmount": 247.5,
"fee": 2.5,
"bankName": "Example Bank",
"accountNumber": "DE89370400440532013000",
"accountHolderName": "A. Customer"
}The wallet service adds its own bookkeeping keys to the same blob — notably
totalDebit, the exact figure subtracted from the balance, which is what a
rejection refund is capped at.
Open the row at Admin → Finance → Withdrawal Management → Withdrawal Records
(/admin/finance/withdraw/log), then the individual transaction
(/admin/finance/withdraw/log/[id]). You get:
- the Withdrawal Details panel, if
bankName,accountNumberoraddressare present; - the Transaction Metadata panel, which prints the whole blob as formatted JSON — this is where every other field you asked for lives.
That screen is where you copy the details into your banking portal, make the payment, and then mark the withdrawal complete. What Approve and Reject actually do per wallet type is covered in Working the withdrawal queue.
The provider binding is not on this screen
The withdraw_method table carries a gatewayAlias column that binds a method
to a payout provider, and when that provider is enabled with auto-dispatch on,
withdrawals through the method are sent to it automatically instead of waiting
for you.
It is not in the create or edit form and not in the API schema, so it is null on every method you build here, and every method you build here is settled by hand. TransFi is the only provider with a dispatch adapter today — see TransFi ramps, virtual IBANs and fiat payouts.
Deleting a method
The model is paranoid: delete sets deletedAt, and existing withdrawals are
unaffected because they carry the method's title in their metadata rather
than a foreign key. Both the single and the bulk delete sit behind
delete.withdraw.method. Switching the method off is nearly always preferable.
Getting one live
-
Create the method with
statusoff, so it is not offered while you build it. -
Set
processingTimeto something true. The customer is shown it, and it is the number your support queue will be measured against. -
Add every field you need to make the payment — and go and look at a real payment instruction in your banking portal while you do it. The missing field is always discovered after the customer's balance is already debited.
-
Title
Bank Name,Account NumberandAddressexactly where they apply, so they land in the formatted panel. -
Set
minAmounthigh enough thatfixedFeeleaves a payout worth making, and setmaxAmountto whatever single payout you are willing to release without a second look. -
Switch it on, then run one small withdrawal from a non-admin test account — a Super Admin pays no fees and proves nothing about the fee configuration.
-
Open the row on
/admin/finance/withdraw/log/[id]and confirm every answer you asked for is present and legible before you rely on it.
Related
- Working the withdrawal queue — what Approve does per wallet type, and how rejection refunds.
- Withdrawal approval and two-factor policy — the seven settings that decide whether a human and a second factor are involved at all.
- Manual deposit methods — the inbound mirror of this screen.
- TransFi ramps, virtual IBANs and fiat payouts — the one provider that can execute a fiat payout without you.