# Bulk payouts

Pay many beneficiaries in one go (payroll, supplier runs, marketplace settlements) by uploading a spreadsheet. You preview the whole file first, fix any row errors, then commit it once. Each row then becomes an ordinary transfer with its own status and webhook.

A bulk payout happens in two calls:

- **Preview:** you upload the file. Multigate checks every row and tells you what's wrong, without moving money.
- **Commit:** you confirm the preview. Multigate creates one transfer per row (a *batch*) and pays them in the background.

## Before you start

- [ ] `MG_BASE_URL`, `MG_API_KEY`, `MG_CLIENT_ID` and `MG_ACCOUNT_NUMBER` are set, as in the [Quickstart](../01-get-started/03-quickstart.md).
- [ ] Your API user has the *initiate payment* permission (`can_initiate_payment`). *Approve payment* alone isn't enough to upload.
- [ ] You know who pays: `onBehalfOf` is **required** on the preview. Send your client ID (`MG_CLIENT_ID`), or a customer ID when you pay from a customer's account.
- [ ] One file pays from **one** source account, in that account's currency. Every row is paid in that currency.
- [ ] The source account will hold the total of all rows **plus fees** when you commit. The preview tells you the exact total.
- [ ] For USD rows paying someone other than the account holder, you have supporting documents uploaded and their IDs ready for `assets` (ask Multigate how to upload them).
- [ ] You've read [Payouts](05-payouts.md): each row is paid exactly like a single transfer.

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant You as Your backend
    participant MG as Multigate
    participant Bank as Beneficiary banks
    You->>MG: GET /transfer/bulk-transfer-template
    MG-->>You: Template (xlsx or csv)
    You->>MG: POST /transfer/bulk-transfer/preview (file)
    MG->>Bank: Look up each beneficiary
    MG-->>You: previewId, batchId, totals, row errors
    Note over You: Fix errors and preview again until no row has errors
    You->>MG: POST /transfer/bulk-transfer/commit (previewId) within 30 minutes
    MG-->>You: batchReference, responseCode 01 or 42
    loop Each row, in the background
        MG->>Bank: Payment
        MG-)You: transfer_completed or transfer_failed
    end
```

<Steps>
<Step title="Get the template">

```bash
curl -o bulk-template.csv "$MG_BASE_URL/payment-service/api/v1/transfer/bulk-transfer-template?format=csv" \
  -H "mp-api-key: $MG_API_KEY"
```

Leave out `format=csv` to get the Excel workbook. Only its **Transfer Entries** sheet is read. The **Instruction** sheet explains each column; the other sheets list bank names with BICs, countries and currencies for the drop-downs.

</Step>
<Step title="Fill it in">

One transfer per row. Keep the header row exactly as in the template (same spelling, upper case and spaces). Empty rows are skipped.

| Column | Required | What to put in it | Example (NGN row) |
|---|---|---|---|
| AMOUNT | Yes | Amount in major units of the source currency. No thousands separators or currency symbols. | `400000` |
| NARRATION | Yes | Payment description. The beneficiary sees the first 36 characters. | `October salary` |
| BENEFICIARY BANK | Yes | Bank name. | `Guaranty Trust Bank` |
| BENEFICIARY BANK BIC OR CODE | Yes | Local bank code (`bankCode` from [List banks](api:listBanks)) for local rows, or the SWIFT BIC for SWIFT rows. | `058` |
| BENEFICIARY BANK ADDRESS | Yes | The bank's address. | `635 Akin Adesola Street, Lagos` |
| BENEFICIARY ACCOUNT NAME | Yes | Name on the account, ideally as returned by [Look up an account name](api:nameEnquiry). | `ADA OKAFOR` |
| BENEFICIARY ACCOUNT NUMBER | Yes | Digits only. NGN: exactly 10 digits. Keep leading zeros: in Excel, format the column as text. An account number with letters (such as an IBAN) is refused with "Invalid data type". | `0123456789` |
| BENEFICIARY NAME | Yes | The beneficiary's full or business name. | `Ada Okafor` |
| BENEFICIARY ADDRESS | Yes | Street address. | `12 Marina Road` |
| BENEFICIARY CITY | Yes | | `Lagos` |
| BENEFICIARY STATE | Yes | | `Lagos` |
| BENEFICIARY COUNTRY | Yes | `NG`, `NGA`, or the drop-down value such as `NIGERIA - NG`. | `NGA` |
| BENEFICIARY EMAIL ADDRESS | Yes | A valid email address. | `ada.okafor@example.com` |
| BENEFICIARY PHONE NUMBER | No | Digits, optionally starting with `+`. No spaces or dashes. | `2348012345678` |
| INTERMEDIARY BANK | SWIFT rows | The drop-down value `Bank name-BIC`, or the BIC alone. Leave empty for local rows. | (empty) |
| INTERMEDIARY BANK COUNTRY | SWIFT rows | Same formats as BENEFICIARY COUNTRY. | (empty) |
| CHARGE TYPE | SWIFT rows | Who pays SWIFT bank charges: `OUR` (you), `SHA` (shared) or `BEN` (the beneficiary). | (empty) |
| PAYMENT METHOD | Yes | The rail: `NIP` for NGN rows, `SWIFT` for SWIFT rows. See [List payment methods](api:listPaymentMethods). | `NIP` |
| PURPOSE CODE | No | Not in the template, but read if you add the column. A code from [List purpose codes](api:listPurposeCodes). | (column not added) |

A complete CSV file with two NGN rows looks like this:

```text
AMOUNT,NARRATION,BENEFICIARY BANK,BENEFICIARY BANK BIC OR CODE,BENEFICIARY BANK ADDRESS,BENEFICIARY ACCOUNT NAME,BENEFICIARY ACCOUNT NUMBER,BENEFICIARY NAME,BENEFICIARY ADDRESS,BENEFICIARY CITY,BENEFICIARY STATE,BENEFICIARY COUNTRY,BENEFICIARY EMAIL ADDRESS,BENEFICIARY PHONE NUMBER,INTERMEDIARY BANK,INTERMEDIARY BANK COUNTRY,CHARGE TYPE,PAYMENT METHOD
400000,October salary,Guaranty Trust Bank,058,"635 Akin Adesola Street, Lagos",ADA OKAFOR,0123456789,Ada Okafor,12 Marina Road,Lagos,Lagos,NGA,ada.okafor@example.com,2348012345678,,,,NIP
250000,October salary,Guaranty Trust Bank,058,"635 Akin Adesola Street, Lagos",TUNDE BELLO,0234567891,Tunde Bello,3 Allen Avenue,Ikeja,Lagos,NGA,tunde.bello@example.com,,,,,NIP
```

A SWIFT row (in a USD file) fills the last four columns, for example `…,Citibank N.A.-CITIUS33XXX,USA,SHA,SWIFT`. Its account number must still be digits only.

Put a value in quotes when it contains a comma, as in the bank address above.

</Step>
<Step title="Preview it">

Send the file as the multipart part `file`, **with its content type set**. Many clients send `application/octet-stream`, which is refused. Use `text/csv` for CSV and `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` for `.xlsx`.

```bash
curl -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/bulk-transfer/preview" \
  -H "mp-api-key: $MG_API_KEY" \
  -F "file=@bulk-template.csv;type=text/csv" \
  -F "onBehalfOf=$MG_CLIENT_ID" \
  -F "sourceAccountNumber=$MG_ACCOUNT_NUMBER" \
  -F "sourceCurrencyIso3Code=NGN"
```

```json
{
  "message": "Successful",
  "previewId": "2f1c0b9a-8d7e-4f6a-9b5c-3e2d1c0b9a8d",
  "batchId": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
  "summary": { "totalAmount": 650000, "totalFees": 100, "transferCount": 2, "requiresApproval": false },
  "rows": [
    { "rowNumber": 2, "narration": "October salary", "amount": 400000, "currency": "NGN", "errors": [] },
    { "rowNumber": 3, "narration": "October salary", "amount": 250000, "currency": "NGN", "errors": [] }
  ]
}
```

Nothing is paid yet. Two kinds of problems come back:

- **File problems** (a missing column, an empty mandatory cell, a wrong type, an unknown purpose code) return `400` with the first problem only. Fix it and upload again.
- **Row problems** come back in `rows[].errors` with `field` and `message`, for example `beneficiaryEmailAddress` "Beneficiary email address is invalid." or `beneficiaryDetails` "Beneficiary lookup failed.". `rowNumber` is the line in your file, counting the header as line 1.

Show `summary.totalAmount + summary.totalFees` to your user for confirmation; that's what the rows will debit. `summary.requiresApproval: true` means the rows will wait for an approver in your business.

During the preview, each beneficiary is looked up at their bank, and beneficiaries you haven't paid before are saved to your beneficiary list.

</Step>
<Step title="Fix row errors, if any">

A preview with any row error can't be committed. To fix them:

1. List the rows that have errors:

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/bulk-transfer/preview" \
  -H "mp-api-key: $MG_API_KEY" \
  -F "file=@bulk-template.csv;type=text/csv" \
  -F "onBehalfOf=$MG_CLIENT_ID" \
  -F "sourceAccountNumber=$MG_ACCOUNT_NUMBER" \
  -F "sourceCurrencyIso3Code=NGN" \
  | jq '.rows[] | select(.errors | length > 0) | {rowNumber, errors}'
```

```json
{
  "rowNumber": 3,
  "errors": [
    { "field": "beneficiaryEmailAddress", "message": "Beneficiary email address is invalid." },
    { "field": "beneficiaryDetails", "message": "Beneficiary lookup failed.", "code": "beneficiaryLookupFailed" }
  ]
}
```

2. For each row, correct the cell that `field` points to (`rowNumber` is the line in your file, header = line 1):

| `field` | Fix |
|---|---|
| `beneficiaryDetails` | The bank couldn't confirm the account. Check BENEFICIARY ACCOUNT NUMBER and BENEFICIARY BANK BIC OR CODE with [Look up an account name](api:nameEnquiry). |
| `beneficiaryEmailAddress` | Correct BENEFICIARY EMAIL ADDRESS. |
| `amount` | Change AMOUNT to fit the currency's limits ("Minimum amount not met", "Maximum amount exceeded"). |
| `paymentMethod` | Use a PAYMENT METHOD configured for the currency and country ([List payment methods](api:listPaymentMethods)). |
| `intermediaryBank`, `intermediaryBankCountry`, `chargeType` | Fill those columns for SWIFT rows. |
| `assets` | A USD row pays a third party: send supporting document IDs in `assets`. |
| `charge` | The fee couldn't be calculated. Preview again in a few minutes. |
| `sourceAccountNumber` | The row pays the source account itself. Remove or correct the row. |

3. Or remove the rows you can't fix yet, and pay them later in another file.
4. Preview the corrected file again. You get a **new** `previewId`; commit that one. Previews you don't commit expire after 30 minutes and are never paid.

</Step>
<Step title="Commit it">

```bash
curl -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/bulk-transfer/commit" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "previewId": "2f1c0b9a-8d7e-4f6a-9b5c-3e2d1c0b9a8d", "comment": "October payroll" }'
```

```json
{
  "message": "Successful",
  "batchReference": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
  "processing": 2,
  "failed": 0,
  "responseCode": "01",
  "responseMessage": "Transfers sent successfully"
}
```

Commit within **30 minutes** of the preview. The balance check (total plus fees) happens now. A preview can be committed only once: after a successful commit, a second one returns "Invalid transaction…" and creates nothing.

:::warning[One commit at a time]
Don't send two commits for the same `previewId` at once. If a commit times out, list the batch with [Find transfers](api:listTransfers) `?batchId=` (the preview's `batchId`) before you try again.
:::

</Step>
<Step title="Track the rows">

Every row is an ordinary transfer with its own `uniqueRequestId` (generated by Multigate), `transactionId`, status and webhook. The webhook doesn't carry the `batchId`, so right after the commit, list the rows and store each row's IDs against your own records:

```bash
curl -s "$MG_BASE_URL/payment-service/api/v1/transaction/report?batchId=6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d&pageSize=100&pageNumber=1" \
  -H "mp-api-key: $MG_API_KEY" \
  | jq '{totalPages, rows: [.data[] | {uniqueRequestId, transactionId, status, amount, account: .beneficiaryDetails.accountNumber, reference}]}'
```

- Match each row to your file by beneficiary account number and amount (`reference` is the row's NARRATION, shortened).
- Pages hold at most 100 rows: increase `pageNumber` until you reach `totalPages`.
- Then handle each row's `transfer_completed` / `transfer_failed` webhook by its `uniqueRequestId`, as in [Payouts › After you send](05-payouts.md#after-you-send), and poll rows that stay non-final with the same `batchId` query.

Users with the *approve payment* permission can also see the batch with its totals and all rows with [Get a bulk transfer](api:getBulkTransferBatch).

</Step>
</Steps>

## Commit results

| `responseCode` | `responseMessage` | Meaning |
|---|---|---|
| `01` | Transfers sent successfully | Rows created as `pending` and paid in the background. |
| `42` | Transfers submitted for approval | Rows wait as `pending_approval` until an approver in your business acts in the dashboard. |
| `03` | Transaction failed | No row could be sent. |

`failed` counts rows created as `failed` because the beneficiary couldn't be confirmed. It's normally `0`, because such rows show up as preview errors and block the commit.

## Statuses

Each row follows the [transfer statuses](05-payouts.md#transfer-statuses): `pending` or `pending_approval` after commit, then `processing`, then `completed` or `failed`. An approver can decline a waiting row (`rejected`, no webhook).

The batch has its own summary status:

| Batch status | Meaning |
|---|---|
| `pending_approval` | At least one row waits for an approver. |
| `processing` | Rows are being paid. |
| `completed` | Every row is final and at least one was paid. Some rows may still have failed. |
| `failed` | Every row is final and none was paid. |
| `rejected` | An approver declined every row. |

```mermaid
stateDiagram-v2
    direction LR
    [*] --> pending_approval: needs approval
    [*] --> processing: sent
    pending_approval --> processing: approved
    pending_approval --> rejected: all declined
    processing --> completed: every row final, at least one paid
    processing --> failed: every row final, none paid
    completed --> [*]
    failed --> [*]
    rejected --> [*]
```

A batch is `completed` as soon as one row is paid and the rest are final, so **check each row's status**, not only the batch.

## Things to know

- **There is no batch-level webhook.** You receive one `transfer_completed` or `transfer_failed` per row. Rows declined by an approver send no webhook.
- **Nothing is reserved at commit.** Each row is debited when it's paid, so spending from the same account in the meantime can make later rows fail.
- **Bulk rows are never split,** and the preview doesn't flag an NGN NIP row above the per-payment maximum. Keep each such row within the maximum shown by [List payment methods](api:listPaymentMethods).
- **Large files:** the preview looks up every beneficiary and must finish within 60 seconds at the gateway. Split very large runs into several files.
- **Finding waiting rows:** [Find transfers](api:listTransfers) with `status=pending_approval` doesn't return bulk rows. Use `batchId` instead.
- **Supporting documents** for third-party USD rows go in `assets` on the preview, as IDs. They apply to the whole batch.
- **Emails:** your API user gets a "Transfer Notification" email per row when it starts processing and when it completes or fails.
- The [Nigerian account number rule](05-payouts.md#errors) applies to every row.

## Errors

| When | Message | Fix |
|---|---|---|
| Preview | File is required | Send the file as the multipart part `file`. |
| Preview | Invalid file type. … are allowed. | Set the part's content type (`text/csv`, `application/csv`, `application/vnd.ms-excel` or `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`). |
| Preview | Missing mandatory column {name} | Use the template's header row unchanged. |
| Preview | Row {n}: Missing mandatory field {name} | Fill that cell. "Row : Missing mandatory field" with no number means the file has no data rows. |
| Preview | Row {n}: Invalid data type for field {name}. Must be a number. | Remove letters, spaces, commas or dashes from AMOUNT, BENEFICIARY ACCOUNT NUMBER or BENEFICIARY PHONE NUMBER. |
| Preview | Invalid Purpose Code | Use a code from [List purpose codes](api:listPurposeCodes), or leave the cell empty. |
| Preview | Source Account Not Found / Invalid Source Account Owner | Check `sourceAccountNumber` and that it belongs to `onBehalfOf`. |
| Preview (row) | Beneficiary lookup failed. | Check the account number and bank code with [Look up an account name](api:nameEnquiry). |
| Preview (row) | Minimum amount not met / Maximum amount exceeded | Change the amount. |
| Preview (row) | Intermediary bank / Intermediary bank country / Charge type is required for SWIFT transfers. | Fill those columns for SWIFT rows. |
| Preview (row) | Supporting documents are required for 3rd party USD transfers. | Upload documents and send their IDs in `assets`. |
| Commit | Invalid transaction (general response for invalid transactions) | The preview is unknown, expired (30 minutes) or already committed. Check [Find transfers](api:listTransfers) with the `batchId` before previewing again. |
| Commit | Transfer instructions are invalid, please upload a valid file | A row still has errors. Fix and preview again. |
| Commit | Insufficient balance in source account | Fund the account for the total plus fees, then commit again within the 30 minutes. |

## Next

- [Payouts](05-payouts.md) for how each row is paid.
- [Event catalogue](../03-webhooks-and-emails/02-event-catalogue.md) for the row webhooks.
- [Idempotency](../02-concepts/03-idempotency.md) for retries.
