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.

8 min readUpdated 6 August 2026withdrawals, payouts, custom-fields, bank-transfer, fiat

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:

  1. You do not choose name. The model's setter overwrites whatever you type with camelCase(title). IBAN becomes iban, Account holder name becomes accountHolderName. Two titles that camel-case to the same key collide, and a field with an empty title is dropped silently on save.

  2. required is 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.

  3. The type list over-promises. The dropdown offers File Upload, Image Upload and QR Code. qr is not in the API's accepted enum (input, textarea, file, image) and a save containing it fails with a 400. file and image are accepted and stored, but the customer withdrawal form has no renderer for them — it handles textarea, then select, then falls through to a text input for everything else. Use input and textarea.

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, accountNumber or address are 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

  1. Create the method with status off, so it is not offered while you build it.

  2. Set processingTime to something true. The customer is shown it, and it is the number your support queue will be measured against.

  3. 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.

  4. Title Bank Name, Account Number and Address exactly where they apply, so they land in the formatted panel.

  5. Set minAmount high enough that fixedFee leaves a payout worth making, and set maxAmount to whatever single payout you are willing to release without a second look.

  6. 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.

  7. 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.