#Payouts
A payout is POST /v1/withdrawals: the platform holds the amount and the fee from the user's balance, signs the transaction from the user's own address, broadcasts it, and tells you how it ended. This page walks the flow once, in order. Every figure comes from the API, never from this page: the price sheet is live, and the quote is the price.
Everything here is server-to-server. Your API key moves every balance on your account — call from your backend, never from a browser or an app.
#1. Quote it
GET /v1/withdrawal-fee-quote with the asset, the amount, to_address and user_id returns what this exact send will cost. Three fields are all you need:
| Field | What it is |
|---|---|
total_fee | what is held on top of amount, and what you show the user |
network_fee | the part that is the network's price for this send, priced now |
platform_fee | the part that is the flat platform fee from your schedule |
Two more fields tell you how fresh the figure is; ignore them on day one:
| Field | What it is |
|---|---|
dynamic | false when no live price could be read and a conservative fallback applies — label the figure "approx" then |
quoted_at | when this quote was priced — every figure is computed for the response, never stored |
(recipient_priced and network_cost are there for reconciliation; you never need them to charge a customer.)
The figure the quote returns is the one that is charged. Show total_fee before the user confirms, and check amount + total_fee against their balance first: a payout the balance cannot cover is refused with 409 insufficient_balance and nothing is held.
#2. Create it
POST /v1/withdrawals
{
"external_user_id": "10482",
"asset_id": "usdt_trc20",
"to_address": "TJYBSfZfwwa28mYZPkE9uodBwGbnwEqBK1",
"amount": "25",
"idempotency_key": "payout-8814"
}amountis what the recipient receives; the fees are charged on top of it.asset_idis an id fromGET /v1/assets;to_addressis validated for that asset's chain (400 invalid_addressotherwise) and the amount against the asset's decimals (400 invalid_amount— more places than the asset supports is refused, never rounded).- There is no minimum amount. How much you send is your call; the only condition is that the balance covers
amount + total_fee. A zero or negative amount is400 amount_below_minimum. idempotency_keyis required. Derive it from your own record (payout-8814), store it with that record, and reuse it on every retry — see Idempotency.
You get 202 Accepted with the withdrawal, status: requested or queued. The hold is placed now: the balance already reads lower.
#3. Watch it settle
| Status | Meaning |
|---|---|
requested → queued | accepted; waiting for the signer |
awaiting_approval | parked by your own maker-checker threshold (set under Settings → Security); an owner or finance login approves it from the Payouts page |
signing → broadcast → confirming | on its way; txid is on the row once broadcast |
confirmed | done — the recipient has the money |
failed | never broadcast; the hold is refunded automatically |
reversed | broadcast, then definitively failed on-chain; the hold is refunded and an operator reconciles |
Two webhooks carry the outcome: withdrawal.confirmed and withdrawal.failed. A rejection from the approvals queue arrives as withdrawal.failed too. Delivery is at-least-once, so deduplicate on the withdrawal id — see Webhooks.
The read is the truth. Before you tell the user anything final, read GET /v1/withdrawals/{id}. A webhook that did not arrive must not be able to strand a payout in your system: sweep your non-terminal payouts against the read on a schedule.
#4. When you do not know what happened
A request that timed out may or may not have created the payout. Two safe moves, and they agree:
- Re-POST with the same
idempotency_key. Either it creates the payout or it returns the existing one — with its status as it stands now, so the replay doubles as a status read. - Or look the key up:
GET /v1/withdrawals?idempotency_key=payout-8814returns the withdrawal, or404if none was ever created.
Never retry with a fresh key. A key reused with different parameters answers 409 idempotency_conflict — a collision in your key generation, not something to retry.
#Many at once
POST /v1/withdrawals/batch takes up to 50 sends in one call. Each item runs the same path as a single payout — fees and approval thresholds apply per item — and carries its own idempotency_key, so re-posting a batch after a timeout re-creates nothing. Items are independent: one bad address never sinks the rest.
#What can go wrong, and what to do
| Answer | Meaning | What to do |
|---|---|---|
400 invalid_address, invalid_amount, amount_below_minimum | the request is wrong | fix it; nothing was held |
400 self_transfer | the recipient is the user's own deposit address | send elsewhere; nothing was held |
409 insufficient_balance | the balance cannot cover amount + total_fee | nothing was held; show the user the total |
409 idempotency_conflict | that key was used with different parameters | fix your key generation; do not retry |
403 account_* | your account is not live | nothing to fix in the request — see Authentication |
503 chain_unavailable | that asset's chain is not reachable right now | retry later, unchanged, with the same key |
500 | ours | retry with backoff and the same key, then resolve with the key lookup |
#Fees, in one line
Every payout is our live network cost plus a flat platform fee, charged on top of amount in the asset being sent. The price sheet shows today's figures; the quote shows the exact one.
#Checklist
- [ ] Quote first; show
total_fee; check the balance coversamount + total_fee - [ ]
idempotency_keyderived from your own record and reused on every retry - [ ]
202handled as pending; the user told only afterconfirmedon the read - [ ]
withdrawal.failedandreversedrefund the hold — nothing for you to reverse - [ ] A reconciliation sweep over non-terminal payouts, whatever webhooks arrived