# Mass payouts

Pay your own recipients in batches, from your own **payout wallet**:
salaries and contractors, tournament prizes and affiliate commissions,
withdrawals from a trading platform or a marketplace. You upload a list (or
send it over the API), payitgo checks and prices every row, you approve the
batch, and payitgo sends each payment as soon as the wallet holds enough.

| | |
|---|---|
| **Coins** | 14 coins on 7 networks: USDT on Tron, BNB Smart Chain and Ethereum; USDC on BNB Smart Chain and Ethereum; DAI; ETH on Ethereum and BNB Smart Chain; TRX, BNB, BTC, LTC, DOGE and XRP ([networks and coins](#networks-and-coins)) |
| **Where the money comes from** | Your payout wallet: one address per network, yours alone, that you top up |
| **What it costs** | 0.5% of each payment, never less than $0.50, plus its network fee ([Fees](#fees)) |
| **How you send payments** | Each payout request as it comes in, sent together when you choose ([Payout requests](#payout-requests)); or whole batches from the dashboard (rows typed or pasted, or a CSV file) or the API ([The API](#the-api)) |
| **No address?** | A [claim link](#claim-links): the recipient chooses the coin and their wallet |
| **Other coins** | [Conversion](#conversion): keep one coin in the wallet and pay in any payout coin |
| **Payroll** | [Schedules](#schedules): the same batch every week, two weeks or month |
| **Safeguards** | Approvals, limits per payment, per batch and per day, a wait for new recipients, sanctions screening ([Approvals and limits](#approvals-and-limits)) |

Test mode has it all, on paper: a test key, or the dashboard's test mode,
tops up a test wallet with a click and sends nothing on chain
([In test mode](#in-test-mode)).

## How it works

1. **Make a batch**, or let your [payout requests](#payout-requests) make
   one. Each payment names a coin, an address, an amount and, if you like,
   your own `reference` for it and the recipient's name, email and a note.
2. **payitgo checks and prices it.** Every row is checked (the coin, the
   amount, the address down to its checksum, the sanctions list, your
   limits) and priced. A row with an error is left out; the rest are
   `ready`. The batch is `quoted`: its prices hold for an hour.
3. **Submit it.** With approvals turned on, it waits for them; otherwise it
   goes straight to funding.
4. **It is funded from your payout wallet.** When the wallet holds what the
   batch needs, coin by coin, its money is set aside and it starts. When it
   does not, the batch waits and says exactly how much to send, and where.
   It starts by itself once the money is there.
5. **payitgo sends it.** The batch's fees go to payitgo first, then its
   payments, up to 50 in each transfer (200 in one Bitcoin, Litecoin or
   Dogecoin transaction). Each payment gets its transaction id as it goes,
   and a webhook.

## Payout requests

**The easiest way in.** You don't have to build batches: your system sends
each payout request the moment it comes in (a trader withdrawing from your
platform, a contractor's invoice approved, an employee's pay run, a prize
won) with `POST /v1/payouts`. payitgo checks and prices it at once, answers
with what it costs, and keeps it in your **list of payout requests**. When
you choose (every hour, at the end of the day, or when someone presses
**Send** in the dashboard), send the list: its valid requests become a
batch, priced again at today's fees and submitted, then approved and funded
like any batch. Or let the list go by itself after a few minutes
([auto-send](#auto-send)).

A trading platform's withdrawals, start to finish:

1. A client asks to withdraw 250 USDT to their Tron wallet. Your server
   sends it to payitgo with your own id for the request as `reference`:

   ```bash
   curl -s https://api.payitgo.co/v1/payouts \
     -H "Authorization: Bearer $PAYITGO_KEY" \
     -H 'Content-Type: application/json' \
     -d '{"reference": "wd-88231", "asset": "USDT_TRC20", "address": "TR7mNkucnhLZoJQLLJ6DR88jpLQufWxUJi", "amount": "250", "name": "Ana Silva", "email": "ana@example.com"}'
   ```

   `201 Created`: the [payment](#the-payment-object), `ready` with its `fee`.
   One that cannot be paid comes back `invalid` with its `errors` (a wrong
   network, a sanctioned address, under the minimum): tell your client at
   once.

   ```json
   {
     "item": {
       "id": "5c7e9a1b3d5f7a9c1e3b5d7f",
       "merchant_id": "3f8a1c27b94e05d6a2c71e90",
       "livemode": true,
       "batch_id": "0d2f4a6c8e1b3d5f7a9c2e4b",
       "reference": "wd-88231",
       "row": 14,
       "recipient": { "name": "Ana Silva", "email": "ana@example.com" },
       "asset": "USDT_TRC20",
       "ticker": "USDT",
       "icon_url": "https://api.payitgo.co/coins/USDT_TRC20.svg",
       "chain": "tron",
       "address": "TR7mNkucnhLZoJQLLJ6DR88jpLQufWxUJi",
       "amount": "250",
       "send_amount": "250",
       "fee": "2.852417",
       "debit": "252.852417",
       "usd_rate": "1",
       "amount_usd": "250",
       "status": "ready",
       "errors": [],
       "warnings": [],
       "created_at": "2026-10-20T13:41:07.52Z",
       "updated_at": "2026-10-20T13:41:07.52Z"
     }
   }
   ```

2. More requests come in the same way. At any time, read the list and what
   sending it takes from the wallet:

   ```bash
   curl -s https://api.payitgo.co/v1/payouts/inbox -H "Authorization: Bearer $PAYITGO_KEY"
   ```

3. Send it: all its valid requests, or only the ones you pick (`ids`):

   ```bash
   curl -s -X POST https://api.payitgo.co/v1/payouts/send -H "Authorization: Bearer $PAYITGO_KEY"
   ```

   The answer is the new [batch](#the-batch-object), already submitted:
   waiting for its approvals, waiting for a top-up (`waiting.shortfall`
   says what to send where), or being sent.

4. Each request then tells you what became of it, by
   [webhook](#webhooks), with your `reference`:
   - `payout_item.processing`: it left the list for a batch: show the
     withdrawal as processing;
   - `payout_item.sent`, with the `tx_id`: mark the withdrawal paid;
   - `payout_item.failed`: it was not paid, its money is still in your
     wallet;
   - `payout_item.canceled`: it will never be paid, and `cancel_reason`
     says why (removed from the list, rejected by an approver of your team,
     its batch canceled, no longer payable): give your client their money
     back.

   Every request ends sent, failed or canceled, and you hear it, whoever
   acted on it: your system, a team member in the dashboard, or payitgo.
   See [Staying in sync](#staying-in-sync). Payroll works the same way:
   send each employee's pay as it is approved, and the list at the end of
   the day.

A request's `reference` makes it safe to send again: the same reference
answers `200 OK` with the payment that has it, wherever it is now. Once a
request is canceled, failed or expired, its reference is free again: to pay
it after all, send a new request (it may reuse the reference). A canceled
payment is never brought back.

### Take a payout request

`POST /v1/payouts` · a key with payouts on

The body is one payment: `asset`, `address` and `amount`, and optionally
`reference`, `name`, `email`, `note`, `tag`, `claim` and `claim_assets`
(as in [Create a batch](#create-a-batch)), and `webhook_url`: where this
request's events go instead of your account's webhook URL (https; it stays
with the request when it is sent). `201 Created`: `{"item": payment}`, in
your list (its `batch_id` is the list's), `ready` with its `fee` and
`debit`, or `invalid` with its `errors`. An invalid request stays in the
list, never sent, until you remove it. `200 OK` with the payment its
`reference` already names. New requests follow your payout settings'
`fee_paid_by` and `convert` as they are when the request comes in; when
sent, each is priced again and follows the conversion setting as it is
then (converted again, or no longer converted, to match).

| Status | Message | Cause |
|---|---|---|
| `400` | `invalid request body` | Not JSON, or a field of the wrong type |
| `400` | `webhook_url: url must use https` · `webhook_url: url host does not resolve` · `webhook_url: url must point at a public host` · `webhook_url: url must point at your own server, not payitgo's` | An unusable `webhook_url` |
| `403` | `this api key cannot make payouts: turn payouts on for it in the dashboard` | ([payout keys](/docs/authentication#payout-keys)) |
| `409` | `your list of payout requests is full: send or cancel some first` | 100,000 requests waiting |

### Read the list

`GET /v1/payouts/inbox?status=&limit=&offset=` · any key

```json
{
  "inbox": {
    "id": "0d2f4a6c8e1b3d5f7a9c2e4b",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": true,
    "name": "Payout requests",
    "source": "requests",
    "inbox": true,
    "open": true,
    "created_by": "requests",
    "by_api_key": false,
    "fee_paid_by": "merchant",
    "convert": "same_network",
    "status": "quoted",
    "totals": [
      { "asset": "USDT_TRC20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_TRC20.svg", "chain": "tron", "network": "Tron (TRC-20)", "items": 1, "amount": "250", "send_amount": "250", "fee": "2.852417", "debit": "252.852417", "debit_usd": "252.85", "saved_usd": "0" },
      { "asset": "USDT_BEP20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg", "chain": "bsc", "network": "BNB Smart Chain (BEP-20)", "items": 1, "amount": "75", "send_amount": "75", "fee": "0.51183421", "debit": "75.51183421", "debit_usd": "75.51", "saved_usd": "0" }
    ],
    "counts": { "total": 3, "invalid": 1, "ready": 2, "cooling": 0, "queued": 0, "sending": 0, "sent": 0, "failed": 0, "canceled": 0, "claimable": 0, "expired": 0 },
    "total_usd": "328.36",
    "approvals_needed": 0,
    "approvals": [],
    "members": {},
    "created_at": "2026-10-18T08:02:11.4Z",
    "updated_at": "2026-10-20T13:52:40.08Z"
  },
  "coverage": [
    { "asset": "USDT_TRC20", "ticker": "USDT", "network": "Tron (TRC-20)", "needed": "252.852417", "spendable": "100", "top_up": "152.852417", "address": "TMkq7dZ3vN8xR2pB6cH9sJ4fL1wY5tGaQe" },
    { "asset": "USDT_BEP20", "ticker": "USDT", "network": "BNB Smart Chain (BEP-20)", "needed": "75.51183421", "spendable": "500", "top_up": "0", "address": "0x8F3a6B2c9D1e4F7a0B5c8D2e6F9a1B4c7D0e3F6a" }
  ],
  "items": [ { "id": "5c7e9a1b3d5f7a9c1e3b5d7f", "reference": "wd-88231", "status": "ready", "…": "…" } ],
  "total": 3,
  "limit": 50,
  "offset": 0
}
```

| Field | Description |
|---|---|
| `inbox` | The list itself, a [batch](#the-batch-object) with `inbox` and `open` `true`: the `totals` and `counts` of the requests waiting in it (valid ones in `totals`). It is never submitted, and never listed among your batches; batch routes on it answer `409 this is your list of payout requests: …` |
| `coverage` | Per coin of the valid requests: `needed` (what sending them takes from the wallet, their `debit`s), `spendable` (what the wallet can spend now), `top_up` (what to send to `address` first; `"0"` when it is covered), with `ticker` and `network` |
| `items` | The requests waiting, in the order they came (`status` filters: `ready`, `invalid`, or both, comma-separated); `total`, `limit` (1 to 200, 50 by default) and `offset` page them |

### Send the list

`POST /v1/payouts/send` · a key with payouts on

No body sends every valid request (up to 10,000 a call: the rest wait for
the next); `{"ids": ["5c7e9a1b3d5f7a9c1e3b5d7f", …]}` sends only those. They
move into a new batch (`source: "requests"`, `inbox: true`, named "Payout
requests of 20 Oct 2026 14:00 UTC"), are priced again at today's fees, and
the batch is submitted as the key: then approvals, funding and the top-up
it may need, as for any [batch](#a-batchs-life) made with an API key. Up to
100 requests are priced while the call waits; more come back `validating`,
and the batch is submitted by itself once priced. Invalid requests, and the
ones you did not pick, stay in the list. Each request sent raises
`payout_item.processing`, with its new `batch_id`.

A request no longer valid when priced again (prices moved under the
minimum, an address added to the sanctions list, your limit per payment
lowered) comes back to the list with its `errors`, and raises
`payout_item.returned`; the others go on. It stays in the list, never sent
and never priced again, until you remove it.

`200 OK`: `{"batch": batch}`. Should submitting it fail (none of its
requests is valid any more at today's prices, or it is over your limit per
batch), its requests go back to the list, each saying why
(`payout_item.returned`), and that batch is canceled (`payout_batch.canceled`,
`canceled_by: "system"`); the answer is the error. For a send priced in the
background, your team gets a "Payout requests not sent" notification
instead.

| Status | Message | Cause |
|---|---|---|
| `400` | `no payout request to send` · `no valid payout request to send` | The list is empty, or has only invalid requests |
| `400` | `payout request 5c7e9a1b3d5f7a9c1e3b5d7f is not a valid request waiting in your list` | An id not `ready` in the list |
| `400` | `none of the requests is valid any more: they are back in your list, each saying why` | Priced again, none can be paid |
| `400` | `this batch takes $328.36, over your limit of $250.00 per batch` | Submitting it was refused: the requests are back in the list |
| `403` | `this api key cannot make payouts: …` | |

### Remove a request

`DELETE /v1/payouts/{id}` · a key with payouts on

A request still in the list is removed: `200 OK` with the payment,
`"status": "canceled"`, `"cancel_reason": "removed"`. Its `reference` is
free again (it moves to `released_reference`), it no longer has a
`batch_id`, and its record stays: `GET /v1/payouts/{id}` keeps answering it.
`payout_item.canceled` is sent.

One already sent but not on its way yet is canceled like any payment
(`"cancel_reason": "canceled"`): funded (`queued`, `cooling`, `sending`),
its money stays in the wallet and its fee comes back; not funded yet, its
batch is totalled again (approvals already given stand), and canceled when
nothing is left in it. A request sent at the very moment you remove it is
canceled where it went, never reported removed while it is paid.

Removing a payment canceled already answers it again, unchanged: safe to
repeat. `404 not found` for another mode's payment; `409 the payment is
sent` (or `failed`, `expired`) once it is done.

To pay a removed request after all, send it again as a new request: it may
reuse the same `reference`.

### Remove several requests

`POST /v1/payouts/remove` · a key with payouts on

`{"ids": ["5c7e9a1b3d5f7a9c1e3b5d7f", "7b1d3f5a9c2e4b6d8f0a1c3e"]}`, 1 to 1,000
ids (one given twice counts once). Each request still in the list is
removed as above, with its own `payout_item.canceled`; the list is totalled
again once. `200 OK` with a result per id, in the order given:

```json
{
  "results": [
    { "id": "5c7e9a1b3d5f7a9c1e3b5d7f", "item": { "id": "5c7e9a1b3d5f7a9c1e3b5d7f", "status": "canceled", "cancel_reason": "removed", "released_reference": "wd-88231", "…": "…" } },
    { "id": "7b1d3f5a9c2e4b6d8f0a1c3e", "error": { "status": 409, "message": "not in your list of payout requests (sent already): cancel it with DELETE /v1/payouts/7b1d3f5a9c2e4b6d8f0a1c3e" } }
  ]
}
```

| Result | When |
|---|---|
| `item` | Removed now, or removed already (nothing changes, no second event) |
| `error` `404` `not found` | No such payment in this mode |
| `error` `409` `not in your list of payout requests (sent already): …` | Sent from the list already: this call never cancels a request that left the list. Cancel it with `DELETE /v1/payouts/{id}` |
| `error` `409` `the payment is sent` (or `failed`, `expired`, `canceled`) | It ended |

| Status | Message | Cause |
|---|---|---|
| `400` | `remove between 1 and 1000 payout requests at a time` | No ids, or too many |
| `400` | `invalid request body` | |
| `403` | `this api key cannot make payouts: …` | |

### Auto-send

With `auto_send_minutes` in your [payout settings](#approvals-and-limits)
(1 to 1440; `0`, the default, sends by hand), the list sends itself once
its oldest valid request has waited that long: a withdrawal requested at
14:00 with 30 minutes goes at 14:30, with everything valid that came in
since. The batch is made and submitted by `auto-send` (its `created_by`),
with the approval rules of a batch made with an API key: approvals unless
`api_auto_submit` lets it skip them (and it never skips them for a new
recipient). Auto-send waits while payitgo has paused your payouts. Turning
auto-send on, or shortening it, loosens your rules: it takes effect 24 hours
later.

## The payout wallet

Your payout wallet is a set of addresses payitgo made for your account
alone: **one per network** (Tron, BNB Smart Chain, Ethereum, Bitcoin,
Litecoin, Dogecoin and the XRP Ledger), the same for every coin of that
network. It is made the first time it is asked for (opening the
payout wallet page, or `GET /v1/payout-wallets`), and it never changes. It
holds only your money, kept apart from every other account's: top it up
with what your batches need.

payitgo runs the wallet for you and signs its transfers, which only ever pay
your batches' recipients, your batches' fees, and withdrawals back to your
own payout addresses.

### Networks and coins

| Network | `chain` | Coins | Network fees paid with |
|---|---|---|---|
| Tron | `tron` | `USDT_TRC20`, `TRX` | payitgo's TRX on the wallet |
| BNB Smart Chain | `bsc` | `USDT_BEP20`, `USDC_BEP20`, `ETH_BEP20`, `BNB` | payitgo's BNB on the wallet |
| Ethereum | `ethereum` | `USDT_ERC20`, `USDC_ERC20`, `DAI_ERC20`, `ETH` | payitgo's ETH on the wallet |
| Bitcoin | `bitcoin` | `BTC` | The wallet's own BTC |
| Litecoin | `litecoin` | `LTC` | The wallet's own LTC |
| Dogecoin | `dogecoin` | `DOGE` | The wallet's own DOGE |
| XRP Ledger | `xrp` | `XRP` | The wallet's own XRP |

On Tron, BNB Smart Chain and Ethereum, payitgo pays each transfer's network
fee with its own coin, kept on the wallet for that, and charges it in each
payment's fee: your TRX, BNB or ETH are never spent on fees. On Bitcoin,
Litecoin, Dogecoin and the XRP Ledger, the wallet's own coin pays the
network: each payment's network fee stays in the wallet, set aside
(`reserved`) until its transaction spends it, and only the platform fee is
sent to payitgo.

- **Bitcoin, Litecoin, Dogecoin:** up to 200 payments go in one
  transaction paying them all, its change back to the wallet. A payment
  under the coin's smallest output is refused (0.00000546 BTC, 0.0000546
  LTC, 0.01 DOGE). Should the network's fee rate rise above what the
  payments were quoted, their transaction waits until it falls.
- **XRP Ledger:** the ledger locks a reserve on every account, so the
  wallet's `balance` is what it holds beyond its reserve. Its first top-up
  must bring the reserve too: `needed` and a batch's shortfall include it
  until the wallet is funded. Paying an address that was never funded takes
  at least the reserve; an address that needs a destination tag must be
  given one; an account that accepts only senders it authorised is refused.
- **ETH and BNB to a contract address** are refused: a contract may not
  handle a plain transfer.

**Send only these coins to their network's address.** Another token sent
there is not counted automatically: contact support and payitgo sorts it out
with you.

### Balances

`GET /v1/payout-wallets` (any key) or `GET /merchant/payout-wallets?mode=test`
(any role) shows each address, and what it holds of each coin:

```bash
curl -s https://api.payitgo.co/v1/payout-wallets -H "Authorization: Bearer $PAYITGO_KEY"
```

`200 OK`:

```json
{
  "wallets": [
    {
      "chain": "tron",
      "network": "Tron",
      "address": "TMkq7dZ3vN8xR2pB6cH9sJ4fL1wY5tGaQe",
      "explorer_url": "https://tronscan.org/#/address/TMkq7dZ3vN8xR2pB6cH9sJ4fL1wY5tGaQe",
      "livemode": true,
      "assets": [
        { "asset": "USDT_TRC20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_TRC20.svg", "balance": "500", "reserved": "122.202417", "spendable": "377.797583", "needed": "0" },
        { "asset": "TRX", "ticker": "TRX", "icon_url": "https://api.payitgo.co/coins/TRX.svg", "balance": "0", "reserved": "0", "spendable": "0", "needed": "0" }
      ]
    },
    {
      "chain": "bsc",
      "network": "BNB Smart Chain",
      "address": "0x8F3a6B2c9D1e4F7a0B5c8D2e6F9a1B4c7D0e3F6a",
      "explorer_url": "https://bscscan.com/address/0x8F3a6B2c9D1e4F7a0B5c8D2e6F9a1B4c7D0e3F6a",
      "livemode": true,
      "assets": [
        { "asset": "USDT_BEP20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg", "balance": "100", "reserved": "0", "spendable": "100", "needed": "151.26183421" },
        { "asset": "USDC_BEP20", "ticker": "USDC", "icon_url": "https://api.payitgo.co/coins/USDC_BEP20.svg", "balance": "0", "reserved": "0", "spendable": "0", "needed": "0" },
        { "asset": "ETH_BEP20", "ticker": "ETH", "icon_url": "https://api.payitgo.co/coins/ETH_BEP20.svg", "balance": "0", "reserved": "0", "spendable": "0", "needed": "0" },
        { "asset": "BNB", "ticker": "BNB", "icon_url": "https://api.payitgo.co/coins/BNB.svg", "balance": "0", "reserved": "0", "spendable": "0", "needed": "0" }
      ]
    },
    { "chain": "ethereum", "network": "Ethereum", "…": "…" }
  ]
}
```

The other networks follow in the same shape: Ethereum, Bitcoin, Litecoin,
Dogecoin, the XRP Ledger.

#### The wallet object

| Field | Type | Description |
|---|---|---|
| `chain` | string | `tron`, `bsc`, `ethereum`, `bitcoin`, `litecoin`, `dogecoin` or `xrp` |
| `network` | string | The network for people: `Tron`, `BNB Smart Chain`, `Ethereum`, `Bitcoin`, `Litecoin`, `Dogecoin`, `XRP Ledger` |
| `address` | string | Where to send that network's coins. Show it with a QR code |
| `explorer_url` | string, optional | The address on the network's block explorer. Live only |
| `livemode` | boolean | `false` for the test wallet |
| `assets` | array | What it holds of each payout coin of that network (below) |
| `error` | string, optional | Why its balances could not be read just now (the figures are then `0`): try again in a moment |

Each asset:

| Field | Type | Description |
|---|---|---|
| `asset` | string | The coin: one of the [payout coins](#networks-and-coins) of that network |
| `ticker`, `icon_url` | string | What people call it (`"USDT"`) and its icon, an SVG image ([Currencies](/docs/currencies#icons)) |
| `balance` | decimal string | What the address holds, read from the chain (at most 20 seconds old). For TRX, BNB and ETH, payitgo's own coin kept there for network fees is left out; for XRP, the ledger's reserve; for BTC, LTC and DOGE it counts confirmed coins and the wallet's own change. In test mode, the balance on paper |
| `reserved` | decimal string | Promised already: payments funded and not sent yet (with their network fee on Bitcoin, Litecoin, Dogecoin and XRP), claim links waiting (their worst-case `debit`), fees not sent yet, withdrawals not sent yet |
| `spendable` | decimal string | `balance` less `reserved`: what a new batch or a withdrawal can use |
| `needed` | decimal string | What your batches waiting for funds need beyond `spendable`: **the top-up to send** now. `"0"` when nothing waits |
| `withdrawal_fee` | decimal string | What withdrawing it costs right now, taken from the amount: a withdrawal must be more than it |

A batch waiting for funds also says what it lacks, coin by coin, with the
address to send it to (its [`waiting.shortfall`](#the-batch-object)).

### Unused funds

Top the wallet up as your batches need it, and withdraw whatever you like,
whenever you like. Every 6 hours payitgo looks at each live payout wallet;
one holding more than $25,000 of spendable funds (payitgo's threshold today)
for a week tells your team, at most once a week, with a notification in the
`payouts` category, so unused funds never go unnoticed: "Payout wallet
holding unused funds: Your payout wallet has held about $31000 unused for a
week. If your next payouts won't need it all, you can withdraw the rest to
your own payout address at any time." It is in your audit log too
(`payout_wallet.idle`).

### Withdrawals

What the wallet holds can be sent back to one of your
[payout addresses](/docs/account#payout-addresses) for that coin (one past
its 24-hour wait): from the dashboard's payout wallet page, by the
**owner**, with their password and their two-factor code. There is no API
route for it: an API key can never send the wallet's money anywhere but to
a batch's recipients.

`POST /merchant/payout-wallets/{asset}/withdraw?mode=test` · owner

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | decimal string | yes, unless `all` | How much of the coin leaves the wallet |
| `all` | boolean | no | `true` withdraws everything `spendable` |
| `address_id` | string | yes | One of your payout addresses that receives that coin |
| `password` | string | yes | The owner's password |
| `code` | string | when they have two-factor sign-in | A code from their authenticator app (or a recovery code) |

```bash
curl -s https://api.payitgo.co/merchant/payout-wallets/USDT_TRC20/withdraw \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"all": true, "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d", "password": "a-long-password", "code": "492071"}'
```

`201 Created`:

```json
{
  "withdrawal": {
    "id": "1d3f5a7c9e2b4d6f8a0c2e4b",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": true,
    "asset": "USDT_TRC20",
    "chain": "tron",
    "amount": "377.797583",
    "fee": "3.71",
    "send_amount": "374.087583",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "status": "requested",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "members": { "9b2e4c71d8a05f3e6c1a7b24": "owner@yourdomain.com" },
    "created_at": "2026-10-20T11:02:15.33Z",
    "updated_at": "2026-10-20T11:02:15.33Z"
  }
}
```

A withdrawal pays the network fee of two transfers (yours, and the one
taking its fee to payitgo), in the coin, out of `amount` (the coin's
`withdrawal_fee` in the wallet says how much right now): `send_amount` is
what reaches your address. Its
`status` goes `requested` → `sending` → `sent` (with `tx_id`, and in live
mode `explorer_url`, the transaction on the block explorer), or `failed`,
which leaves the money in the wallet. When it is sent, a
`payout_wallet.withdrawn` webhook and a "Payout wallet withdrawal sent"
notification follow. `GET /merchant/payout-wallets/withdrawals?mode=test`
lists the latest 50. While payitgo has paused payouts on your account, live
withdrawals are refused too.

| Status | Message | Cause |
|---|---|---|
| `400` | `payouts in SHIB_ERC20 are not available yet` | Not a payout coin |
| `400` | `address_id is not one of your payout addresses` · `that payout address receives USDT_BEP20, not USDT_TRC20` | The wrong address |
| `400` | `that payout address is used from 2026-10-21T09:00:29Z` | The address is still in its 24-hour wait |
| `400` | `nothing to withdraw` · `the wallet has 12.5 USDT_TRC20 to spare` | Nothing spendable, or more than `spendable` |
| `400` | `withdrawing costs 3.71 USDT_TRC20: withdraw more than that` | The fees would take it all |
| `400` | `two-factor code is incorrect or missing` · `turn on two-factor sign-in first: this takes a code from it` | The code, or two-factor sign-in that your [payout settings](#approvals-and-limits) require |
| `403` | `password is incorrect` | |
| `403` | `insufficient permissions` | Not the owner |
| `409` | `payouts are paused on this account: contact support` | payitgo paused your payouts (live mode) |

## A batch's life

```
validating ──▶ quoted ──▶ pending_approval ──▶ awaiting_funds ──▶ processing ──▶ completed
                 │  ▲            │                   │
                 │  └ add, remove, new quote         │
                 ▼               ▼                   ▼
             canceled         rejected            canceled (by you, or by payitgo
                                                  when nothing can still be paid)
```

| `status` | What it means | What can happen next |
|---|---|---|
| `validating` | Its rows are being checked and priced. A batch of more than 100 payments is checked in the background, within a minute or two | `quoted` by itself; canceled |
| `quoted` | Checked and priced: `totals`, `total_usd`, each payment's fees. Its prices hold until `quote_expires_at`, an hour | Add or remove payments, get a new quote, **submit**, cancel |
| `pending_approval` | Submitted; waiting for `approvals_needed` approvals | Approved (it moves on), rejected, canceled |
| `awaiting_funds` | Approved; waiting for the wallet. `waiting` says why | Funded (`processing`) by itself; canceled |
| `processing` | Funded: its money is set aside in the wallet, and its fees and payments are being sent | `completed`; canceling stops the payments not sent yet |
| `completed` | Funded, and every payment is sent, failed, canceled or expired | Final |
| `canceled` | Canceled before it was funded: nothing was sent. By you (`canceled_by` a member or a key), or by payitgo (`canceled_by: "system"`): a send of payout requests that could not be submitted, or a batch none of whose payments could still be paid when it was funded | Final |
| `rejected` | An approver refused it (`rejection.reason`) | Final |

Only a `validating` or `quoted` batch takes new payments. Submitting a batch
cancels the payments left out (`invalid`): each becomes `canceled` with
`"cancel_reason": "invalid"` (its `errors` say why), so a submitted batch
never holds a payment that cannot be paid. Until it is funded, a payment can
still be canceled: the batch is totalled again (approvals already given
stand), and canceled when nothing is left. To change a submitted batch
otherwise, cancel it and make another.

When a batch is rejected or canceled, each of its payments not sent is
canceled too, and each raises `payout_item.canceled` (`cancel_reason`
`rejected`, with the approver's reason as `cancel_note`, or
`batch_canceled`): your system hears about every payment, not only the
batch.

### Why a batch waits

While `awaiting_funds`, `waiting.reason` says what it waits for, and
`waiting.note` says it for people:

| `reason` | Means | What to do |
|---|---|---|
| `funds` | The wallet holds less than the batch needs of at least one coin. `waiting.shortfall` lists what is missing: the coin, its network, the amount and the address to send it to | Send that amount to that address. The batch starts by itself, usually within a minute of the money arriving on chain |
| `daily_limit` | Funding it now would pass your daily limit (live only) | Nothing: it is funded from 00:00 UTC. Or raise the limit (which takes 24 hours) |
| `paused` | payitgo paused payouts on your account | Contact support |

A batch whose prices expired (`quote_expires_at`) by the time it is funded
is priced again first, at today's network fees, so the wallet is asked for
what its payments cost now: the shortfall of a batch that waits a long time
can move a little.
A payment that can no longer be paid then (under the minimum at today's
prices, an address added to the sanctions list, your limit per payment
lowered) is canceled (`"cancel_reason": "invalid"`, `canceled_by:
"system"`, its `errors` saying why), its money free in the wallet; the
others go on. A batch left with nothing to pay is canceled by payitgo, and
your team is told.

### The payments

Each row is a payment with its own `status`:

| `status` | What it means |
|---|---|
| `validating` | Being checked |
| `ready` | Checked and priced: it goes with the batch |
| `invalid` | Left out: `errors` say why. It is never sent and costs nothing. Canceled (`cancel_reason: invalid`) when its batch is submitted; a payout request stays `invalid` in your list until you remove it |
| `cooling` | Funded; a [new recipient](#new-recipients) waits until `cooling_until` |
| `queued` | Funded; waiting for its transfer |
| `sending` | Its transfer is on its way |
| `sent` | On chain: `tx_id` (and `explorer_url`, live) |
| `failed` | Given up (`fail_reason`); its money stays in your wallet |
| `canceled` | Canceled before it was sent: `cancel_reason` says why, `canceled_by` who |
| `claimable` | A [claim link](#claim-links), funded: waiting for its recipient to claim it |
| `expired` | A claim link never claimed: its money is free in the wallet again |

A payment whose transfer fails on chain three times fails, and so does one
the token refuses outright (a recipient its issuer blocked); `fail_reason`
says which, and the rest of the batch goes on.

## Checking and pricing

Every row is checked when it is added, and again whenever the batch is
quoted:

- **The coin:** one of the [payout coins](#networks-and-coins). `USDT` or
  `USDC` alone is refused with a hint to add the network.
- **The amount:** over 0, with no more decimals than the coin takes (6 for
  TRX, XRP, and USDT or USDC on Tron and Ethereum; 8 for the others), worth
  at least $1, and within your [limit per payment](#approvals-and-limits).
- **The address:** valid for the coin's network down to its checksum, not
  the zero address, not your own payout wallet. An address of another
  network says which (`this address is for Tron, not BNB Smart Chain`).
- **The chain:** in live mode, each recipient's address is read on chain: a
  Tron wallet that never held USDT, or a Tron address not activated yet,
  costs more to pay, and its payment is priced so; ETH or BNB sent to a
  contract may not be handled, so that row is refused; an XRP address that was
  never funded, needs a tag or accepts only authorised senders is checked
  for it. A Bitcoin, Litecoin or Dogecoin amount under the coin's smallest
  output is refused.
- **Claim links and conversions:** a [claim link](#claim-links) has no
  address and a valid email (or none); a [converted](#conversion) payment's
  address is checked on the network of the coin it is converted into.
- **The [sanctions list](#sanctions-screening)**, your
  [references](#references-and-retries), and duplicate rows.

Each problem is an **issue**: an error leaves the payment out (`invalid`), a
warning is worth knowing and the payment still goes.

```json
{ "code": "wrong_network", "field": "address", "message": "this address is for Tron, not BNB Smart Chain", "hint": "pay it in a coin of that network, or check the address" }
```

| Field | Type | Description |
|---|---|---|
| `code` | string | For your code: one of the codes below |
| `field` | string, optional | The field at fault: `asset`, `amount`, `address`, `tag`, `reference`, `email` or `claim_assets` |
| `message` | string | For people: what is wrong |
| `hint` | string, optional | For people: what to do |

| `code` | Kind | Meaning |
|---|---|---|
| `unsupported_asset` | error | No coin, an unknown coin, `USDT` or `USDC` without its network, a coin payouts do not pay in, or a claim link offering such a coin (or more than 10) |
| `invalid_amount` | error | The amount is 0, negative or not a number |
| `too_precise` | error | More decimals than the coin takes |
| `invalid_address` | error | Not an address of the coin's network (the message says what is wrong), the zero address, a destination tag on a coin that takes none, an XRP account that needs a tag given none or accepts only authorised senders, or an address on a claim link. As a warning: an XRP account that asks not to be sent XRP |
| `wrong_network` | error | An address of another network |
| `own_wallet` | error | Your own payout wallet |
| `duplicate_reference` | error | Another payment already has this `reference`: in this batch, or in any earlier one of the same mode |
| `sanctioned` | error | The address is on the US Treasury's sanctions list (OFAC SDN): it cannot be paid |
| `contract_recipient` | error | ETH or BNB to a contract address, which may not handle a plain transfer (live only) |
| `below_minimum` | error | Worth less than the smallest payment, $1; under the coin's smallest output (BTC, LTC, DOGE); under the reserve a never-funded XRP address needs; or under what a conversion takes at least |
| `invalid_email` | error | A claim link's email is not an email address |
| `conversion_unavailable` | error | The payment cannot be converted into its coin (now): pay it in the wallet's coin |
| `over_limit` | error | Over your limit per payment |
| `fee_exceeds_amount` | error | The recipient pays the fees, and they would leave less than $1 |
| `duplicate_payment` | warning | Another row pays the same amount of the same coin to the same address |
| `new_holder` | warning | The first USDT sent to a Tron wallet costs more in network fees: included in its fee (live only) |
| `activation` | warning | A TRX payment to a Tron address not activated yet: the transfer activates it, which costs more; included in its fee (live only) |
| `check_failed` | warning | The address could not be read on chain just now: priced as the costlier case |
| `new_recipient` | warning | You never paid this address: the payment waits after the batch is funded ([New recipients](#new-recipients)) |

Codes may be added: treat an unknown one by its kind (in `errors` or in
`warnings`) and show its `message`.

## Fees

Each payment pays:

| Fee | How much |
|---|---|
| **Platform fee** | 0.5% of the payment's amount, never less than $0.50. A rate agreed with your account replaces it |
| **Network fee** | Sending it on its network, priced from the network's conditions now, at its lowest rate: its own transfer, and its share of what the batch's payments in that coin share (the transfer taking their fees to payitgo, and the network fee top-ups of each transfer of up to 50 payments) |

Both are in the payment's coin, rounded up to the coin's decimals: the
platform fee at the coin's dollar price when the batch was priced. A
payment's `fee` is everything it is charged, as one sum: the two together,
and for a converted payment what converting costs too
([Conversion](#conversion)). On Bitcoin, Litecoin, Dogecoin
and the XRP Ledger the network fee stays in your wallet for the transaction
to spend: only the platform fee is sent to payitgo. A
[claim link](#claim-links) is priced at its costliest address, and its fee
taken when claimed.

On Tron, a USDT transfer's network fee is mostly its energy, priced at the
network's lowest rate. Should that rate be out of reach for a moment, the
transfer waits and tries again a few minutes later rather than paying more,
so the network fee you were quoted is the one you pay.

**Who pays the fees** is the batch's `fee_paid_by`:

| `fee_paid_by` | The recipient receives (`send_amount`) | The wallet gives (`debit`) |
|---|---|---|
| `merchant` (the default) | The whole `amount` | `amount` + `fee` |
| `recipient` | `amount` − `fee` (at least $1 must be left) | `amount` |

A batch that does not say takes the default of your
[payout settings](#approvals-and-limits).

**A worked example.** A payment of 120 USDT. Its platform fee is 0.60 USDT:
0.5% of $120 is $0.60, over the $0.50 minimum (a payment of $60 would pay
the minimum, $0.50). Its network fee depends on the network: about 1.60 USDT
on Tron and about 0.012 USDT on BNB Smart Chain, at today's prices.

| 120 USDT | On Tron (`USDT_TRC20`) | On BNB Smart Chain (`USDT_BEP20`) |
|---|---|---|
| Platform fee | 0.60 | 0.60 |
| Network fee | about 1.60 | about 0.012 |
| `fee` | about 2.20 | about 0.612 |
| You pay the fees: the recipient receives / the wallet gives | 120 / about 122.20 | 120 / about 120.612 |
| The recipient pays them: the recipient receives / the wallet gives | about 117.80 / 120 | about 119.388 / 120 |

The batch shows the totals per coin (`totals`: amounts, fees, what the
wallet gives and its dollar value) and `total_usd`, what the whole batch
takes from the wallet. `saved_usd` adds up, over a coin's payments, what
sending each with its batch saves in network fees against a transfer of its
own.

**The fees are sent first.** When a batch is funded, its fees in each coin
are sent from the wallet to payitgo in one transfer, then its payments. A
claim link's fee goes in a transfer of its own when it is claimed.

**A payment never sent gets its fee back.** A payment canceled, or failed,
before its batch's fees were sent is simply left out of that fee transfer.
One canceled or failed after earns a **fee credit** (`fee_credit`): its
platform fee, and its network fee too unless a transfer for it was already
broadcast. Credits are taken off your next batch's fees in the same coin.
You only ever pay for what was sent.

Withdrawing from the wallet pays the network fee of two transfers
([Withdrawals](#withdrawals)), nothing else.

## Claim links

Pay someone whose wallet address you don't know: a payment with
`"claim": true` (or with `claim_assets`) becomes a **claim link**. With an
email, payitgo emails the recipient the link; they open it, choose the coin
and enter the address of their wallet, confirm with a code sent to that
email, and the payment goes there. Without an email, share the link
yourself (the payment's `claim.url`, the hosted claim page) and give the
recipient its **PIN** (`claim.pin`, 6 digits) another way: a call, a text
message, in person. The recipient enters the PIN to claim, so a link that
is lost or forwarded pays no one on its own.

A claim link is only ever made when you ask for it: a payment with an email
but no address and no `claim` is refused for its missing address, never
turned into a link (a system that lost an address would otherwise pay
whoever holds the email). A claim link given an address too is refused
(`invalid_address`, "a claim link has no address: its recipient chooses
one").

```bash
curl -s https://api.payitgo.co/v1/payout-batches \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"reference": "prizes-2026-10", "items": [{"reference": "prize-ana", "asset": "USDT_TRC20", "amount": "250", "name": "Ana Silva", "email": "ana@example.com", "claim": true, "claim_assets": ["USDT_BEP20", "USDC_BEP20"], "note": "Cup final, 1st place"}]}'
```

- **Coins:** the payment's `asset`, and the other coins listed in
  `claim_assets` (up to 10 payout coins), paid at the same dollar value.
  Another coin is offered only while your wallet covers it when the link is
  opened.
- **Pricing:** a claim link is priced at the costliest address it could be
  claimed to (a Tron wallet that never held USDT, sent on its own), and that
  worst-case `debit` is set aside in your wallet while it waits. When it is
  claimed, it is priced again for the coin and the address chosen, and its
  fee is taken then, in that coin: usually less than set aside.
- **Statuses:** once its batch is funded, the payment is `claimable` until
  claimed (then `queued`, `sending`, `sent` like any payment) or until its
  link ends: `expired`. The batch stays `processing` until every claim link
  is claimed or expired; its `counts` have `claimable` and `expired`.
- **Expiry:** a link works for 14 days after its batch is funded
  (`claim.expires_at`). An expired payment's money is free in your wallet
  again, no fee is taken, its reference is given back, and your team gets a
  "Claim link expired" notification and a `payout_item.claim_expired`
  webhook.
- **Emails** (live mode only): the link when the batch is funded, a reminder
  72 hours before it ends, and the code when the recipient asks for it. A
  claim link is never converted, and its recipient never waits as a
  [new recipient](#new-recipients): the code proves who they are.
- **The code:** 6 digits, valid 10 minutes, 5 tries; at most 10 codes per
  link, one a minute.
- **The PIN** (a link without an email): 6 digits, made with the link, never
  emailed and never shown on the claim page. After 5 wrong PINs the link is
  **locked** (`claim.pin_locked`): nobody can claim it, and your team gets a
  "Claim link locked" notification. Make a **new PIN** from the dashboard
  (`POST /merchant/payout-items/{id}/new-claim-pin`): the old one stops
  working and the link is unlocked. Do that too when a PIN reached the wrong
  person.

The payment carries `claim`:

| Field | Type | Description |
|---|---|---|
| `email` | string, optional | Where the link and the codes are emailed |
| `assets` | array of strings | The other coins it can be received in |
| `url` | string, optional | The link (the hosted claim page). Share it when there is no email. Shown only to those who may make payouts (an API key with payouts on, an owner or finance member) and in webhooks. Absent in a quote, and in the dashboard's live updates (read the payment) |
| `pin` | string, optional | A link without an email: the PIN its recipient enters to claim it. Give it apart from the link. Shown where `url` is |
| `pin_locked` | boolean, optional | `true` once 5 wrong PINs were entered: make a new PIN to unlock it |
| `expires_at` | string, optional | When the link ends, once funded |
| `claimed_at` | string, optional | When it was claimed |
| `funded_asset`, `funded_amount` | string, decimal string, optional | What it was funded with, when its recipient chose another coin: the payment's `asset` and `amount` are then the coin and amount chosen |
| `emailed` | integer | Emails sent so far (the link, the reminder, resends) |
| `extended` | integer | How many times the link was extended |

From the dashboard, the team can **email the link again**
(`POST /merchant/payout-items/{id}/resend-claim`, live only, at most 5
emails in all, an hour apart), **extend it**
(`POST /merchant/payout-items/{id}/extend-claim {"days": 7}`, 1 to 30 days,
at most 3 times), give a link without an email a **new PIN**
(`POST /merchant/payout-items/{id}/new-claim-pin`), or **cancel it** like any payment
([Mass payouts in the dashboard](/docs/account#mass-payouts-in-the-dashboard)).
When it is claimed, `payout_item.claimed` is sent with the payment as it
now is (coin, address and fee chosen).

In test mode nothing is emailed: open the link's `url` yourself; the
emailed code is always `000000`, and a link without an email takes its
`pin`, as in live mode.

## Conversion

Pay recipients in coins your wallet doesn't hold: with conversion on, every
payment in another coin than your **convert-from coin** is paid from that
coin, converted on its way. Keep one coin in the wallet (USDT on Tron, by
default), and pay in BTC, ETH, USDC on Ethereum or any payout coin.

Turn it on in your payout settings (`"convert": "convert"`, and
`convert_from`, a payout coin, `USDT_TRC20` by default), or for one batch or
quote (`convert` and `convert_from` in its body). Payments in the
convert-from coin itself are paid directly; [claim links](#claim-links) are
never converted. The [payout requests](#payout-requests) list follows your
settings.

A converted payment's own fields are in the wallet's coin: `asset` is the
convert-from coin, and `amount`, `fee` (what converting costs included) and
`debit` are in it. The
recipient's side is in `convert`, and `address` is the recipient's address on
`convert.to`'s network:

| Field | Type | Description |
|---|---|---|
| `convert.to` | string | The coin the recipient receives (what the row asked for) |
| `convert.ticker`, `convert.network` | string | That coin for people |
| `convert.to_amount` | decimal string | What you asked to pay, in that coin |
| `convert.estimate` | decimal string | What the recipient should receive, at today's rate |
| `convert.amount_out` | decimal string, optional | What they received, once paid |

**How it is priced.** The payment sends the amount's worth, plus what
converting costs and a 0.5% buffer for the rate moving, so the recipient
receives about the amount asked, usually a little more; the rate when it is
sent decides. With the recipient paying the fees, they receive what is
left after all of them. A payment
under the conversion's smallest amount is refused (`below_minimum`); a pair
that cannot be converted gets `conversion_unavailable`. A batch with
converted payments holds its prices 15 minutes instead of an hour.

**How it is sent.** Converted payments go one at a time. Should a conversion
fail and its coins come back, they come back to your payout wallet and the
payment fails, its `fail_reason` saying how much came back.

In test mode conversions are priced at an example cost ($1 plus 1% of the
amount) and paid on paper.

## Schedules

Make the same batch again and again (payroll, a monthly prize, a weekly
commission run) with a **schedule**, set up in the dashboard: its payments,
and when it runs.

| Field | Type | Description |
|---|---|---|
| `name` | string | Your name for it; each run's batch is named "{name} · {date}" |
| `cadence` | string | `weekly`, `biweekly` (every other week, counted from its first run) or `monthly` (the default) |
| `weekday` | integer | Weekly and biweekly: 0 (Sunday) to 6 (Saturday) |
| `day` | integer | Monthly: 1 to 28, or -1 for the month's last day (default 1) |
| `at` | string | The time of day, `HH:MM` (24 hours; default `09:00`) |
| `timezone` | string | An IANA time zone (`Africa/Harare`, `Europe/London`; default `UTC`): the run keeps its wall-clock time across daylight saving changes |
| `fee_paid_by` | string | `merchant` or `recipient` (default: your payout settings') |
| `auto_submit` | boolean | Submit each run's batch (approvals and limits apply as to any batch); off, the batch waits `quoted` for someone to submit it |
| `remind_days` | integer | 0 to 7: that many days before a run your wallet couldn't cover, your team is told what to top up |
| `items` | array | Its payments, up to 1000, as in a [batch](#create-a-batch) (claim links included) |
| `csv` | string | Instead of `items`: a [payments file](#csv-files)'s text |

The payments are checked when the schedule is saved: a wrong row is refused
(`400 row 3: this address is for Tron, not BNB Smart Chain`).

**Each run** makes a batch (`source: "schedule"`, reference
`schedule-{id}-{YYYY-MM-DD}`, each payment's reference with `-{YYYY-MM-DD}`
added, so a run is never made twice), checks and prices it, and submits it
when `auto_submit` is on. Whoever last set the schedule's payments
(`edited_by`) made the batch: they cannot approve it, someone else does. A
run that cannot be made (a row no longer valid, the batch over your limit)
is noted in the schedule's `last_error`, and your team is notified. Each run
sends a `payout_schedule.run` webhook: the schedule without its `items`
(`id`, `livemode`, `name`, `status`, `cadence`, `weekday`, `day`, `at`,
`timezone`, `fee_paid_by`, `auto_submit`, `remind_days`, `totals`,
`next_run_at`, `last_run_at`, `last_batch_id`, `last_error`, `runs`). `remind_days` before a
run the wallet couldn't cover, your team gets a "Top up for scheduled
payouts" notification and a `payout_schedule.funding_needed` webhook,
`{"schedule", "shortfall"}`, saying what to send where.

Pausing a schedule stops its runs; resuming it starts again from the next
of its days (runs missed meanwhile are not made). Deleting it keeps the
batches it made.

The schedule object: `id`, `livemode`, `name`, `status` (`active` or
`paused`), `cadence`, `weekday`, `day`, `at`, `timezone`, `fee_paid_by`,
`auto_submit`, `remind_days`, `items`, `totals` (per coin: `asset`,
`ticker`, `items`, `amount`; fees are priced at each run), `next_run_at`,
`last_run_at`, `last_batch_id`, `last_error`, `runs`, `created_by`,
`edited_by`, `members` (their emails, as on [a batch](#the-batch-object)),
`created_at`, `updated_at`. The routes
(`/merchant/payout-schedules`) are in
[Mass payouts in the dashboard](/docs/account#mass-payouts-in-the-dashboard).

## Approvals and limits

Your **payout settings** decide who approves a batch, how much may leave,
and how new recipients and API keys are treated. The owner reads and
changes them in the dashboard, with their password and two-factor code
([Payout settings](/docs/account#payout-settings)).

| Setting | Default | What it does |
|---|---|---|
| `approvals` | `0` | How many team members, other than whoever made or submitted the batch, approve it before it is funded: 0, 1 or 2 |
| `approver_roles` | `["owner"]` | Who may approve (and reject, and release a new recipient's payment): `owner`, `finance`, or both |
| `two_factor` | `false` | Approving, withdrawing, releasing and changing the payout settings need the person's two-factor code: someone without two-factor sign-in cannot. Turning it on needs the owner's code |
| `per_payout_max_usd` | `"0"` (none) | The most one payment may be worth: a row over it is `over_limit` |
| `batch_max_usd` | `"0"` (none) | The most a batch may take from the wallet (`total_usd`): submitting a larger one is refused |
| `daily_max_usd` | `"0"` (none) | The most funded in a UTC day, live only: a batch that would pass it waits until 00:00 UTC (`daily_limit`) |
| `new_recipient_hours` | `0` (none) | How long a payment to an address you never paid waits after its batch is funded, up to 168 ([New recipients](#new-recipients)) |
| `api_auto_submit` | `false` | A batch made with an API key skips approval when it is submitted, unless it pays a new recipient (the limits still apply) |
| `api_require_allowlist` | `false` | Only API keys limited to IP addresses may make payouts ([The API](#the-api)) |
| `auto_send_minutes` | `0` (send by hand) | Your [payout requests](#payout-requests) send themselves once the oldest valid one has waited this long, 1 to 1440 minutes ([auto-send](#auto-send)) |
| `fee_paid_by` | `"merchant"` | Who pays the fees of a batch that does not say: `merchant` or `recipient` |
| `convert` | `"same_network"` | `same_network`: each payment is paid in its own coin, from the wallet's balance of that coin. `convert`: every payment in another coin than `convert_from` is paid from `convert_from`, converted on its way ([Conversion](#conversion)) |
| `convert_from` | `"USDT_TRC20"` | The coin conversions are paid from |

**Stricter now, looser in 24 hours.** A change that tightens a rule (more
approvals, a lower limit, a longer wait, two-factor on, an approver role
removed, auto-submit off, the IP rule on, auto-send off or longer) is in
force at once. One that
loosens a rule waits **24 hours** (`pending_controls`, in force from its
`effective_at`), so whoever might take over the account cannot loosen the
rules and pay out at once. Meanwhile the stricter rule stays in force. A
change made while one waits replaces it, and its 24 hours start again.
`fee_paid_by`, `convert` and `convert_from` are no safeguards: they change at once.

The approvals are made in the dashboard, by a person: **Approve** asks for
their password, and their two-factor code when they have two-factor sign-in.
An API key never approves. With 2 approvals, make sure two people other than
the batch's maker can approve.

## New recipients

With `new_recipient_hours` set, a payment to an address you never paid
before (and that is not one of your own payout addresses) waits that long
after its batch is funded: it is `cooling`, until `cooling_until`. It warns
of it while the batch is quoted (`new_recipient`). A batch made with an API
key and paying a new recipient always waits for its approvals, even with
`api_auto_submit` on.

An approver can let one go sooner from the dashboard (**Release**, with
their password and code). Canceling it keeps its money in the wallet.

## Sanctions screening

Every recipient's address is checked against the US Treasury's sanctions
list (OFAC's SDN list, the digital currency addresses in it), which payitgo
downloads again every day. A listed address is refused (`sanctioned`), its
payment is left out, and the refusal is recorded in your audit log. The
check runs whenever a batch is quoted and again when a batch waiting for
funds is priced again.

## References and retries

- **A batch's `reference`** is unique in its mode: making a batch with a
  reference you used already returns that batch, `200 OK` instead of
  `201 Created`, with nothing added. Send one with every batch your system
  makes, and retry the same request safely after a timeout.
- **A payment's `reference`** (an employee's id, a prize's, a withdrawal
  request's) is unique in its mode across all your batches. A row whose
  reference another payment carries is kept, but `invalid`
  (`duplicate_reference`): its `reference` stays empty and the one it asked
  for is in `refused_reference`. Your system never pays the same withdrawal
  twice.
- **A payment never sent gives its reference back.** When a payment is
  canceled or fails (a batch canceled or rejected before funding included),
  its reference moves to `released_reference` and its `reference` is
  emptied, so a new payment can carry it: pay the same withdrawal request
  again in a new batch.
- **A payout request's `reference`** works the same way: sending
  [`POST /v1/payouts`](#take-a-payout-request) again with it answers `200 OK`
  with the payment that has it, in your list or sent.
- Find a payment by your reference with
  [`GET /v1/payouts?reference=`](#get-a-payment).

## CSV files

In the dashboard, a batch can be made from a CSV file
(`POST /merchant/payout-batches/csv`, the file as the body, up to 16 MiB and
10,000 rows). Start from the template, `GET /merchant/payout-batches/template.csv`:

```
reference,asset,address,amount,name,email,note
payroll-2026-10-ana,USDT_TRC20,TR7mNkucnhLZoJQLLJ6DR88jpLQufWxUJi,1500,Ana Silva,ana@example.com,October salary
prize-cup-final-1,USDT_BEP20,0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed,250,Player One,,Cup final 1st place
```

The first row names the columns, in any order and any case, with spaces or
dashes for underscores. `asset`, `address` and `amount` are required
(`address` may be left out when the file has a `claim` column; its rows
with `claim` set to yes are [claim links](#claim-links)):

| Column | Also read from | Value |
|---|---|---|
| `reference` | `ref`, `id`, `external_id`, `employee_id`, `player_id` | Your id for the payment |
| `asset` | `coin`, `token`, `currency` | A [payout coin](#networks-and-coins); or `USDT`, `USDC` or `DAI` with a `network` column |
| `network` | `chain` | Completes an asset given without its network: `TRC20`, `TRC-20` or `Tron`; `BEP20`, `BEP-20`, `BSC` or `BNB Smart Chain`; `ERC20`, `ERC-20` or `Ethereum` |
| `address` | `wallet`, `wallet_address`, `recipient_address`, `destination` | The recipient's address |
| `tag` | `destination_tag`, `memo_tag` | XRP only: a whole number |
| `amount` | `value`, `sum` | The amount in the coin (below for the decimal mark) |
| `name` | `recipient`, `recipient_name`, `full_name` | The recipient's name |
| `email` | `recipient_email` | Their email |
| `note` | `description`, `memo`, `comment` | Your note |
| `claim` | `claim_link` | `yes`, `y`, `true`, `1` or `x`: a [claim link](#claim-links) without an address |
| `claim_assets` | `claim_coins` | A claim link's other coins, separated by spaces, `\|` or `/` (`USDT_BEP20 USDC_BEP20`) |

- Commas, semicolons or tabs separate the columns: whichever the header row
  uses most. Spreadsheet exports with a byte-order mark are read as well.
- Empty lines are skipped. An amount that is not a number becomes 0, which
  the check refuses (`invalid_amount`), so the row shows up rather than
  vanishing.
- **The decimal mark** of an amount is the last of `.` and `,` in it:
  `1,500.50` and `1.500,50` are both 1500.50. A lone `,` is a decimal
  comma in a file separated by semicolons or tabs (`1500,50` is 1500.50),
  and a thousands separator in a comma-separated file (`"1,500"` is 1500).
  Spaces, non-breaking spaces and `'` are ignored (`1 500`, `1'500`).
- A payment's `row` is its place in the batch, from 1, in the file's
  order: not the file's line number (the header and empty lines are not
  counted).

| Status | Message | Cause |
|---|---|---|
| `400` | `the file is empty or not CSV` | |
| `400` | `the header row has no amount column (it needs asset, address and amount; reference, name, email, note, claim and claim_assets are optional; address may be left out when there is a claim column)` | A required column is missing |
| `400` | `line 7: parse error on line 7, column 12: bare " in non-quoted-field` | A line the CSV reader cannot read (a stray quote, say) |
| `400` | `the file has no payment` · `a batch holds at most 10000 payments` | |

`GET /merchant/payout-batches/{id}/items.csv` downloads a batch's results:
`row, reference, name, email, asset, address, tag, amount, fee,
send_amount, debit, status, tx_id, explorer_url, fail_reason, errors,
warnings, claim_url, claim_pin, claim_expires_at, convert_to,
convert_amount, convert_estimate, convert_amount_out` (each issue as `code:
message`, separated by `; `; `fee` is the payment's whole fee). A cell that
a spreadsheet would read as a formula starts with `'`.

## The API

Your server makes batches with an API key, like payments. The key decides
the mode: a test key makes test batches, paid from the test wallet on paper.

**Payouts are off for every key until the owner turns them on.** Reading
(batches, payments, the wallet) works with any key, read-only ones
included; making, adding to, quoting again, submitting and canceling
batches, quotes and test top-ups need a key with `payouts` on. The owner turns it on in the
dashboard ([Update a key](/docs/authentication#update-a-key)), and can limit
the key to your servers' IP addresses (`allowed_ips`): a key limited so is
refused from any other address, on every route. With
`api_require_allowlist` in your payout settings, only keys limited to IP
addresses may make payouts.

Every route that takes a batch id answers `404 not found` for a batch of
the other mode than the key's: a test key never reads or changes a live
batch, and the other way round.

| Method | Path | Key | Body | Response |
|---|---|---|---|---|
| `POST` | [`/v1/payouts`](#take-a-payout-request) | payouts | a payment: `reference`, `asset`, `address`, `tag`, `amount`, `name`, `email`, `note`, `claim`, `claim_assets`, `webhook_url` | `201 {"item": payment}`; `200` for a reference used already |
| `GET` | [`/v1/payouts/inbox?status=&limit=&offset=`](#read-the-list) | any | | `200 {"inbox": batch, "coverage", "items", "total", "limit", "offset"}` |
| `POST` | [`/v1/payouts/send`](#send-the-list) | payouts | `ids` (optional) | `200 {"batch": batch}` |
| `DELETE` | [`/v1/payouts/{id}`](#remove-a-request) | payouts | | `200 {"item": payment}` |
| `POST` | [`/v1/payouts/remove`](#remove-several-requests) | payouts | `ids` (1 to 1000) | `200 {"results": [{"id", "item"} or {"id", "error"}, …]}` |
| `GET` | [`/v1/payouts?status=&updated_since=&cursor=&limit=`](#list-payments) | any | | `200 {"items": [payment, …], "has_more", "next_cursor"}` |
| `GET` | [`/v1/events?type=&after=&created_after=&limit=`](#replay-events) | any account key | | `200 {"events": [event, …], "has_more"}` |
| `POST` | [`/v1/payout-quotes`](#quote-payments) | payouts | `fee_paid_by`, `convert`, `convert_from`, `items` (1 to 100) | `200 {"quote": {…}, "items": [payment, …]}`, nothing kept |
| `POST` | [`/v1/payout-batches`](#create-a-batch) | payouts | `reference`, `name`, `fee_paid_by`, `webhook_url`, `convert`, `convert_from`, `items` (1 to 1000) | `201 {"batch": batch}`; `200` for a reference used already |
| `GET` | [`/v1/payout-batches?status=&limit=&offset=`](#list-batches) | any | | `200 {"batches": [batch, …], "total", "limit", "offset"}` |
| `GET` | [`/v1/payout-batches/{id}`](#get-a-batch) | any | | `200 {"batch": batch}` |
| `GET` | [`/v1/payout-batches/{id}/items?status=&limit=&offset=`](#list-a-batchs-payments) | any | | `200 {"items": [payment, …], "total", "limit", "offset"}` |
| `POST` | [`/v1/payout-batches/{id}/items`](#add-payments) | payouts | `items` (1 to 1000) | `200 {"batch": batch}` |
| `POST` | [`/v1/payout-batches/{id}/quote`](#get-a-new-quote) | payouts | | `200 {"batch": batch}` |
| `POST` | [`/v1/payout-batches/{id}/submit`](#submit-a-batch) | payouts | | `200 {"batch": batch}` |
| `POST` | [`/v1/payout-batches/{id}/cancel`](#cancel-a-batch) | payouts | | `200 {"batch": batch}` |
| `GET` | [`/v1/payouts?reference=`](#get-a-payment) | any | | `200 {"item": payment}` |
| `GET` | [`/v1/payouts/{id}`](#get-a-payment) | any | | `200 {"item": payment}` |
| `GET` | [`/v1/payout-wallets`](#balances) | any | | `200 {"wallets": [wallet, …]}` |
| `POST` | [`/v1/payout-wallets/{asset}/simulate-top-up`](#in-test-mode) | payouts, test key | `amount` | `200 {"wallets": [wallet, …]}` |

The dashboard has the same routes under `/merchant` (with `?mode=test`;
payout requests are under `/merchant/payout-requests`, payments under
`/merchant/payout-items`),
plus approving, rejecting, removing a payment, CSV files, canceling or
releasing one payment, withdrawals and the payout settings
([Mass payouts in the dashboard](/docs/account#mass-payouts-in-the-dashboard)).

### Quote payments

`POST /v1/payout-quotes` · a key with payouts on

What up to 100 payments would cost, checked and priced exactly as in a
batch, without keeping anything: use it to show a recipient their fee
before they ask for a withdrawal. The body takes `fee_paid_by`, `convert`,
`convert_from` and `items`, as a batch does.

```bash
curl -s https://api.payitgo.co/v1/payout-quotes \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"fee_paid_by": "recipient", "items": [{"asset": "USDT_BEP20", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "amount": "120"}]}'
```

`200 OK`: `quote` holds the totals a batch of them would have (`livemode`,
`fee_paid_by`, `convert`, `convert_from` (only when converting),
[`totals`](#the-batch-object), `total_usd`, `counts`), and
`items` the [payments](#the-payment-object) as they would be, each with its
`fee` and issues. Nothing is kept: the payments have no `id`, `merchant_id`,
`batch_id` or times.

```json
{
  "quote": {
    "livemode": true,
    "fee_paid_by": "recipient",
    "convert": "same_network",
    "totals": [
      { "asset": "USDT_BEP20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg", "chain": "bsc", "network": "BNB Smart Chain (BEP-20)", "items": 1, "amount": "120", "send_amount": "119.38816579", "fee": "0.61183421", "debit": "120", "debit_usd": "120", "saved_usd": "0" }
    ],
    "total_usd": "120",
    "counts": { "total": 1, "invalid": 0, "ready": 1, "cooling": 0, "queued": 0, "sending": 0, "sent": 0, "failed": 0, "canceled": 0, "claimable": 0, "expired": 0 }
  },
  "items": [
    {
      "livemode": true,
      "row": 1,
      "recipient": {},
      "asset": "USDT_BEP20",
      "ticker": "USDT",
      "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg",
      "chain": "bsc",
      "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
      "amount": "120",
      "send_amount": "119.38816579",
      "fee": "0.61183421",
      "debit": "120",
      "usd_rate": "1",
      "amount_usd": "120",
      "status": "ready",
      "errors": [],
      "warnings": []
    }
  ]
}
```

| Status | Message | Cause |
|---|---|---|
| `400` | `quote between 1 and 100 payments` | |
| `400` | `fee_paid_by must be merchant or recipient` | |

### Create a batch

`POST /v1/payout-batches` · a key with payouts on

| Field | Type | Required | Description |
|---|---|---|---|
| `reference` | string | no, but send one | Your id for the batch, unique in its mode. A reference used already returns that batch ([retries](#references-and-retries)) |
| `name` | string | no | Your name for it, for people ("October payroll") |
| `fee_paid_by` | string | no | `merchant` (fees on top) or `recipient` (fees out of each amount). Default: your [payout settings](#approvals-and-limits) |
| `webhook_url` | string | no | An `https://` URL on a public host: this batch's events go there instead of your account's webhook URL ([Webhooks](#webhooks)) |
| `convert`, `convert_from` | string | no | `same_network` or `convert`, and the coin conversions are paid from ([Conversion](#conversion)). Default: your payout settings |
| `items` | array | yes | 1 to 1000 payments (below). Add more with [`/items`](#add-payments), up to 10,000 |

Each payment:

| Field | Type | Required | Description |
|---|---|---|---|
| `asset` | string | yes | A [payout coin](#networks-and-coins) (any case). With conversion on, any payout coin: it is the coin the recipient receives |
| `address` | string | yes, unless a claim link | The recipient's address on the coin's network |
| `amount` | decimal string | yes | How much of the coin. With `fee_paid_by: "recipient"` the fees come out of it |
| `reference` | string | no | Your id for this payment, unique in its mode ([retries](#references-and-retries)) |
| `name`, `email`, `note` | string | no | Who it is for, and why: kept with it, shown in the dashboard and the results file. A [claim link](#claim-links)'s link is emailed there; otherwise nothing is sent to the email |
| `tag` | integer | no | XRP only: the destination tag the recipient's address needs |
| `claim` | boolean | no | `true` makes it a [claim link](#claim-links), without an address (with no email: you share its link) |
| `claim_assets` | array of strings | no | The other coins its recipient may choose (up to 10). Giving them makes the payment a claim link |
| `webhook_url` | string | no | An `https://` URL on a public host: this payment's events go there instead of the batch's `webhook_url` or your account's URL. At most 10 different URLs in one call |

```bash
curl -s https://api.payitgo.co/v1/payout-batches \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "reference": "payroll-2026-10",
    "name": "October payroll",
    "items": [
      {"reference": "payroll-2026-10-ana", "asset": "USDT_TRC20", "address": "TR7mNkucnhLZoJQLLJ6DR88jpLQufWxUJi", "amount": "120", "name": "Ana Silva", "email": "ana@example.com", "note": "October salary"},
      {"reference": "payroll-2026-10-ben", "asset": "USDT_BEP20", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "amount": "250", "name": "Ben Okafor"},
      {"reference": "payroll-2026-10-chen", "asset": "USDT_BEP20", "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", "amount": "75", "name": "Chen Wei"}
    ]
  }'
```

`201 Created`. Up to 100 payments are checked while the call waits, and the
batch comes back `quoted`:

```json
{
  "batch": {
    "id": "6a1f3c8e2d9b4a7051c3e8f2",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": true,
    "reference": "payroll-2026-10",
    "name": "October payroll",
    "source": "api",
    "inbox": false,
    "open": false,
    "created_by": "5e1a9c3d7b2f40e8a6c4d2b1",
    "by_api_key": true,
    "fee_paid_by": "merchant",
    "convert": "same_network",
    "status": "quoted",
    "totals": [
      { "asset": "USDT_TRC20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_TRC20.svg", "chain": "tron", "network": "Tron (TRC-20)", "items": 1, "amount": "120", "send_amount": "120", "fee": "2.202417", "debit": "122.202417", "debit_usd": "122.2", "saved_usd": "0" },
      { "asset": "USDT_BEP20", "ticker": "USDT", "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg", "chain": "bsc", "network": "BNB Smart Chain (BEP-20)", "items": 1, "amount": "250", "send_amount": "250", "fee": "1.26183421", "debit": "251.26183421", "debit_usd": "251.26", "saved_usd": "0" }
    ],
    "counts": { "total": 3, "invalid": 1, "ready": 2, "cooling": 0, "queued": 0, "sending": 0, "sent": 0, "failed": 0, "canceled": 0, "claimable": 0, "expired": 0 },
    "total_usd": "373.46",
    "quote_expires_at": "2026-10-20T10:15:02.41Z",
    "approvals_needed": 0,
    "approvals": [],
    "members": {},
    "created_at": "2026-10-20T09:15:01.87Z",
    "updated_at": "2026-10-20T09:15:02.41Z"
  }
}
```

The third row pays a Tron address in a BNB Smart Chain coin: it is
`invalid`, left out of `totals` ([list the payments](#list-a-batchs-payments)
to see why). A batch of more than 100 payments comes back `validating`, its
`counts.total` and `counts.ready` both the number of payments until it is
checked; a `payout_batch.quoted` webhook says when it is ready, within a
minute or two.

| Status | Message | Cause |
|---|---|---|
| `400` | `a batch needs at least one payment` | No `items` |
| `400` | `send at most 1000 payments in one call: add the rest with /items, or upload a CSV file` | Over 1000 items |
| `400` | `fee_paid_by must be merchant or recipient` | |
| `400` | `webhook_url: url must use https` (and the other [URL errors](/docs/payments#errors)) | An unusable `webhook_url`, the batch's or a payment's |
| `400` | `payments may name at most 10 different webhook_urls in one call` | |
| `400` | `convert must be same_network or convert` · `convert_from: payouts are not paid from SHIB_ERC20 (pay from …)` | An unusable `convert` or `convert_from` |
| `400` | `invalid request body` | Not JSON, or a field of the wrong type |
| `403` | `this api key cannot make payouts: turn payouts on for it in the dashboard` | The key's `payouts` is off |
| `403` | `your payout rules require keys limited to IP addresses: set this key's allowed IPs` | `api_require_allowlist` is on and the key has no `allowed_ips` |

A row's own problems never fail the request: they are its `errors`.

### Add payments

`POST /v1/payout-batches/{id}/items` · a key with payouts on

`{"items": [payment, …]}`, 1 to 1000 at a time, to a `validating` or
`quoted` batch, up to 10,000 payments in all. They are numbered after the
last row, and the whole batch is checked and priced again (in the
background past 100 payments). `200 OK`: `{"batch": batch}`.

| Status | Message | Cause |
|---|---|---|
| `400` | `add between 1 and 1000 payments at a time` · `a batch holds at most 10000 payments` | |
| `404` | `not found` | No such batch |
| `409` | `a submitted batch cannot change: cancel it and make another` | It was submitted |

### Submit a batch

`POST /v1/payout-batches/{id}/submit` · a key with payouts on

Sends a `quoted` batch on: its `ready` payments go, the `invalid` ones stay
out. With approvals in your payout settings it becomes `pending_approval`
(unless `api_auto_submit` lets it skip them); otherwise `awaiting_funds`,
and it is funded at once if the wallet covers it (`processing`). When the
wallet falls short, the answer already says by how much:

```json
{
  "batch": {
    "id": "6a1f3c8e2d9b4a7051c3e8f2",
    "status": "awaiting_funds",
    "waiting": {
      "reason": "funds",
      "note": "top up the payout wallet: 122.202417 USDT_TRC20 on Tron (TRC-20), 251.26183421 USDT_BEP20 on BNB Smart Chain (BEP-20)",
      "since": "2026-10-20T09:16:40.12Z",
      "shortfall": [
        { "asset": "USDT_TRC20", "network": "Tron (TRC-20)", "amount": "122.202417", "address": "TMkq7dZ3vN8xR2pB6cH9sJ4fL1wY5tGaQe" },
        { "asset": "USDT_BEP20", "network": "BNB Smart Chain (BEP-20)", "amount": "251.26183421", "address": "0x8F3a6B2c9D1e4F7a0B5c8D2e6F9a1B4c7D0e3F6a" }
      ]
    },
    "approvals_needed": 0,
    "submitted_by": "5e1a9c3d7b2f40e8a6c4d2b1",
    "submitted_at": "2026-10-20T09:16:40.09Z",
    "…": "…"
  }
}
```

| Status | Message | Cause |
|---|---|---|
| `400` | `no valid payment to send` | Every payment is `invalid` |
| `400` | `this batch takes $373.46, over your limit of $250.00 per batch` | Over `batch_max_usd` |
| `404` | `not found` | |
| `409` | `the batch is still being checked` | `validating` |
| `409` | `its prices expired: get a new quote first` | Past `quote_expires_at` (an hour after it was quoted; 15 minutes with converted payments): [get a new quote](#get-a-new-quote), then submit |
| `409` | `the batch was submitted already` | |

### Get a new quote

`POST /v1/payout-batches/{id}/quote` · a key with payouts on

Checks and prices a batch not submitted yet (`validating` or `quoted`)
again, at today's prices: its prices then hold for another hour (15
minutes with converted payments). Use it
when submitting answers `409 its prices expired: get a new quote first`.
`200 OK`: `{"batch": batch}`, `quoted` (or `validating` past 100 payments,
`payout_batch.quoted` following). `409 a submitted batch cannot change:
cancel it and make another` once submitted; `404 not found` for another
mode's batch.

### Cancel a batch

`POST /v1/payout-batches/{id}/cancel` · a key with payouts on

Before it is funded (`validating`, `quoted`, `pending_approval`,
`awaiting_funds`), the whole batch is `canceled` and nothing leaves the
wallet. Once it is `processing`, the payments not sent yet are canceled
(one whose transfer is being signed at that moment may still go): their
money stays in the wallet, their fees come back ([Fees](#fees)), and the
batch completes with what was sent. `200 OK`: `{"batch": batch}`.
`409 the batch is completed` (or `canceled`, `rejected`) when there is
nothing left to cancel.

### List batches

`GET /v1/payout-batches?status=&limit=&offset=` · any key

The key's mode's batches, newest first. `status` filters on one status;
`limit` is 1 to 200 (50 by default).

### Get a batch

`GET /v1/payout-batches/{id}` · any key

`200 OK`: `{"batch": batch}`. `404 not found` for another mode's batch.

### List a batch's payments

`GET /v1/payout-batches/{id}/items?status=&limit=&offset=` · any key

In row order. `status` takes one status or several, comma-separated
(`?status=invalid`, `?status=sent,failed`).

```bash
curl -s 'https://api.payitgo.co/v1/payout-batches/6a1f3c8e2d9b4a7051c3e8f2/items?status=invalid' -H "Authorization: Bearer $PAYITGO_KEY"
```

`200 OK`:

```json
{
  "items": [
    {
      "id": "4e6a8c0b2d4f6a1c3e5b7d9a",
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "livemode": true,
      "batch_id": "6a1f3c8e2d9b4a7051c3e8f2",
      "reference": "payroll-2026-10-chen",
      "row": 3,
      "recipient": { "name": "Chen Wei" },
      "asset": "USDT_BEP20",
      "ticker": "USDT",
      "icon_url": "https://api.payitgo.co/coins/USDT_BEP20.svg",
      "chain": "bsc",
      "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "amount": "75",
      "send_amount": "0",
      "fee": "0",
      "debit": "0",
      "usd_rate": "0",
      "amount_usd": "0",
      "status": "invalid",
      "errors": [
        { "code": "wrong_network", "field": "address", "message": "this address is for Tron, not BNB Smart Chain", "hint": "pay it in a coin of that network, or check the address" }
      ],
      "warnings": [],
      "created_at": "2026-10-20T09:15:01.88Z",
      "updated_at": "2026-10-20T09:15:02.4Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
```

### Get a payment

`GET /v1/payouts/{id}` or `GET /v1/payouts?reference=payroll-2026-10-ana` · any key

One payment, by its id or by your reference. By reference, the payment
that carries it now; if none does, the latest that gave it back (canceled,
failed or expired, with it as `released_reference`): what became of a
request sent with it. A sent payment:

```json
{
  "item": {
    "id": "8b2d4f6a1c3e5a7b9d0f2c4e",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": true,
    "batch_id": "6a1f3c8e2d9b4a7051c3e8f2",
    "reference": "payroll-2026-10-ana",
    "row": 1,
    "recipient": { "name": "Ana Silva", "email": "ana@example.com", "note": "October salary" },
    "asset": "USDT_TRC20",
    "ticker": "USDT",
    "icon_url": "https://api.payitgo.co/coins/USDT_TRC20.svg",
    "chain": "tron",
    "address": "TR7mNkucnhLZoJQLLJ6DR88jpLQufWxUJi",
    "amount": "120",
    "send_amount": "120",
    "fee": "2.202417",
    "debit": "122.202417",
    "usd_rate": "1",
    "amount_usd": "120",
    "status": "sent",
    "errors": [],
    "warnings": [],
    "tx_id": "3c9f1a7e5b2d8c4f6a0e9b3d7c1f5a2e8b4d0c6f9a3e7b1d5c8f2a6e0b4d9c3f",
    "explorer_url": "https://tronscan.org/#/transaction/3c9f1a7e5b2d8c4f6a0e9b3d7c1f5a2e8b4d0c6f9a3e7b1d5c8f2a6e0b4d9c3f",
    "sent_at": "2026-10-20T09:31:12.6Z",
    "created_at": "2026-10-20T09:15:01.88Z",
    "updated_at": "2026-10-20T09:31:12.6Z"
  }
}
```

| Status | Message | Cause |
|---|---|---|
| `404` | `not found` | No such payment in the key's mode |

### List payments

`GET /v1/payouts?status=&updated_since=&cursor=&limit=` · any key

Every payment of the key's mode, whatever its batch (requests in your list,
removed ones, every batch's), **oldest change first**: to bring your system
back in sync after it was down, or to check it is.

| Query | Description |
|---|---|
| `status` | Comma-separated statuses (`sent,failed,canceled,expired` for the ended ones); all when left out |
| `updated_since` | RFC 3339 time: payments changed at or after it |
| `cursor` | The `next_cursor` of the page before: the payments changed after the last one it listed |
| `limit` | 1 to 200; 50 by default |

```json
{
  "items": [ { "id": "5c7e9a1b3d5f7a9c1e3b5d7f", "reference": "wd-88231", "status": "sent", "updated_at": "2026-10-20T14:31:12.6Z", "…": "…" } ],
  "has_more": true,
  "next_cursor": "MjAyNi0xMC0yMFQxNDozMToxMi42WnwyYzVlN2E5YjFkM2Y1YTdjOWUxYjNkNWY"
}
```

Read with `updated_since` the first time, then with `cursor` while
`has_more`; keep the last `next_cursor` to start from next time. Changes of
the last few seconds are left for the next read, so a cursor never skips
one.

| Status | Message | Cause |
|---|---|---|
| `400` | `updated_since must be an RFC 3339 time, such as 2026-10-20T09:00:00Z` | |
| `400` | `limit must be 1 to 200` | |
| `400` | `cursor is not one this API gave` | |

### The batch object

| Field | Type | Description |
|---|---|---|
| `id` | string | The batch's id |
| `merchant_id` | string | Your account |
| `livemode` | boolean | `false` for a test batch |
| `reference` | string, optional | Your id for it, unique in its mode |
| `name` | string | Your name for it; `""` when none |
| `source` | string | `api`, `dashboard`, `csv`, `requests` (made of [payout requests](#payout-requests)) or `schedule` (a [schedule](#schedules)'s run) |
| `inbox` | boolean | Made of payout requests: your list of them, or a batch of sent ones |
| `open` | boolean | Your list of payout requests itself (one per mode): never submitted, never listed among batches |
| `created_by` | string | The team member who made it, the API key's id (`by_api_key`), `auto-send` for payout requests that sent themselves, or `requests` for the list |
| `by_api_key` | boolean | Whether an API key made it (`true` for auto-send too) |
| `fee_paid_by` | string | `merchant` or `recipient` |
| `webhook_url` | string, optional | Where its events go instead of your account's URL |
| `convert`, `convert_from` | string | Its [conversion](#conversion) rule (`same_network` or `convert`) and, when converting, the coin converted from |
| `status` | string | [A batch's life](#a-batchs-life) |
| `waiting` | object, optional | While `awaiting_funds` and waiting: `reason` (`funds`, `daily_limit`, `paused`), `note` for people, `since`, and for `funds` the `shortfall`: `[{"asset", "network", "amount", "address"}]`, what to send where |
| `totals` | array | Its payments that go, per coin: `asset`, `ticker` and `icon_url` (what people call the coin, and its icon), `chain`, `network`, `items` (how many), `amount`, `send_amount`, `fee` (their fees, as one sum), `debit` (what the wallet gives), `debit_usd`, and `saved_usd` (what sending them together saves in network fees) |
| `counts` | object | Its payments by status: `total`, `invalid`, `ready`, `cooling`, `queued`, `sending`, `sent`, `failed`, `canceled`, `claimable`, `expired` |
| `total_usd` | decimal string | What it takes from the wallet, in dollars |
| `quote_expires_at` | string, optional | Its prices hold until then |
| `approvals_needed` | integer | Approvals it needs, set when it is submitted |
| `approvals` | array | `[{"user_id", "at"}]`: who approved it, and when |
| `rejection` | object, optional | `{"by", "at", "reason"}` once `rejected` |
| `submitted_by`, `submitted_at` | string, optional | Who submitted it (a member's id or a key's), and when |
| `funded_at`, `completed_at` | string, optional | When it was funded, and completed |
| `canceled_by`, `canceled_at` | string, optional | Who canceled it, and when (also on a `processing` batch whose remaining payments were canceled) |
| `members` | object | The emails of the team members it names (`created_by`, `submitted_by`, `approvals[].user_id`, `rejection.by`, `canceled_by`), by id, as they were when each acted: `{"9b2e4c71d8a05f3e6c1a7b24": "owner@yourdomain.com"}`. A member who has left the team stays named. API keys, `auto-send` and the platform are not in it; `{}` when no member acted |
| `created_at`, `updated_at` | string | |

### The payment object

| Field | Type | Description |
|---|---|---|
| `id` | string | The payment's id |
| `merchant_id` | string | Your account |
| `batch_id` | string, optional | Its batch: your list's id while it is a payout request waiting there. Absent for a request removed from the list |
| `livemode` | boolean | |
| `reference` | string, optional | Your id for it, unique in its mode |
| `refused_reference` | string, optional | A reference it was given that another payment carries: it keeps none, and is `invalid` |
| `released_reference` | string, optional | Once it is canceled or failed (never sent): the reference it had, given back for a new payment to use; `reference` is then empty |
| `row` | integer | Its place in the batch, from 1; payments added later follow the last row |
| `recipient` | object | `name`, `email`, `note`, as you gave them (each left out when empty) |
| `asset`, `chain` | string | The coin, and its network |
| `ticker`, `icon_url` | string | What people call the coin (`"USDT"`) and its icon, an SVG image. Absent for a coin payouts do not know |
| `address` | string | The recipient's address, as it is sent to (an EVM address in its checksummed form) |
| `tag` | integer, optional | A destination tag |
| `amount` | decimal string | What you asked to pay |
| `send_amount` | decimal string | What reaches the recipient |
| `fee` | decimal string | Everything it is charged, in the coin, as one sum: the platform fee and the network fee, and for a converted payment what converting costs |
| `debit` | decimal string | What it takes from the wallet |
| `usd_rate` | decimal string | The coin's dollar price when it was priced |
| `amount_usd` | decimal string | `amount` in dollars, to the cent |
| `status` | string | [The payments](#the-payments) |
| `errors`, `warnings` | array | Its [issues](#checking-and-pricing); `[]` when none |
| `cooling_until` | string, optional | A new recipient's payment waits until then |
| `tx_id` | string, optional | Its transaction, once sent (made up in test mode) |
| `explorer_url` | string, optional | The transaction on the block explorer. Live only |
| `fail_reason` | string, optional | Why it failed |
| `cancel_reason` | string, optional | Why it was canceled: `removed` (taken out of your list of payout requests), `canceled` (that payment alone, by your team or a key), `batch_canceled` (its batch was canceled), `rejected` (its batch was rejected), `invalid` (no longer payable when its batch was submitted or funded: `errors` say why) |
| `cancel_note` | string, optional | With `rejected`: the approver's reason |
| `canceled_by`, `canceled_at` | string, optional | Who canceled it (a team member's id, an API key's id, or `system`: payitgo itself), and when |
| `members` | object, optional | The email of the team member in `canceled_by`, by id, as on [batches](#the-batch-object) |
| `webhook_url` | string, optional | Where its events go instead of its batch's `webhook_url` or your account's URL |
| `claim` | object, optional | A [claim link](#claim-links)'s state: `email`, `assets`, `url`, `pin`, `pin_locked`, `expires_at`, `claimed_at`, `funded_asset`, `funded_amount`, `emailed`, `extended` |
| `convert` | object, optional | A [converted](#conversion) payment: `to`, `ticker`, `network`, `to_amount`, `estimate`, `amount_out` |
| `fee_credit` | decimal string, optional | The fee given back for a payment never sent, taken off your next batch's fees in its coin |
| `sent_at` | string, optional | When it was sent |
| `created_at`, `updated_at` | string | |

Before it is checked (`validating`) and when it is `invalid`, its `fee` and
dollar figures are `"0"`.

## Webhooks

Each step of a batch and of each payment raises a
[webhook](/docs/webhooks#events), signed like every other with the secret
of its mode. Where they go:

- a payment made with its own `webhook_url` (a payout request, or a batch
  row): its events go there;
- otherwise a batch made with `webhook_url`: its batch and payment events
  go there;
- otherwise your account's webhook URL of the mode. Withdrawals and
  schedules always go there.

| Event | Sent when | `data` |
|---|---|---|
| `payout_batch.quoted` | It was checked and priced: made, payments added or removed, quoted again | Batch |
| `payout_batch.pending_approval` | Submitted; it waits for approvals | Batch |
| `payout_batch.approved` | It got its last approval: it is funded next (`payout_batch.processing`), or waits (`payout_batch.awaiting_funds`) | Batch |
| `payout_batch.rejected` | An approver refused it (`rejection.reason`) | Batch |
| `payout_batch.awaiting_funds` | It cannot be funded yet: `waiting` says why (`funds`, with the `shortfall` to send; `daily_limit`; `paused`). Sent again whenever the reason changes. A batch the wallet covers at once goes straight to `payout_batch.processing` | Batch |
| `payout_batch.processing` | Funded: its fees and payments are being sent | Batch |
| `payout_batch.completed` | Funded, and every payment is sent, failed, canceled or expired (`counts`) | Batch |
| `payout_batch.canceled` | Canceled before it was funded: by your team or a key, or by payitgo (`canceled_by: "system"`: a send of requests that could not be submitted, or nothing left that could be paid) | Batch |
| `payout_item.processing` | A payout request left your list for a batch (sent from the dashboard, by `POST /v1/payouts/send` or by auto-send): `batch_id` is the new batch | Payment |
| `payout_item.returned` | A sent payout request came back to your list: its batch could not be submitted, or it was no longer valid when priced again (`status` `invalid`, `errors` say why). Not final | Payment |
| `payout_item.sent` | A payment is on chain (`tx_id`) | Payment |
| `payout_item.failed` | A payment was given up (`fail_reason`); its money stays in the wallet | Payment |
| `payout_item.canceled` | A payment was canceled before it was sent, whatever canceled it: `cancel_reason` (`removed`, `canceled`, `batch_canceled`, `rejected` with `cancel_note`, `invalid`) and `canceled_by` say why and who | Payment |
| `payout_item.claimed` | A [claim link](#claim-links) was claimed: the payment with the coin, address and fee its recipient chose | Payment |
| `payout_item.claim_expired` | A claim link ended unclaimed; its money is free in the wallet again | Payment |
| `payout_schedule.run` | A [schedule](#schedules) ran: its batch was made (`last_batch_id`), or `last_error` says why not | The schedule, without its `items` |
| `payout_schedule.funding_needed` | `remind_days` before a schedule's run the wallet couldn't cover | `{"schedule", "shortfall"}` (the schedule without its `items`) |
| `payout_wallet.withdrawn` | A withdrawal reached your payout address (`tx_id`) | Withdrawal |
| `payout_wallet.withdrawal_failed` | A withdrawal could not be sent (`fail_reason`); its money stays in the wallet | Withdrawal |

Taking a [payout request](#payout-requests) raises no event (the answer
says it all); from then on, the request's payment raises the events above.

### Staying in sync

Every payment you ask for, a payout request or a batch row, ends in exactly
one final state, and you hear it once, **whoever acted on it**: your system,
a member of your team in the dashboard (removing it, rejecting or canceling
its batch), or payitgo (a payment no longer payable):

| Final `status` | Event | What to do in your system |
|---|---|---|
| `sent` | `payout_item.sent` | Mark it paid (`tx_id`) |
| `failed` | `payout_item.failed` | Not paid: its money is in your wallet. Request it again if you still want to pay it |
| `canceled` | `payout_item.canceled` | Not paid, never will be (`cancel_reason`). Give your client their money back, or request it again |
| `expired` | `payout_item.claim_expired` | A claim link nobody claimed |

A payout request also tells you when it leaves your list
(`payout_item.processing`) and when it comes back (`payout_item.returned`).
A batch row is in its batch from the start: its batch's events say how the
batch goes.

- **At least once, recorded once.** A payment's event is recorded with the
  change itself, so it is never lost, even if payitgo stops at that moment;
  it is then sent within minutes. It keeps its `id` however often it is
  sent: deduplicate on the event's `id`.
- **In any order.** Events can arrive out of order (retries, several at
  once): act on the payment's `status` and its latest `updated_at`, never
  on the order they arrive in. A final status never changes again.
- **Replay what you missed.** [`GET /v1/events`](#replay-events) lists
  every event of the mode, as it was sent, from any point: after an outage,
  read from the last event you handled.
- **Check the whole picture.** [`GET /v1/payouts`](#list-payments) lists
  your payments by last change, whatever their batch.
- **Canceled stays canceled.** A canceled payment is never brought back: to
  pay it after all, send a new request (its reference is free again).

### Replay events

`GET /v1/events?type=&after=&created_after=&limit=` · any key of the
account (read-only ones too; a storefront's key is refused)

The mode's events, **oldest first**, each exactly as its webhook carried
it (`id`, `type`, `livemode`, `created_at`, `data`), payments' and payouts'
alike. Events are kept; a test ping is left out.

| Query | Description |
|---|---|
| `type` | Comma-separated: exact types (`payout_item.sent`), or a family (`payout_item.*`); all when left out |
| `after` | An event's `id`: the events after it. Keep the last one you handled |
| `created_after` | RFC 3339 time: the events after it (ignored with `after`) |
| `limit` | 1 to 200; 100 by default |

```bash
curl -s "https://api.payitgo.co/v1/events?type=payout_item.*&after=4f1c2a9e7b3d5f80a2c4e6b8" \
  -H "Authorization: Bearer $PAYITGO_KEY"
```

```json
{
  "events": [
    {
      "id": "9b1e3c5a7d2f4b6e8a0c1d3f",
      "type": "payout_item.canceled",
      "livemode": true,
      "created_at": "2026-10-20T15:02:44.312Z",
      "data": { "id": "5c7e9a1b3d5f7a9c1e3b5d7f", "status": "canceled", "cancel_reason": "rejected", "cancel_note": "paid by bank transfer instead", "released_reference": "wd-88231", "…": "…" }
    }
  ],
  "has_more": false
}
```

While `has_more`, read again with `after` the last `id`. Events of the last
few seconds are left for the next read, so moving forward never skips one.
A key that cannot make payouts gets claim links without their `url` and
`pin`.

| Status | Message | Cause |
|---|---|---|
| `400` | `type must be event types, such as payout_item.sent or payout_item.*` | |
| `400` | `created_after must be an RFC 3339 time, such as 2026-10-20T09:00:00Z` | |
| `400` | `limit must be 1 to 200` | |
| `400` | `after is not an event of this mode` | An unknown id, or the other mode's |
| `403` | `a storefront's key cannot read the account's events: use an account key` | |

## In test mode

A test key (or the dashboard with `?mode=test`) works with your **test
payout wallet**: other addresses than the live ones, with balances on
paper. Nothing is ever read from or sent on a chain:

- **Top it up with a click:** `POST /v1/payout-wallets/{asset}/simulate-top-up`
  with a test key (a key with payouts on), or
  `POST /merchant/payout-wallets/{asset}/simulate-top-up?mode=test` (owner or
  finance), adds `amount` (over 0, up to 10,000,000) to its paper balance,
  and funds any test batch waiting for it. The answer is
  `{"wallets": [wallet, …]}`.

  ```bash
  curl -s https://api.payitgo.co/v1/payout-wallets/USDT_TRC20/simulate-top-up \
    -H "Authorization: Bearer $PAYITGO_TEST_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"amount": "1000"}'
  ```

- Batches are checked and priced as live ones, with the same fees, but
  recipients' addresses are not read on chain: no `new_holder`,
  `activation` or `contract_recipient` issues.
- Fees and payments are sent **on paper**, within seconds: each payment
  gets a made-up `tx_id` (64 hex characters, on no chain, with no
  `explorer_url`), the paper balance goes down, and the webhooks are sent as
  live.
- Withdrawals work the same way, to a test payout address.
- Every network works on paper, Bitcoin, Litecoin, Dogecoin and XRP
  included.
- [Claim links](#claim-links) email no one: open the link's `url` yourself;
  the code is always `000000`.
- [Conversions](#conversion) are priced at an example cost ($1 plus 1%) and
  paid on paper.
- [Payout requests](#payout-requests) sent with a test key wait in your
  test list, apart from the live one, and are sent the same way.
- The daily limit does not apply.

| Status | Message | Cause |
|---|---|---|
| `400` | `only a test wallet is topped up on paper: use a test key` | A live key on `/v1` |
| `400` | `only a test wallet is topped up on paper: add ?mode=test` | The dashboard route without `?mode=test` |
| `400` | `payouts in SHIB_ERC20 are not available yet` | Not a payout coin |
| `400` | `amount must be over 0 and at most 10000000` | |

## Limits

| What | Limit |
|---|---|
| Payments in a batch | 10,000 |
| Payout requests waiting in your list | 100,000; up to 10,000 go in each send |
| Payments in one create or add call | 1,000 (a CSV file in the dashboard takes up to 10,000) |
| Checked while the call waits | 100: a larger batch is checked in the background (`validating`) |
| Payments in a quote | 100 |
| A quote's prices | Hold for an hour |
| The smallest payment | $1 |
| Payments in one transfer | 50 (200 in one Bitcoin, Litecoin or Dogecoin transaction); one transfer at a time per wallet and network |
| Speed | One transfer every 15 seconds per network, from one wallet: about 240 payments an hour; on Bitcoin, Litecoin and Dogecoin one transfer pays up to 200 |
| A CSV file | 16 MiB |
| `allowed_ips` | 20 addresses or ranges per key |
| A simulated top-up | 10,000,000 of the coin per call |
| A batch with converted payments | Its prices hold 15 minutes |
| A claim link | Works 14 days after funding; extended up to 3 times (1 to 30 days); up to 5 emails; 10 other coins |
| A schedule | 1000 payments |
| `/v1` requests | 20 a second per key ([rate limits](/docs/errors#rate-limits)) |

## Audit log

Every step is in your [audit log](/docs/account#audit-log), with who did it
and the figures before and after: `payout_batch.created`, `.items_added`,
`.item_removed`, `.submitted`, `.approved`, `.rejected`, `.funded`,
`.canceled`, `.completed`, `.items_canceled` (payments no longer payable
when it was funded); `payout_request.created`, `payout_request.canceled`,
`payout_requests.sent`, `payout_requests.returned`, `payout_requests.removed`;
`payout_item.canceled`, `.released`, `.screening_hit`, `.claimed`,
`.claim_resent`, `.claim_extended`, `.claim_expired`, `.claim_locked`,
`.claim_pin_changed`; `payout_schedule.created`, `.updated`,
`.paused`, `.resumed`, `.deleted`, `.run`, `.funding_needed`;
`payout_wallet.created`, `.extended` (a network added to it), `.test_top_up`,
`.withdrawal_requested`, `.withdrawal_sent`, `.withdrawal_failed`, `.idle`;
`payout_controls.updated` and `.took_effect` (a loosening whose 24 hours
ended); `api_key.updated`.
