# 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](fees) 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"
}
```

- `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](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`](webhook-events#withdrawal-confirmed)
and [`withdrawal.failed`](webhook-events#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](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

| 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](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](fees#withdrawals) 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
