cryptoinfraDocs

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

Open as MarkdownFor AI assistants

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:

FieldWhat it is
total_feewhat is held on top of amount, and what you show the user
network_feethe part that is the network's price for this send, priced now
platform_feethe 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:

FieldWhat it is
dynamicfalse when no live price could be read and a conservative fallback applies — label the figure "approx" then
quoted_atwhen 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

text
POST /v1/withdrawals
{
  "external_user_id": "10482",
  "asset_id": "usdt_trc20",
  "to_address": "TJYBSfZfwwa28mYZPkE9uodBwGbnwEqBK1",
  "amount": "25",
  "idempotency_key": "payout-8814"
}
  • amount is what the recipient receives; the fees are charged on top of it.
  • asset_id is an id from GET /v1/assets; to_address is validated for that asset's chain (400 invalid_address otherwise) 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 is 400 amount_below_minimum.
  • idempotency_key is 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

StatusMeaning
requested → queuedaccepted; waiting for the signer
awaiting_approvalparked by your own maker-checker threshold (set under Settings → Security); an owner or finance login approves it from the Payouts page
signing → broadcast → confirmingon its way; txid is on the row once broadcast
confirmeddone — the recipient has the money
failednever broadcast; the hold is refunded automatically
reversedbroadcast, 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-8814 returns the withdrawal, or 404 if 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

AnswerMeaningWhat to do
400 invalid_address, invalid_amount, amount_below_minimumthe request is wrongfix it; nothing was held
400 self_transferthe recipient is the user's own deposit addresssend elsewhere; nothing was held
409 insufficient_balancethe balance cannot cover amount + total_feenothing was held; show the user the total
409 idempotency_conflictthat key was used with different parametersfix your key generation; do not retry
403 account_*your account is not livenothing to fix in the request — see Authentication
503 chain_unavailablethat asset's chain is not reachable right nowretry later, unchanged, with the same key
500oursretry 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 covers amount + total_fee
  • [ ] idempotency_key derived from your own record and reused on every retry
  • [ ] 202 handled as pending; the user told only after confirmed on the read
  • [ ] withdrawal.failed and reversed refund the hold — nothing for you to reverse
  • [ ] A reconciliation sweep over non-terminal payouts, whatever webhooks arrived