# Payouts

Send money from one of your Multigate accounts to a bank account, in Nigeria or abroad, with one endpoint: [Create a transfer](api:createSingleTransfer). This page shows which request to send for your case, what happens after you send it, and how to handle every outcome.

## Words used on this page

| Term | Meaning |
|---|---|
| **Payout** or **transfer** | Money sent from your account to someone else's bank account. The API calls it a transfer. |
| **Beneficiary** | The person or business you pay. |
| **Source account** | Your Multigate account that is debited (the money comes out of it). |
| **Bank code** | A bank's local identifier, for example `058` in Nigeria. Get it from [List banks](api:listBanks). |
| **BIC (SWIFT code)** | A bank's international identifier, 8 or 11 characters, for example `COBADEFFXXX`. |
| **NUBAN** | The 10-digit format of every Nigerian bank account number. |
| **Rail** | The network the payment travels on: NIP (Nigeria's instant transfers), RTGS (high-value local transfers) or SWIFT (international wires). Multigate picks it for you. |
| **`uniqueRequestId`** | An ID you generate for each payout. Multigate refuses a second request with the same ID, so a retry can never pay twice (this is called an *idempotency key*). |
| **Screening** | Multigate's automatic compliance check of each payment, before any money moves. |
| **Approval (maker-checker)** | A rule in your business that a second person must approve a payment someone else created. |
| **Webhook** | An HTTPS `POST` Multigate sends to your server when the payout finishes. |

More terms are in the [Glossary](../06-reference/02-glossary.md).

## When to use it

| You want to | Use |
|---|---|
| Pay one beneficiary now | [Create a transfer](api:createSingleTransfer), this page |
| Pay many beneficiaries from a file | [Bulk payouts](06-bulk-payouts.md) |
| Pay out for a remittance (IMTO) with sender details | [Remittance](07-remittance.md) |
| Send money back to someone who paid you | [Refunds](08-refunds.md) |
| Pay in a different currency from the one you hold | [Convert first](09-fx-conversions.md), then pay out from the target account |

## Before you start

- [ ] You have done the [Quickstart](../01-get-started/03-quickstart.md), so `MG_BASE_URL` and `MG_API_KEY` are set in your terminal. If a call returns `401`, check that `MG_API_KEY` holds the whole key and that it hasn't been revoked ([When a request returns 401](../01-get-started/06-authentication.md#when-a-request-returns-401)).
- [ ] Your API user has the *initiate payment* or *approve payment* permission (`can_initiate_payment` or `can_approve_payment` in `permissions` of the current user). Without either, create calls return `403`. Getting a fee quote needs *initiate payment*.
- [ ] You have an **active source account** in the currency you will send. Find it with [List accounts](api:listAccounts): use one of its account numbers (`fundingDetails[].accountNumber`) as `sourceAccountNumber`, or its `accountId` as `sourceAccountId`. The Quickstart saved an NGN one in `MG_ACCOUNT_NUMBER`.
- [ ] The account's available balance covers the amount **plus the fee** (see [Fees](#fees)). Check it with [Get an account balance](api:getAccountBalance). In the sandbox, add money with [Simulate an inflow](api:simulateInflow).
- [ ] Your [webhook URL is registered](../01-get-started/03-quickstart.md), and your endpoint [verifies signatures](../03-webhooks-and-emails/03-verifying-signatures.md). The result of a payout arrives there.
- [ ] **Paying for a customer?** You have the customer ID (`entityId` from [Create a customer](api:createSubClient)), and the source account belongs to that customer ([List accounts](api:listAccounts) with `onBehalfOf`). See [Acting for customers](../02-concepts/06-acting-for-customers.md).
- [ ] **USD to someone else?** You need supporting documents (for example an invoice) when the beneficiary's account name doesn't match your source account's name. Ask Multigate how to upload them; you pass their IDs in `assets`.

## How a payout works

The create call only **checks** and **records** the transfer, then answers within seconds. Everything else happens after the response:

1. **Checks (during the call).** Multigate checks the source account, the beneficiary, the currency, the amount limits and the fee (and your balance, for a large NGN transfer that is split, or a linked-account source). If any check fails, you get `400` and nothing is created.
2. **Approval (if your business uses it).** The transfer waits for an approver in your business. See [Approvals](#approvals).
3. **Screening.** Multigate checks the payment against its compliance and risk rules. Most payments clear in seconds; some are held for manual review.
4. **Balance check and debit.** Multigate checks that your available balance covers the amount plus the fee, then debits it. If the balance is too low, the transfer ends `failed` (`responseCode` `22`) with a `transfer_failed` webhook, and nothing is debited. Your balance isn't reserved before this point.
5. **Payment.** The money is sent to the beneficiary's bank on the right rail.
6. **Result.** You receive a `transfer_completed` or `transfer_failed` webhook.

```mermaid
sequenceDiagram
    autonumber
    participant You as Your backend
    participant MG as Multigate
    participant Bank as Beneficiary bank
    You->>MG: POST /transfer/name-enquiry (optional, local)
    MG-->>You: Account name
    You->>MG: GET /transfer/get-transfer-fee (optional)
    MG-->>You: Fee
    You->>MG: POST /transfer/single-transfer (uniqueRequestId)
    Note over MG: Check account, beneficiary, limits and fee
    MG-->>You: 200 OK, status pending, pending_approval or processing
    Note over MG: Your approval rules, then compliance screening
    Note over MG: Check balance, then debit amount and fee
    MG->>Bank: Send payment on the chosen rail
    Bank-->>MG: Result
    MG-)You: Webhook transfer_completed or transfer_failed
```

## Choose your request

Every request has the same core fields. What changes is how you describe the beneficiary.

| Your case | `sourceCurrencyIso3Code` | Beneficiary | `beneficiaryDetails.bankBicOrCode` | Extra fields | Example in [Send a payout](#send-a-payout) |
|---|---|---|---|---|---|
| Pay a Nigerian bank account in naira | `NGN` | Full `beneficiaryDetails` | Local bank code from [List banks](api:listBanks) (`countryIso3Code=NGA`) | `accountNumber` must be the 10-digit NUBAN | *NGN local* tab |
| Pay a local account in GHS, XOF or ETB | That currency | Full `beneficiaryDetails` | Local bank code for the beneficiary's country | `countryIso3Code` must be the bank's country | *Other local currency* tab |
| Pay abroad in USD, EUR, GBP… | That currency | Full `beneficiaryDetails` | The bank's BIC | `postCode`; `chargeType` for SWIFT; `intermediaryBank` if the bank needs one; `assets` for USD to someone else | *International* tab |
| Pay someone you've paid or saved before | Any | `beneficiaryId` only | Not sent | None | *Saved beneficiary* tab |
| Pay from a customer's account | Any | Either of the above | As above | `onBehalfOf` = customer ID | *For a customer* tab |

The beneficiary's currency must be the source account's currency. To pay in another currency, [convert](09-fx-conversions.md) first.

### Request fields

| Field | Required | Where the value comes from |
|---|---|---|
| `uniqueRequestId` | Yes | You generate it: one per payout, unique across **all** of Multigate. Use a UUID with your own prefix, for example `acme_po_6f1c…`. Store it before you call. |
| `reference` | Yes | Your description of the payment, 5 to 100 characters. The beneficiary sees the first 36 characters as the narration. |
| `amount` | Yes | Amount the beneficiary receives, in major units of the source currency (`250000` is 250,000.00 NGN; `1200.50` is 1,200.50 USD). At most 2 decimals. |
| `sourceCurrencyIso3Code` | Yes | The source account's currency, upper case (`NGN`). |
| `sourceAccountNumber` | One of these two | An account number of your source account, from [List accounts](api:listAccounts). |
| `sourceAccountId` | | The source account's `accountId`, from [List accounts](api:listAccounts). |
| `beneficiaryDetails` | One of these two | The full bank details. Multigate saves them as a beneficiary (or reuses the matching saved one). |
| `beneficiaryId` | | A saved beneficiary's ID: `beneficiary.entityId` from [Create a beneficiary](api:createBeneficiary), or `beneficiaryDetails.entityId` from an earlier transfer. |
| `onBehalfOf` | Only for a customer | The customer ID. Leave it out to pay from your own business's account. |
| `chargeType` | SWIFT only | Who pays the banks' SWIFT charges: `OUR` (you), `SHA` (shared, the default) or `BEN` (the beneficiary). |
| `paymentMethod` | No | Leave it out: Multigate picks the rail. See [Which rail is used](#which-rail-is-used). |
| `assets` | USD to someone else | IDs of uploaded supporting documents. |
| `clientReference` | No | Your own reference, stored with the transfer. |

`beneficiaryDetails` needs `name`, `accountName`, `accountNumber`, `bankBicOrCode`, `currencyIso3Code`, `countryIso3Code`, `addressLine1`, `city` and `state`. The full list, with lengths, is in [Create a transfer](api:createSingleTransfer).

## Send a payout

<Steps>
<Step title="Check who you're paying">

For a local account you haven't paid before, ask the bank for the name on the account with [Look up an account name](api:nameEnquiry), and compare it with the name your user expects. This catches typing mistakes before money moves.

Find the bank code first:

```bash
curl -s "$MG_BASE_URL/payment-service/api/v1/banks?countryIso3Code=NGA&bankName=guaranty" \
  -H "mp-api-key: $MG_API_KEY" \
  | jq '.data[] | {bankName, bankCode, swift_code, isActive}'
```

Then look up the account:

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/name-enquiry" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "bankCode": "058", "bankAccountNumber": "0123456789", "currencyIso3Code": "NGN", "countryIso3Code": "NGA" }'
```

```json
{
  "message": "Successful",
  "responseCode": "00",
  "responseMessage": "Successful",
  "status_code": "00",
  "status_desc": "successful",
  "bankAccountName": "ADA OKAFOR",
  "bankAccountNumber": "0123456789",
  "bankName": "Guaranty Trust Bank",
  "accountCurrency": "NGN"
}
```

The lookup answers with HTTP `200` even when the account isn't found. Only `responseCode` `00` means found; anything else means don't pay that account yet.

</Step>
<Step title="Show the fee">

```bash
curl -s "$MG_BASE_URL/payment-service/api/v1/transfer/get-transfer-fee?amount=250000&sourceCurrencyIso3Code=NGN&sourceAccountNumber=$MG_ACCOUNT_NUMBER" \
  -H "mp-api-key: $MG_API_KEY"
```

```json
{
  "message": "Successful",
  "feeAmount": 50,
  "feeCurrency": "NGN",
  "isFeePercentage": false,
  "transactionCharge": 50,
  "beneficiaryWillReceive": 250000
}
```

Show your user `amount + feeAmount`: that is what leaves the account. For NGN amounts above 100,000,000, use [Validate a transfer](api:validateTransfer) instead: its `splitPreview.projectedTotalFee` is the exact fee for a [split transfer](#large-ngn-transfers-are-split).

</Step>
<Step title="Generate and store your uniqueRequestId">

Generate a new ID for this payout and save it in your database **before** you call Multigate, with your payout's status set to "sending". If the call times out, this ID is how you find out what happened.

```bash
export MG_REQUEST_ID="acme_po_$(uuidgen | tr 'A-Z' 'a-z')"
```

Replace `acme` with a short prefix for your business: the ID must be unique across every Multigate client, not only yours.

</Step>
<Step title="Create the transfer">

Pick the tab for your case. Each request is complete: every required field is there.

<Tabs>
<Tab title="NGN local">

Pay a Nigerian bank account in naira from your NGN account.

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "uniqueRequestId": "$MG_REQUEST_ID",
  "reference": "Invoice 2026-118",
  "amount": 250000,
  "sourceCurrencyIso3Code": "NGN",
  "sourceAccountNumber": "$MG_ACCOUNT_NUMBER",
  "beneficiaryDetails": {
    "name": "Ada Okafor",
    "accountName": "ADA OKAFOR",
    "accountNumber": "0123456789",
    "bankBicOrCode": "058",
    "currencyIso3Code": "NGN",
    "countryIso3Code": "NGA",
    "addressLine1": "12 Marina Road",
    "city": "Lagos",
    "state": "Lagos"
  }
}
EOF
```

- `accountNumber` must be exactly 10 digits. See the [warning in Errors](#errors).
- Use the name from the name enquiry as `accountName`.
- NGN amounts above 100,000,000 are [split into several payments](#large-ngn-transfers-are-split).

</Tab>
<Tab title="Other local currency">

Pay a local account in GHS, XOF or ETB from an account in the same currency. The shape is the same as NGN; the bank code comes from [List banks](api:listBanks) for the beneficiary's country (here `countryIso3Code=GHA`). Here `MG_GHS_ACCOUNT_NUMBER` holds an account number of your GHS account.

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "uniqueRequestId": "$MG_REQUEST_ID",
  "reference": "Supplier payment Oct",
  "amount": 4800,
  "sourceCurrencyIso3Code": "GHS",
  "sourceAccountNumber": "$MG_GHS_ACCOUNT_NUMBER",
  "beneficiaryDetails": {
    "name": "Kofi Mensah",
    "accountName": "Kofi Mensah",
    "accountNumber": "1441000123456",
    "bankBicOrCode": "130100",
    "currencyIso3Code": "GHS",
    "countryIso3Code": "GHA",
    "addressLine1": "5 Independence Avenue",
    "city": "Accra",
    "state": "Greater Accra"
  }
}
EOF
```

- `countryIso3Code` must be the bank's country: local bank codes repeat across countries.
- Multigate doesn't check these account numbers with the bank before paying, so check them with your user.

</Tab>
<Tab title="International (SWIFT)">

Pay a bank abroad. Here a German supplier is paid in USD from your USD account (`MG_USD_ACCOUNT_NUMBER`).

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "uniqueRequestId": "$MG_REQUEST_ID",
  "reference": "PO 7781 Lumen Trading",
  "amount": 18000,
  "sourceCurrencyIso3Code": "USD",
  "sourceAccountNumber": "$MG_USD_ACCOUNT_NUMBER",
  "chargeType": "SHA",
  "assets": ["3c9d7e21-5b4a-4f6e-8a1d-2e7f9c0b6a51"],
  "beneficiaryDetails": {
    "name": "Lumen Trading GmbH",
    "accountName": "Lumen Trading GmbH",
    "accountNumber": "DE89370400440532013000",
    "bankBicOrCode": "COBADEFFXXX",
    "currencyIso3Code": "USD",
    "countryIso3Code": "DEU",
    "addressLine1": "Friedrichstrasse 10",
    "city": "Berlin",
    "state": "Berlin",
    "postCode": "10117",
    "intermediaryBank": {
      "bankName": "Citibank N.A.",
      "bic": "CITIUS33XXX",
      "countryIso3Code": "USA"
    }
  }
}
EOF
```

- `bankBicOrCode` is the BIC. It must be in Multigate's bank list; an unlisted 11-character BIC is matched on its first 8 characters.
- `accountNumber` is the account number or IBAN, letters and digits only.
- `intermediaryBank` is the correspondent bank that some beneficiary banks need for USD. Leave it out if the beneficiary didn't give you one. In requests its fields are `bankName`, `bic` and `countryIso3Code`; transfer webhooks return it as `bankName`, `bankCode` and `swiftCode`.
- `assets` is required for USD only when `beneficiaryDetails.accountName` doesn't match your source account's name (a *third-party* payment). Leave it out when paying your own account abroad.
- EUR, GBP and other foreign currencies use the same shape. Some go through a local rail abroad (for example SEPA for EUR) instead of SWIFT: see [Which rail is used](#which-rail-is-used).
- USD payouts are completed by Multigate's operations team: they stay `processing` until then.

</Tab>
<Tab title="Saved beneficiary">

Pay a beneficiary you saved earlier. Put its ID in `MG_BENEFICIARY_ID`: `beneficiary.entityId` from [Create a beneficiary](api:createBeneficiary), or `beneficiaryDetails.entityId` from an earlier transfer's response.

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "uniqueRequestId": "$MG_REQUEST_ID",
  "reference": "Invoice 2026-119",
  "amount": 75000,
  "sourceCurrencyIso3Code": "NGN",
  "sourceAccountNumber": "$MG_ACCOUNT_NUMBER",
  "beneficiaryId": "$MG_BENEFICIARY_ID"
}
EOF
```

- Send `beneficiaryId` **or** `beneficiaryDetails`, never both: both together fail validation.
- The beneficiary must be in the source account's currency, and belong to the same business or customer as the source account.

</Tab>
<Tab title="For a customer">

Pay from one of your customer's accounts. `MG_CUSTOMER_ID` is the customer's `entityId` from [Create a customer](api:createSubClient); `MG_CUSTOMER_ACCOUNT_NUMBER` is an account number from [List accounts](api:listAccounts) called with `onBehalfOf=$MG_CUSTOMER_ID`.

```bash
curl -s -X POST "$MG_BASE_URL/payment-service/api/v1/transfer/single-transfer" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "uniqueRequestId": "$MG_REQUEST_ID",
  "reference": "Kola Stores payroll Oct",
  "amount": 120000,
  "sourceCurrencyIso3Code": "NGN",
  "sourceAccountNumber": "$MG_CUSTOMER_ACCOUNT_NUMBER",
  "onBehalfOf": "$MG_CUSTOMER_ID",
  "beneficiaryDetails": {
    "name": "Tunde Bello",
    "accountName": "TUNDE BELLO",
    "accountNumber": "0234567891",
    "bankBicOrCode": "058",
    "currencyIso3Code": "NGN",
    "countryIso3Code": "NGA",
    "addressLine1": "3 Allen Avenue",
    "city": "Ikeja",
    "state": "Lagos"
  }
}
EOF
```

- The source account must belong to that customer ("Invalid Source Account Owner" otherwise).
- The beneficiary is saved for the customer, not for your business. A `beneficiaryId` must also be the customer's (saved with `subClientId`).
- The customer must be `active`. A newly approved customer can take a few minutes to become usable here ("Invalid sub onBehalfOf" until then).
- Webhooks still come to **your** URL; `onBehalfOf` in the payload tells you which customer.

</Tab>
</Tabs>

A successful call returns `200` with the transfer:

```json
{
  "message": "Successful",
  "transactionId": "PMT-261010-093000482913",
  "uniqueRequestId": "acme_po_6f1c2a90-3b7d-4e8a-9c51-0d2e4f6a8b13",
  "reference": "Invoice 2026-118",
  "status": "pending",
  "responseCode": null,
  "responseMessage": "",
  "amount": "250000.00",
  "charge": "50.00",
  "totalAmount": "250050.00",
  "sourceAccountNumber": "1029384756",
  "sourceCurrencyIso3Code": "NGN",
  "beneficiaryId": null,
  "beneficiaryDetails": {
    "entityId": "Bn4kQ8rT2vX6yZ1aC3dEf",
    "accountName": "ADA OKAFOR",
    "accountNumber": "0123456789",
    "bankName": "Guaranty Trust Bank",
    "bankBicOrCode": "058",
    "currencyIso3Code": "NGN",
    "countryIso3Code": "NGA"
  },
  "transactionType": "DR",
  "createdAt": "2026-10-10T09:30:00.482Z"
}
```

The response is shortened here. **Store these fields** with your payout:

| Field | Why |
|---|---|
| `transactionId` | Multigate's ID for the transfer (`PMT-…`). Support and receipts use it. |
| `status`, `responseCode` | Where the transfer is now. `pending` with `responseCode` `null` means it is being screened. |
| `charge`, `totalAmount` | The fee, and what will be debited (`amount + charge`). Amounts are decimal strings: parse them as decimals, never as floating-point numbers. |
| `beneficiaryDetails.entityId` | The saved beneficiary. Send it as `beneficiaryId` next time. |

`sourceAccountNumber` in the response can differ from the number you sent: it shows the account Multigate debits. `beneficiaryId` is `null` when you sent `beneficiaryDetails`.

</Step>
</Steps>

## After you send

The create response is never the final result. Follow these steps for every payout.

<Steps>
<Step title="Record the first status">

Save `transactionId` and `status` from the create response. The status is one of:

| `status` | `responseCode` | Meaning | What you do |
|---|---|---|---|
| `pending` | `null` | Being screened. | Wait for the webhook. |
| `pending_approval` | `42` | Waiting for an approver in your business. | Ask your approver to act in the dashboard, then wait for the webhook. |
| `processing` | `01` | Sent for payment. | Wait for the webhook. |

Show your user "processing", never "paid", at this point.

</Step>
<Step title="Handle the webhook">

When the payout finishes, Multigate sends `transfer` / `transfer_completed` or `transfer` / `transfer_failed` to your webhook URL. Verify the signature first ([Verifying signatures](../03-webhooks-and-emails/03-verifying-signatures.md)), answer `200` quickly, then process the event:

```javascript
// Called after the signature check, with the parsed webhook body.
async function handleTransferEvent(event) {
  const { notification_type: type, notification } = event.header;
  if (type !== 'transfer') return;
  if (notification !== 'transfer_completed' && notification !== 'transfer_failed') return;

  const { uniqueRequestId, transactionId, responseCode, responseMessage } = event.body;
  // A split NGN transfer sends one event per part: acme_po_…-1, acme_po_…-2 (see "Large NGN transfers").
  const payout = await db.payouts.findByUniqueRequestId(uniqueRequestId);
  if (!payout) return; // not one of yours

  // Webhooks can arrive twice or out of order: never change a payout that is already final.
  if (['completed', 'failed', 'rejected'].includes(payout.status)) return;

  if (notification === 'transfer_completed') {
    await db.payouts.update(payout.id, { status: 'completed', transactionId });
  } else {
    await db.payouts.update(payout.id, { status: 'failed', transactionId, responseCode, failureReason: responseMessage });
  }
}
```

- `transfer_completed` means the beneficiary has been paid. Mark it paid.
- `transfer_failed` means it wasn't paid; for a bank or partner failure the debit is reversed to your account. Show `responseMessage` to your user. To try again, create a **new** transfer with a **new** `uniqueRequestId`.
- Payload fields are in the [Event catalogue](../03-webhooks-and-emails/02-event-catalogue.md).

</Step>
<Step title="Poll when no webhook arrives">

Some final outcomes send **no webhook**: `rejected`, a compliance decline, and a debit that couldn't be posted (see [Transfer statuses](#transfer-statuses)). Webhook delivery also stops after 5 failed retries. So run a job that checks every payout still non-final in your system 30 minutes after it was created, then every hour:

```bash
curl -s "$MG_BASE_URL/payment-service/api/v1/transaction/report?uniqueRequestId=$MG_REQUEST_ID,$MG_REQUEST_ID-1" \
  -H "mp-api-key: $MG_API_KEY" \
  | jq '.data[] | {uniqueRequestId, transactionId, status, responseCode, responseMessage}'
```

```json
{
  "uniqueRequestId": "acme_po_6f1c2a90-3b7d-4e8a-9c51-0d2e4f6a8b13",
  "transactionId": "PMT-261010-093000482913",
  "status": "completed",
  "responseCode": "00",
  "responseMessage": "Successful"
}
```

Apply a final status (`completed`, `failed`, `rejected`) exactly as you would from a webhook. The `-1` also finds a [split NGN transfer](#large-ngn-transfers-are-split). In the sandbox, results can take 15 minutes or more; don't poll more often than every few minutes.

If a payout is still not final after 24 hours, or stays `pending` with `responseCode` `null` for more than a few minutes, contact support with its `transactionId`.

</Step>
<Step title="If the create call itself gets no answer">

A timeout, a `5xx` or a dropped connection on create means the outcome is **unknown**: the transfer may or may not have been created. The create call waits only for the checks (not for the bank), and the production gateway gives up after 60 seconds.

1. **Don't** send a new transfer with a new `uniqueRequestId`: that can pay twice.
2. Look the payout up with the poll command above (`?uniqueRequestId=<yours>,<yours>-1`).
3. **Found:** the transfer exists. Save its `transactionId` and continue from step 1.
4. **Not found:** wait a minute and look again. Still not found: send the **same** request again with the **same** `uniqueRequestId`. If the first attempt was created in the meantime, you get `400` "Unique request ID already exist" and nothing is paid twice; look it up again.
5. If the same `uniqueRequestId` is sent twice at the same moment (for example by two of your servers), only one transfer is created and the other request gets `400` "Unique request ID already exist". Still let one worker own each payment, so you know which answer to keep.

See [Idempotency](../02-concepts/03-idempotency.md) for the full pattern.

</Step>
<Step title="Give your user a receipt">

For a `completed` transfer, [Download a transfer receipt](api:downloadTransferReceipt) returns a PDF:

```bash
curl -s -o receipt.pdf "$MG_BASE_URL/payment-service/api/v1/transaction/download-transfer-receipt/PMT-261010-093000482913" \
  -H "mp-api-key: $MG_API_KEY"
```

</Step>
</Steps>

## Transfer statuses

```mermaid
stateDiagram-v2
    direction LR
    [*] --> pending: created, screening
    [*] --> pending_approval: needs an approver
    [*] --> processing: not screened (linked account)
    pending --> processing: screening cleared
    pending --> failed: declined by compliance (43)
    pending_approval --> processing: approved (05), then screened
    pending_approval --> rejected: declined by approver
    processing --> pending: bank unreachable, retrying (31)
    processing --> completed: paid
    processing --> failed: bank, partner or debit failure
    processing --> rejected: declined in Multigate review
    completed --> [*]
    failed --> [*]
    rejected --> [*]
```

| Status | Final? | `responseCode` | Meaning | Webhook | What to do |
|---|---|---|---|---|---|
| `pending` | No | `null` | Being screened. | None | Wait. |
| `pending` | No | `31` | The bank or partner couldn't be reached. Multigate retries a few times. | None | Wait. Contact support if it stays `pending` for hours. |
| `pending` | No | other | A manually settled payout (for example USD) is being completed by Multigate's operations team. | None | Wait for the webhook. |
| `pending_approval` | No | `42` | Waiting for an approver in your business. | None | Ask your approver to act in the dashboard. |
| `processing` | No | `01` (or `05` just after approval) | Sent for payment. USD payouts, and some other currencies, are completed by Multigate's operations team and stay `processing` until then. With some banks, a reported failure also leaves the payout `processing` while Multigate's operations team confirms it: those banks sometimes report a payment as failed when it went through, or before the money is back, so Multigate marks it `failed` (and returns the money to your account) only once that is checked. Where the bank's failure is definitive, the payout fails straight away. | None | Wait for the webhook, and poll payouts that stay `processing` unusually long; contact support with the `transactionId` if one doesn't move. |
| `completed` | **Yes** | `00` | The beneficiary has been paid. | `transfer_completed` | Mark it paid. |
| `failed` | **Yes** | Varies | Not paid. For a bank or partner failure the debit is reversed to your account. | `transfer_failed`, with the exceptions below | Read `responseMessage`. Fix and send a **new** transfer if appropriate. |
| `failed` | **Yes** | `43` | Declined by Multigate compliance ("Payment ongoing review") before any debit. | None | Contact support. |
| `failed` | **Yes** | `22` | Your balance (or separate fee account) didn't cover amount plus fee when Multigate checked it, just before the debit. Nothing was debited. | `transfer_failed` | Fund the account, then send a new transfer with a new `uniqueRequestId`. |
| `failed` | **Yes** | `03` | The debit couldn't be posted (for example, your balance dropped after Multigate checked it), or the balance couldn't be read. Nothing was paid. | None when the debit failed; `transfer_failed` when the balance couldn't be read | Check your balance, then send a new transfer. |
| `rejected` | **Yes** | `04` | Declined by an approver in your business (nothing was debited), or by Multigate's compliance or settlement review. | None | Poll to detect it. If it was `processing` before, contact support about the debit. |
| `pending_security_review` | No | `43` | Web-app transfers only, never transfers created with API credentials, and today only in the sandbox: Multigate's security check held the transfer for its compliance team. Nothing has been screened, approved or debited. Webhooks show it as `pending`. | None | Nothing to do from the API. Released, it continues like a new transfer; otherwise it becomes `cancelled`. |
| `cancelled` | **Yes** | `04` | A held web-app transfer that Multigate declined ("Security review declined") or didn't review within 24 hours ("Security review expired"). Nothing was debited. | None | Poll to detect it. |

`responseCode` and `responseMessage` give more detail. Failures reported by the bank or partner carry their own code, for example `26` ("Invalid Account Number").

If Multigate can't confirm what happened to a payment at the bank (for example the call to the bank was interrupted, or the bank accepted it and later reported it failed), the transfer stays `pending` or `processing` and its `responseMessage` reads "CBI provider dispatch outcome is unknown" (or "CBI reference belongs to another request; reconcile before resolution"). Multigate's operations team reconciles it with the bank; no webhook is sent until then. Don't send the payment again with a new `uniqueRequestId`: that could pay twice.

:::caution[Sandbox: slower, and sometimes random]
In the sandbox, Multigate checks pending payouts with the bank every 15 minutes (every 15 seconds in production, at first), so `processing` can last 15 minutes or more. Test banks and simulated partners decide the outcome, sometimes at random. Test that you handle every status rather than expecting one. See [Sandbox vs production](../01-get-started/05-sandbox-vs-production.md#payout-results-and-timing).
:::

:::warning[Some final outcomes send no webhook]
`rejected` never sends a webhook, and some failures (a compliance decline, code `43`; a debit that couldn't be posted; a Nigerian account number that isn't 10 digits) end in `failed` without one. Poll any transfer that is still non-final in your system after the time you expect, with [Find transfers](api:listTransfers).
:::

Once a transfer is `processing`, Multigate keeps asking the bank for the result:

| Age of the transfer | Production checks |
|---|---|
| First 30 minutes | Every 15 seconds |
| 30 minutes to 6 hours | Every 15 minutes |
| 6 to 24 hours | Every 30 minutes |

## Fees

- **On top of the amount.** The beneficiary receives the full `amount`; your account is debited `amount + charge`. The fee is in the source currency.
- **Quoted before, charged after.** [Get a transfer fee](api:getTransferFee) quotes it; the create response shows it as `charge`. It is debited with the amount, after screening and approval.
- **Separate fee account.** If Multigate has set up a separate fee account for your account, the fee is taken from there, and that account must cover it ("Insufficient funds in fee account" otherwise).
- **Between Multigate accounts.** A transfer to another Multigate account is free, unless the receiving business has opted in to a fee. It skips screening and approval, and completes as soon as Multigate's ledger records it.
- **On your statement** the fee is a separate `Fee` line (ID `FEE-…`) with your `uniqueRequestId` as `customReference`. See [Balances and statements](10-balances-and-statements.md#what-each-statement-line-is).
- **SWIFT bank charges** are separate from Multigate's fee: `chargeType` decides who pays the banks along the way.

## Which rail is used

If you don't set `paymentMethod`, Multigate chooses:

```mermaid
flowchart TD
    start[Transfer] --> chosen{paymentMethod set?}
    chosen -->|Yes| use[Use it, if it is configured for the currency]
    chosen -->|No| linked{Source is a SWIFT linked account?}
    linked -->|Yes| swift[SWIFT]
    linked -->|No| partner{Currency paid out through a payout partner?}
    partner -->|Yes| prail[The partner's local rail, for example SEPA for EUR or FPS for GBP, otherwise SWIFT]
    partner -->|No| lcy{NGN, GHS, ETB, CNY or XOF?}
    lcy -->|Yes| local[The currency's configured local rail, NIP for NGN]
    lcy -->|No| other[The currency's configured rail, otherwise SWIFT]
    local --> big{NGN above 100,000,000?}
    big -->|Yes| split[Split into several NIP payments]
```

- Amounts above the currency's maximum are refused ("Maximum amount exceeded"), except NGN on NIP, which is split into at most 100 parts (larger amounts are refused). [List payment methods](api:listPaymentMethods) shows the configured limits and indicative fees.
- Single transfers don't take a purpose code. [Purpose codes](api:listPurposeCodes) are used in [bulk payout](06-bulk-payouts.md) files.
- The transfer object doesn't show which rail was used.

## Large NGN transfers are split

NGN transfers above the per-payment limit of the local instant rail (100,000,000 NGN unless Multigate configures otherwise) are sent as several payments. [Validate a transfer](api:validateTransfer) tells you in advance (`splitPreview`), with the exact total fee.

When a transfer is split:

- The response includes `splitSummary`, listing each part with its own `reference` (transfer ID) and `uniqueRequestId`: yours with `-1`, `-2`, … added. No part is stored under your original `uniqueRequestId`, so look parts up with `?uniqueRequestId=<yours>-1,<yours>-2`. Your original ID stays reserved: sending it again is refused with "Unique request ID already exist".
- The fee is quoted once for the whole amount and shared across the parts in proportion to their amounts.
- At most 100 parts: an NGN transfer above 100 times the limit (10,000,000,000 NGN at the default limit) is refused with `400` "Maximum amount exceeded".
- Each part is its own transfer: it appears separately in [Find transfers](api:listTransfers) and sends its own webhook.
- Your transfer is complete when **every** part is `completed`. Parts can end differently; handle a partly paid transfer.

## Approvals

If your business uses maker-checker approval, a transfer starts in `pending_approval` unless the API user that creates it may also approve it:

- **Without an approval workflow:** a transfer goes straight to screening and payment only when the API user has both the *initiate payment* and the *approve payment* permissions. Otherwise it waits for an approver.
- **With an approval workflow** (rules set up in the dashboard, by amount and currency): it goes straight through only when the API user is an approver for that amount and currency and the workflow needs one approval ("first to approve"). Otherwise it waits for the approvers the workflow names.

An approver approves or declines it in the Multigate dashboard:

- Approved: the transfer moves to `processing` (`responseCode` `05`), is screened, then pays as normal. Your balance is checked just before the debit.
- Declined: it moves to `rejected` (`responseCode` `04`, no webhook) and the creator gets a "Transaction Declined" email with the reason.

To send payments straight to processing, ask your administrator to give your API user both the *initiate* and the *approve* payment permissions, or to make it an approver in your workflow.

## Errors

:::danger[Check Nigerian account numbers before you send]
NGN account numbers must be exactly **10 digits** (NUBAN). Today a transfer to a shorter or longer number is accepted, then fails without a webhook, and the debit isn't reversed automatically. Validate the length in your system, and use [Look up an account name](api:nameEnquiry), before creating the transfer.
:::

A `400` on create means nothing was created. Fix the request and send it again (you can keep the same `uniqueRequestId`, because the first attempt didn't happen). The error body is `[{ "errors": { "message": "…" } }]`, or for a missing field a `failed_validation` body with an `errors` list (see [Errors](../02-concepts/02-errors.md)).

| Message | Cause | Fix |
|---|---|---|
| Unique request ID already exist | This `uniqueRequestId` was used before, by you or by another Multigate client. | Look up the existing transfer. For a new payment, use a new ID with your own prefix. |
| Insufficient Balance | The account can't cover amount plus fee. Only from [Validate a transfer](api:validateTransfer), and for a large NGN transfer that is split, a linked-account source or a receivable payout; other transfers are accepted and end `failed` with `responseCode` `22`. See [When your balance is checked](#when-your-balance-is-checked). | Fund the account, or send less. |
| Insufficient funds in fee account | Your separate fee account can't cover the fee. Same timing as "Insufficient Balance". | Fund the fee account. |
| Source Account Not Found / Account not ready | Wrong account number or currency, or the account isn't active yet. | Check the account number, currency and status with [List accounts](api:listAccounts). |
| Invalid Account Type | The account found can't be debited (it isn't a wallet or a linked account). | Use your wallet's account number, or send its `accountId` as `sourceAccountId`. |
| Invalid Source Account Owner | The account belongs to another business or customer. | Use your account, or set `onBehalfOf` to the customer that owns it. |
| Invalid onBehalfOf / Invalid sub onBehalfOf | The customer isn't yours, or isn't active (yet). | Check the customer ID and status. |
| Beneficiary Not Found / Beneficiary account not ready | Wrong `beneficiaryId`, or the saved beneficiary isn't active. | Check the beneficiary, or send `beneficiaryDetails`. |
| Invalid Destination Account Owner | The saved beneficiary belongs to a different business or customer than the source account. | Use a beneficiary saved for the same business or customer. |
| Bank not found | Unknown bank code or BIC. | Check with [List banks](api:listBanks). |
| Currency mismatch | The beneficiary's currency differs from the source currency. | Convert first, then pay from the other account. |
| Source and Destination account cannot be the same | You're paying the source account itself. | Check the beneficiary account number. |
| Minimum amount not met / Maximum amount exceeded | Outside the limits for this currency. | Check [List payment methods](api:listPaymentMethods). |
| Supporting documents are required for 3rd party USD transfers. | USD payment to someone whose name doesn't match your account name. | Upload the documents and pass their IDs in `assets`. |
| failed_validation | A field is missing or invalid. | Read the `errors` list: it names each field. |
| Unable to determine the fee… (HTTP `503`) | The fee service was briefly unavailable. Nothing was created. | Retry with the same `uniqueRequestId`. |

The full list is in [Create a transfer](api:createSingleTransfer).

### When your balance is checked

So that creating a transfer is fast, Multigate doesn't check your balance when you create most transfers. It checks it just before it debits your account, after screening and approval:

| Transfer | Balance checked | If the balance is short |
|---|---|---|
| Most transfers from a wallet | When the payout starts | The transfer is created (`200`), then ends `failed` with `responseCode` `22` before any money moves. You receive [`transfer` / `transfer_failed`](webhook:transfer.transfer_failed) and the failure email. |
| NGN transfers above 100,000,000 (split), payouts from a linked account, receivable payouts | On create | `400` "Insufficient Balance" and nothing is created. |

What this means for you:

- **Check before you send** when you need an immediate answer, for example to tell a user "insufficient funds" on screen. [Validate a transfer](api:validateTransfer) always checks the balance (amount plus fee).
- **Handle `transfer_failed` with `responseCode` `22`** as "not paid, nothing debited". Today its `responseMessage` is the raw key `insufficientBalance` (or `insufficientFeeBalance`), not a sentence, so match on the code. Fund the account, then send a **new** transfer with a new `uniqueRequestId`: the failed one keeps its ID.
- **The balance isn't reserved** between the check and the debit. Several payouts sent at once from the same account can each pass the check while together they need more than the balance, so keep your own running total of what you've sent.

## Common mistakes

- **A new `uniqueRequestId` on retry.** After a timeout, look the payout up first. A new ID can pay twice.
- **Treating the create response as the result.** `200` means "accepted", not "paid". Wait for the webhook or poll.
- **Relying on webhooks alone.** `rejected` and some failures send none. Poll non-final payouts.
- **Sending both `beneficiaryId` and `beneficiaryDetails`.** Send one.
- **Paying in a different currency from the source account.** Convert first.
- **Parsing amounts as floats.** Amounts are decimal strings in responses; use a decimal type.
- **Using a BIC for a local NGN payout, or a local code abroad.** Local payouts take the bank code; international ones take the BIC.

## Webhooks and emails

| Event | When |
|---|---|
| Webhook `transfer` / `transfer_completed` | The transfer (or each split part) is paid. |
| Webhook `transfer` / `transfer_failed` | It fails at the bank or partner, or your balance is short when the payout starts (`responseCode` `22`). Not sent for compliance declines, failed debits or `rejected` transfers. |
| Email "Transfer Notification" to the API user that created it | When it starts processing, and when it completes or fails. |
| Email "Transaction Declined" to the creator | An approver declines it. |

## Next

<CardGroup cols={2}>
<Card title="Bulk payouts" icon="lucide-files" href="06-bulk-payouts.md">
Pay hundreds of beneficiaries from one file.
</Card>
<Card title="Webhook events" icon="lucide-bell-ring" href="../03-webhooks-and-emails/02-event-catalogue.md">
Payload of every transfer event.
</Card>
</CardGroup>
