> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stabyl.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bulk Withdrawals

> Pay many beneficiaries from one signed request and reconcile each transfer.

A bulk NGN withdrawal pays several bank accounts from one signed request. You
send one list of beneficiaries, Stabyl creates one parent withdrawal, and every
beneficiary settles as its own bank transfer. This is the right tool for
payroll, supplier runs, customer refunds, and any other job where you would
otherwise loop over single withdrawals.

Bulk uses the same routes, signing, and scopes as a single withdrawal. If you
have not read [Single withdrawals](/docs/withdrawals) yet, start there. This
page only covers what changes when `items` has more than one entry.

<Note>
  Bulk withdrawals are available for NGN bank payouts today. A USD stablecoin
  withdrawal accepts exactly one item per request; sending more returns
  `400 VALIDATION_FAILED`.
</Note>

## How it works

1. Build one `items` list with one entry per beneficiary.
2. Quote it with `POST /partner/wallets/withdrawal/quotes` to preview the total
   debit and the number of bank transfers.
3. Submit the same list with `POST /partner/wallets/withdrawals` and one
   `idempotency_key`.
4. Persist the returned parent `id`, then reconcile each transfer with
   `GET /partner/wallets/transactions/{id}`.

The whole request is checked before any money moves. If one beneficiary is
invalid, the request is rejected and nothing is debited. Once accepted, every
transfer settles on its own, so one bank rejecting a payment does not undo the
others.

## Limits

| Limit                        | Value                     | What happens when you exceed it                                                             |
| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| Beneficiaries per request    | 1 to 50 items             | `400 VALIDATION_FAILED`, nothing is created                                                 |
| Largest single bank transfer | ₦100,000,000.00           | The item is split into several transfers automatically                                      |
| Bank transfers per request   | 500 in total              | `400 VALIDATION_FAILED`, nothing is created                                                 |
| Smallest item amount         | ₦1.00                     | `400 VALIDATION_FAILED` below ₦1.00. Kobo is dropped silently: ₦350,000.50 pays ₦350,000.00 |
| Total principal per request  | Your account policy       | `422 ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED`, nothing is created                                 |
| Available balance            | Total principal plus fees | `402 INSUFFICIENT_BALANCE`, nothing is created                                              |

The account limit and balance check apply to the whole request, not to each
beneficiary. A request of 50 small payments and a request of one large payment
are both measured by their total. The account limit counts principal only; the
balance check counts principal plus the ₦100 fee per transfer. The item,
transfer, and per-transfer amount limits are server settings that can change,
so treat them as current values rather than permanent constants. Because kobo
is dropped after the request is accepted, send whole-naira amounts; two
requests that differ only in kobo are different intents under the same
idempotency key.

## Fees

The NGN withdrawal fee is a flat ₦100.00 for every bank transfer that is
created. Most beneficiaries need exactly one transfer, so for a typical bulk
request the fee is ₦100.00 multiplied by the number of beneficiaries. A
beneficiary above ₦100,000,000.00 is split into more than one transfer and pays
the fee for each of them.

The quote response reports `transfer_count`, the aggregate `fee`, and the
`debit_amount` that will leave your NGN wallet, so you can show the true cost
before you submit.

## Quote a bulk request

Quote bodies use `items` and omit `idempotency_key`:

```json theme={null}
{
  "currency": "NGN",
  "items": [
    {
      "client_item_id": "payroll-2026-09-emp-0412",
      "amount": "350000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Adaeze Okafor"
      },
      "description": "September salary"
    },
    {
      "client_item_id": "payroll-2026-09-emp-0587",
      "amount": "420000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "058",
        "bank_name": "GTBank",
        "account_number": "0987654321",
        "account_name": "Tunde Bakare"
      },
      "description": "September salary"
    },
    {
      "client_item_id": "payroll-2026-09-emp-0731",
      "amount": "275000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "057",
        "bank_name": "Zenith Bank",
        "account_number": "1122334455",
        "account_name": "Ngozi Eze"
      },
      "description": "September salary"
    }
  ]
}
```

The quote for this list reports three transfers, a fee of `300.00`, an amount
of `1045000.00`, and a `debit_amount` of `1045300.00`. Quoting never reserves
funds, but it runs the account-limit and balance checks and has its own rate
limit, so quote when the operator reviews the list rather than on every
keystroke.

## Submit a bulk request

Submit uses the same list and adds one `idempotency_key` for the whole batch.
Sign the exact bytes you send, as described in
[Request Signing](/docs/withdrawals#request-signing).

```json theme={null}
{
  "currency": "NGN",
  "items": [
    {
      "client_item_id": "payroll-2026-09-emp-0412",
      "amount": "350000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Adaeze Okafor"
      },
      "description": "September salary"
    },
    {
      "client_item_id": "payroll-2026-09-emp-0587",
      "amount": "420000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "058",
        "bank_name": "GTBank",
        "account_number": "0987654321",
        "account_name": "Tunde Bakare"
      },
      "description": "September salary"
    },
    {
      "client_item_id": "payroll-2026-09-emp-0731",
      "amount": "275000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "057",
        "bank_name": "Zenith Bank",
        "account_number": "1122334455",
        "account_name": "Ngozi Eze"
      },
      "description": "September salary"
    }
  ],
  "idempotency_key": "payroll-2026-09-run-1"
}
```

Use one `idempotency_key` per batch, not per beneficiary. Give every item a
`client_item_id` from your own system. It is optional, but it is the easiest
way to match a transfer outcome back to the right employee, supplier, or
customer, and it comes back in the submit response and in every later
transaction read.

The same bank account can appear more than once in a list. Each entry is paid
separately, so check your list for accidental duplicates before you submit.

## Submit response

The response is one parent transaction. `items` keeps the order of your
request, and each item lists the bank transfers that will pay it.

```json theme={null}
{
  "status": "success",
  "data": {
    "id": "0198f3a0-0000-7000-8000-0000000000a1",
    "status": "pending",
    "rail": "fiat",
    "currency": "NGN",
    "tx_type": "withdrawal",
    "amount": "1045000.00",
    "reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
    "fee": "300.00",
    "debit_amount": "1045300.00",
    "estimated_receive": "1045000.00",
    "receive_currency": "NGN",
    "transfer_count": 3,
    "max_transfer_amount": "100000000.00",
    "items": [
      {
        "client_item_id": "payroll-2026-09-emp-0412",
        "amount": "350000.00",
        "destination": {
          "type": "bank_account",
          "bank_code": "044",
          "bank_name": "Access Bank",
          "account_number": "0123456789",
          "account_name": "Adaeze Okafor"
        },
        "transfers": [{ "sequence": 1, "amount": "350000.00", "status": "processing" }]
      },
      {
        "client_item_id": "payroll-2026-09-emp-0587",
        "amount": "420000.00",
        "destination": {
          "type": "bank_account",
          "bank_code": "058",
          "bank_name": "GTBank",
          "account_number": "0987654321",
          "account_name": "Tunde Bakare"
        },
        "transfers": [{ "sequence": 2, "amount": "420000.00", "status": "processing" }]
      },
      {
        "client_item_id": "payroll-2026-09-emp-0731",
        "amount": "275000.00",
        "destination": {
          "type": "bank_account",
          "bank_code": "057",
          "bank_name": "Zenith Bank",
          "account_number": "1122334455",
          "account_name": "Ngozi Eze"
        },
        "transfers": [{ "sequence": 3, "amount": "275000.00", "status": "processing" }]
      }
    ]
  }
}
```

`sequence` numbers run across the whole batch, so the first transfer of the
second item continues counting from where the first item stopped. The parent
starts as `pending` and each transfer as `processing`. Store `data.id`
immediately. It is the only identifier you need to follow the batch to
completion.

## Outcomes

Each bank transfer succeeds or fails on its own. The parent status summarizes
them:

| Parent status | Meaning                                                              |
| ------------- | -------------------------------------------------------------------- |
| `pending`     | Accepted; the batch has not been handed to the bank yet              |
| `processing`  | At least one transfer is still with the bank                         |
| `success`     | Every transfer was paid                                              |
| `partial`     | Some transfers were paid and the rest failed. This is final.         |
| `failed`      | No transfer was paid                                                 |
| `cancelled`   | The batch was cancelled before any transfer was paid. This is final. |

When a transfer fails, its principal and its ₦100.00 fee are returned to your
NGN wallet. Transfers that succeeded stay paid. You never need to reverse or
re-send the successful part of a batch; only re-send the beneficiaries whose
transfers failed, in a new request with a new `idempotency_key`.

Each outcome has its own webhook: `withdrawal.success`, `withdrawal.partial`,
and `withdrawal.failed`. Subscribe to all three so a mixed batch is not left
waiting for an event that will never arrive. See
[Withdrawal webhook events](/docs/webhook-withdrawal-events) for the payloads.

<Info>
  A webhook tells you the batch has finished. It does not list who was paid.
  Finish bulk reconciliation with the transaction read below, which carries the
  per-transfer outcomes.
</Info>

## Reconciling each transfer

Read the parent with `GET /partner/wallets/transactions/{id}`. The
`settlement_details.transfers` list carries one entry per bank transfer with
its own `status`, your `client_item_id`, and the position of the item in your
original request.

```json theme={null}
{
  "status": "success",
  "data": {
    "id": "0198f3a0-0000-7000-8000-0000000000a1",
    "rail": "fiat",
    "currency": "NGN",
    "tx_type": "withdrawal",
    "status": "partial",
    "amount": "1045000.00",
    "reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
    "description": "September salary",
    "failure_reason": null,
    "created_at": "2026-09-09T08:12:04+00:00",
    "updated_at": "2026-09-09T08:19:41+00:00",
    "settlement_details": {
      "type": "bank_transfer",
      "rail_reference": "WD-0198f3a0-1c2d-7e4f-8a91-0b3c5d7e9f11",
      "recipient": {
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Adaeze Okafor"
      },
      "transfer_count": 3,
      "transfers": [
        {
          "sequence": 1,
          "item_index": 0,
          "item_transfer_index": 1,
          "client_item_id": "payroll-2026-09-emp-0412",
          "amount": "350000.00",
          "status": "success",
          "description": "September salary",
          "recipient": {
            "bank_code": "044",
            "bank_name": "Access Bank",
            "account_number": "0123456789",
            "account_name": "Adaeze Okafor"
          }
        },
        {
          "sequence": 2,
          "item_index": 1,
          "item_transfer_index": 1,
          "client_item_id": "payroll-2026-09-emp-0587",
          "amount": "420000.00",
          "status": "failed",
          "description": "September salary",
          "recipient": {
            "bank_code": "058",
            "bank_name": "GTBank",
            "account_number": "0987654321",
            "account_name": "Tunde Bakare"
          }
        },
        {
          "sequence": 3,
          "item_index": 2,
          "item_transfer_index": 1,
          "client_item_id": "payroll-2026-09-emp-0731",
          "amount": "275000.00",
          "status": "success",
          "description": "September salary",
          "recipient": {
            "bank_code": "057",
            "bank_name": "Zenith Bank",
            "account_number": "1122334455",
            "account_name": "Ngozi Eze"
          }
        }
      ],
      "confirmed_at": "2026-09-09T08:19:41+00:00",
      "failed_at": null
    }
  }
}
```

| Field                 | Meaning                                                                              |
| --------------------- | ------------------------------------------------------------------------------------ |
| `sequence`            | Position of the transfer across the whole batch, starting at 1                       |
| `item_index`          | Position of the beneficiary in your `items` list, starting at 0                      |
| `item_transfer_index` | Position of the transfer within that beneficiary, starting at 1                      |
| `client_item_id`      | The value you sent for that beneficiary; omitted if you sent none                    |
| `status`              | `pending`, `processing`, `success`, `failed`, or `cancelled` for this transfer alone |

The parent-level `recipient` and `description` show the first beneficiary in
the batch. Use the per-transfer `recipient` and `client_item_id` for anything
beneficiary specific. `description` and `recipient` on a transfer are omitted
rather than `null` when absent. On a `partial` parent, `confirmed_at` is the
time the batch finished settling and `failed_at` stays `null`.
`settlement_details.rail_reference` carries the same `WD-` reference as
`reference`.

In the example above, two employees were paid and one transfer failed. Your
system should mark the first and third employees as paid and queue a fresh
withdrawal for the second employee after checking the account details. The
wallet receives `420100.00` back in total: a `refund` transaction for the
`420000.00` principal appears in the timeline with the description
`Refund for failed withdrawal transfers`, and the `100.00` fee for that
transfer is released back to the balance alongside it.

## Retries and idempotency

A transport failure on submit is handled exactly like a single withdrawal.
Keep the same body and `idempotency_key`, generate a new timestamp and nonce,
sign again, and resubmit. The same key with the same list returns the existing
parent instead of paying anyone twice.

Changing anything in the list under the same key returns
`IDEMPOTENCY_CONFLICT`. That includes the order of the items, an amount, an
account number, a description, or a `client_item_id`. If your list changed,
it is a new batch and needs a new key.

## Practical tips

* Quote first and show the `transfer_count` and `debit_amount` to the person
  approving the batch.
* Keep batches at 50 beneficiaries or fewer and split larger payroll runs into
  several requests with distinct idempotency keys such as
  `payroll-2026-09-run-1`, `payroll-2026-09-run-2`.
* Save `client_item_id` alongside your own record before you submit, so a
  restart between submit and reconciliation cannot lose the mapping.
* Treat `partial` as final. Retry only the failed beneficiaries, never the
  whole batch.
* Subscribe to `withdrawal.partial` alongside `withdrawal.success` and
  `withdrawal.failed`, or a mixed batch will never trigger your webhook
  handler.
* Reconcile every batch with a transaction read even when a webhook arrived.
  Webhooks tell you the batch finished; the transaction read tells you who was
  paid.
