# Cross-border payouts

Pay suppliers, contractors or partners abroad from your local-currency balance: convert NGN to USD at a locked rate, then pay out by international bank transfer (SWIFT, the network banks use to send money abroad).

**Who it's for:** corporate enterprises and SMEs paying suppliers abroad, and fintechs offering international payouts.

## Architecture

```mermaid
flowchart LR
    you[Your backend] -->|1 quote and lock| fx[FX conversion]
    ngn[(Your NGN account)] -->|debited sourceAmount| fx
    fx -->|credited targetAmount| usd[(Your USD account)]
    you -->|2 create transfer| usd
    usd -->|SWIFT payment| bank([Supplier's bank abroad])
    fx -.->|trade_completed| hook[Your webhook endpoint]
    usd -.->|transfer_completed or transfer_failed| hook
```

```mermaid
sequenceDiagram
    autonumber
    participant You as Your backend
    participant MG as Multigate
    participant Bank as Supplier's bank abroad
    You->>MG: Get a transfer fee (USD account, supplier amount)
    You->>MG: Get a quote NGN to USD (amount = supplier amount + fee)
    MG-->>You: Quote id, buyRate, endTime (about 60 seconds)
    You->>MG: Lock the rate (quote id, NGN accountId, USD accountId)
    MG-->>You: status completed (or processing)
    MG-)You: trade / trade_completed
    You->>MG: Create a transfer from the USD account (SWIFT, chargeType, assets)
    MG-->>You: 200, status pending (screening)
    Note over MG: Screening, approval, debit, then payment
    MG->>Bank: SWIFT payment
    MG-)You: transfer / transfer_completed or transfer_failed
```

## Before you start

- [ ] A sandbox API key, and the variables `MG_BASE_URL`, `MG_API_KEY` and `MG_CLIENT_ID` set as in the [Quickstart](../01-get-started/03-quickstart.md).
- [ ] A webhook URL registered ([Webhooks overview](../03-webhooks-and-emails/01-overview.md)). Events that happen before you register it are never delivered.
- [ ] Your API user has the *lock rates* permission (to convert) and the *initiate payment* permission (to quote fees and pay). See [Approvals](../04-products/05-payouts.md#approvals) for whether its payments also need an approver.
- [ ] Multigate has set up partner banks for NGN and USD for your business, and you have accepted their terms of service in the dashboard.
- [ ] **Supporting documents.** A USD payout to an account whose name doesn't match your own account name is a *third-party payment* and needs supporting documents, such as the supplier's invoice. Paying a supplier is almost always third-party. The upload endpoint isn't public yet: ask Multigate how to upload documents and get their IDs.

:::warning[Known issue: no conversions for customers]
Today a quote or lock with `onBehalfOf` is refused with `400` "Invalid onBehalfOf id". If you pay out for your customers (sub-clients), convert in your own business's accounts until Multigate fixes this. See [FX conversions](../04-products/09-fx-conversions.md).
:::

## Build plan

<Steps>
<Step title="Open an NGN and a USD account">

Open one account in each currency with [Create an account](api:createAccount), each with its own `uniqueRequestId` (your unique ID for the request, so a retry isn't executed twice: see [Idempotency](../02-concepts/03-idempotency.md)). Follow [Accounts › Open an account](../04-products/02-accounts.md#open-an-account).

From each response, store:

- `account.accountId`: the conversion needs it as `sourceAccountId` (NGN) and `targetAccountId` (USD).
- An account number for payouts: the wallet's own number or one of its `fundingDetails[].accountNumber` values.

If an account comes back `processing`, wait for the [`account` / `account_opened`](webhook:account.account_opened) webhook. Some USD wallets have no virtual account (`fundingDetails` is empty); that's expected, because you fund them by conversion.

Fund the NGN account by bank transfer to its `fundingDetails` account number. In the sandbox, use [Simulate an inflow](api:simulateInflow).

</Step>
<Step title="Save the supplier as a beneficiary">

Save the supplier once with [Create a beneficiary](api:createBeneficiary), and keep `beneficiary.entityId` as their `beneficiaryId`. For USD, `bankBicOrCode` is the bank's SWIFT BIC (an 8- or 11-character bank code such as `COBADEFFXXX`). Send the address, city, state and a real `postCode` (it defaults to `000000`). See [Beneficiaries › Rules by currency](../04-products/04-beneficiaries.md#rules-by-currency).

You can skip this step and send `beneficiaryDetails` on the transfer instead: Multigate then saves the beneficiary for you.

</Step>
<Step title="Quote the payout fee">

Multigate's fee is charged to the USD account **on top of** the amount, so the USD account must hold both. Quote it with [Get a transfer fee](api:getTransferFee):

```bash
curl "$MG_BASE_URL/payment-service/api/v1/transfer/get-transfer-fee?amount=18000&sourceCurrencyIso3Code=USD&sourceAccountNumber=4410023391&beneficiaryId=Bn4kQ8rT2vX6yZ1aC3dEf" \
  -H "mp-api-key: $MG_API_KEY"
```

```json
{ "message": "Successful", "feeAmount": 25, "feeCurrency": "USD", "isFeePercentage": false, "transactionCharge": 25, "beneficiaryWillReceive": 18000 }
```

The fee shown is an example; yours comes from your pricing. You need `18000 + feeAmount` USD: here 18,025.

</Step>
<Step title="Convert NGN to USD">

Get a quote with [Get a quote](api:getFxQuote), where `amount` is what you want to **receive** in USD, then lock it with [Lock a rate](api:lockFxRate) before `endTime` (about 60 seconds). Both calls need the `entityId` header set to your client ID.

```bash
curl "$MG_BASE_URL/trade-service/api/v1/fx-rates/best-rate?sourceCurrency=NGN&targetCurrency=USD&amount=18025" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "entityId: $MG_CLIENT_ID"
```

Show your user `buyRate` (NGN per USD), store the quote `id`, then lock:

```bash
curl -X POST "$MG_BASE_URL/trade-service/api/v1/fx-rates/lock-rate" \
  -H "mp-api-key: $MG_API_KEY" \
  -H "entityId: $MG_CLIENT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "FX-261010-150000482913",
    "sourceAccountId": "Ac4mW8pZ2kR6tY1nQ7xVb",
    "targetAccountId": "Ac7tY2nQ5xVb9mW3pZ8kR",
    "amount": 18025,
    "reference": "acme_conv_20261010_0007"
  }'
```

- `status` `completed`: the USD account is credited. Go on.
- `status` `processing`: wait for [`trade` / `trade_completed`](webhook:trade.trade_completed), or poll [Get a conversion](api:getFxTrade) with the quote ID.
- Before paying out after a conversion that was `processing`, check that the USD account actually received `targetAmount` ([Get an account balance](api:getAccountBalance)): today `trade_completed` is also sent when the provider fails a conversion later.

Full rules, retries and errors: [FX conversions](../04-products/09-fx-conversions.md).

</Step>
<Step title="Create the SWIFT payout">

Generate a `uniqueRequestId`, store it, then call [Create a transfer](api:createSingleTransfer) from the USD account:

```bash
curl -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 '{
    "uniqueRequestId": "acme_po_20261010_0003",
    "reference": "Invoice LT-2026-118",
    "amount": 18000,
    "sourceCurrencyIso3Code": "USD",
    "sourceAccountNumber": "4410023391",
    "beneficiaryId": "Bn4kQ8rT2vX6yZ1aC3dEf",
    "paymentMethod": "SWIFT",
    "chargeType": "SHA",
    "assets": ["3c9d7e21-5b4a-4f6e-8a1d-2e7f9c0b6a51"]
  }'
```

- `assets`: the IDs of the supporting documents (see Before you start). Without them a third-party USD payout is refused with `400` "Supporting documents are required for 3rd party USD transfers."
- `chargeType`: who pays the banks' SWIFT charges (table below).
- With `beneficiaryDetails` instead of `beneficiaryId`, add `intermediaryBank` (`bankName`, `bic`, `countryIso3Code`) when the supplier's bank needs a correspondent bank.
- Store `transactionId` (`PMT-…`) with your `uniqueRequestId`. The response status is `pending` (being screened), `pending_approval` (waiting for your approver) or `processing`.

If the call times out or returns `5xx`, the outcome is unknown: follow [Payouts › After you send](../04-products/05-payouts.md#after-you-send) (step "If the create call itself gets no answer") and never retry with a new `uniqueRequestId`.

</Step>
<Step title="Confirm and send the receipt">

Wait for [`transfer` / `transfer_completed`](webhook:transfer.transfer_completed) or [`transfer_failed`](webhook:transfer.transfer_failed), matched by `uniqueRequestId`. USD payouts are completed by Multigate's operations team, so they stay `processing` longer than local transfers: show "processing" in your UI. If one isn't final after 24 hours, contact support with its `transactionId`.

When it's `completed`, give the supplier the PDF from [Download a transfer receipt](api:downloadTransferReceipt).

</Step>
</Steps>

## Choose who pays SWIFT charges

| `chargeType` | Effect |
|---|---|
| `OUR` | You pay all charges; the supplier receives the full amount. |
| `SHA` | Charges are shared; intermediary banks may deduct some. The default when you leave `chargeType` out. |
| `BEN` | The supplier pays the charges. |

`chargeType` isn't validated, so send one of these values exactly. Multigate's own fee is separate and always charged to your account on top of the amount.

## Data to store

| Field | From | Used for |
|---|---|---|
| NGN and USD `accountId` | [Create an account](api:createAccount) | `sourceAccountId` and `targetAccountId` on the lock |
| USD account number | the account's number or `fundingDetails` | `sourceAccountNumber` on the fee quote and the transfer |
| `beneficiaryId` | [Create a beneficiary](api:createBeneficiary) (`beneficiary.entityId`) | Paying the same supplier again |
| Quote `id` and your `reference` | [Get a quote](api:getFxQuote), your own | Retrying a lock, matching `trade_completed` |
| `sourceAmount`, `targetAmount`, `rate` | the lock response | Reconciling the conversion |
| `uniqueRequestId` (stored **before** the call) and `transactionId` | your own, then the transfer response | Matching webhooks, lookups, support |

## Webhooks to handle

| Event | What to do |
|---|---|
| [`trade` / `trade_completed`](webhook:trade.trade_completed) | Match by `id` (the quote ID). It can arrive before the lock response. Check the USD balance before paying out after a `processing` conversion. |
| [`transfer` / `transfer_completed`](webhook:transfer.transfer_completed) | Mark the payout paid; send the receipt. |
| [`transfer` / `transfer_failed`](webhook:transfer.transfer_failed) | Mark it failed and show `responseMessage`. A new attempt needs a new `uniqueRequestId`. |

Some outcomes send no webhook: a decline by your approver (`rejected`), a compliance decline (`failed`, `responseCode` `43`) and a debit that couldn't be posted (`failed`, `03`). Poll [Find transfers](api:listTransfers) `?uniqueRequestId=` for payouts that stay non-final. See [Outcomes without a webhook](../03-webhooks-and-emails/02-event-catalogue.md#outcomes-without-a-webhook).

## Reconciliation

Each day, pull [Get an account statement](api:getAccountStatement) for both accounts ([Balances and statements](../04-products/10-balances-and-statements.md#daily-reconciliation)):

- **NGN account:** a `Trade` debit of `sourceAmount` per conversion.
- **USD account:** a `Trade` credit of `targetAmount` per conversion, and a `Transfer` debit per payout with `customReference` = your `uniqueRequestId`, plus its fee.

Anything only in your records is usually a payout still in a non-final status; anything only in the statement is usually a webhook you didn't process.

## Things that trip people up

- The transfer currency must match the source account: you can't pay USD from an NGN account. Convert first.
- The balance isn't reserved when you create the transfer; it's checked just before the debit. Keep the USD in the account until the payout is final: if it's short at that check, the payout ends `failed` (`22`, with a webhook), and if it drops right after the check, `failed` (`03`) without one.
- In the sandbox an unfunded lock succeeds; production refuses it with `402` "Insufficient balance". Check the NGN balance before you lock. See [Sandbox vs production](../01-get-started/05-sandbox-vs-production.md#fx-conversions).
- Never lock a second quote for the same payout until you've confirmed the first didn't go through: a quote ID is the only retry key for a conversion.

## Go-live notes

- Agree USD payout pricing and the supporting-document process with Multigate.
- Decide with your admin who approves payouts (maker-checker: one person creates, another approves), and test the `pending_approval` path.
- Re-test an FX lock larger than your balance in production on day one and handle the `402`.
- Work through the [go-live checklist](../01-get-started/07-go-live-checklist.md).
