# Getting started

Hold balances in multiple currencies, pay recipients across borders and collect payments from your customers.

Source: https://www.migrapay.com/docs

<Callout>
  Start in Sandbox with a test key. Switch to a live key once your integration works and your business account is approved.
</Callout>

## Prerequisites

* A MigraPay business account. Sign up from the [business dashboard](/business/register).
* An API key. Create one in the dashboard under **Developers → API keys** and choose the scopes it needs. The key is shown only once, so store it in your server's secret manager.
* A server. API keys are secret and must never be sent from a browser or a mobile app.

## Quickstart

<Steps>
  <Step>
    ### Set your key and base URL

    Every endpoint lives under one base URL. Sandbox and Live share it: the key you send decides the environment.

    ```bash title="Base URL"
    https://business-api.migrapay.com/v1/api
    ```

    ```bash
    export MIGRAPAY_API_KEY="mpb_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ```
  </Step>

  <Step>
    ### Make your first request

    List your wallets. The key needs the `wallet.view` scope.

    <CodeBlockTabs defaultValue="cURL">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="cURL">
          cURL
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="Node.js">
          Node.js
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="Python">
          Python
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="cURL">
        ```bash
        curl https://business-api.migrapay.com/v1/api/wallets \
          -H "Authorization: Bearer $MIGRAPAY_API_KEY"
        ```
      </CodeBlockTab>

      <CodeBlockTab value="Node.js">
        ```js
        const res = await fetch('https://business-api.migrapay.com/v1/api/wallets', {
          headers: { Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}` },
        });
        const { data } = await res.json();
        console.log(data.wallets);
        ```
      </CodeBlockTab>

      <CodeBlockTab value="Python">
        ```python
        import os, requests

        res = requests.get(
            "https://business-api.migrapay.com/v1/api/wallets",
            headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
        )
        print(res.json()["data"]["wallets"])
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ```json title="200 OK"
    {
      "status": "success",
      "message": "Wallets retrieved successfully.",
      "data": {
        "wallets": [
          {
            "id": "3f6c2a10-8b1d-4e5f-9a7c-000000000001",
            "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
            "currency": "CAD",
            "country": "CA",
            "balance": 12584.5,
            "ledgerBalance": 12584.5,
            "primary": true,
            "enabled": true,
            "status": "active",
            "createdAt": "2026-08-01T10:15:30.000Z",
            "updatedAt": "2026-08-01T10:15:30.000Z"
          }
        ]
      }
    }
    ```
  </Step>

  <Step>
    ### Send a payout

    Save a recipient once, then pay them from a wallet. Here a CAD wallet pays a bank account in Nigeria; the conversion to NGN happens as part of the payout. The key needs `recipient.create` and `payment.create`.

    <CodeBlockTabs defaultValue="1. Create a recipient">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="1. Create a recipient">
          1\. Create a recipient
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="2. Create the payout">
          2\. Create the payout
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="1. Create a recipient">
        ```bash
        curl -X POST https://business-api.migrapay.com/v1/api/recipients \
          -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
          -H "Content-Type: application/json" \
          -d '{
            "displayName": "Ada Okafor",
            "ownerType": "individual",
            "rail": "bank",
            "currency": "NGN",
            "destination": {
              "bankCode": "058",
              "accountNumber": "0123456789",
              "accountName": "Ada Okafor"
            }
          }'
        ```
      </CodeBlockTab>

      <CodeBlockTab value="2. Create the payout">
        ```bash
        curl -X POST https://business-api.migrapay.com/v1/api/payouts \
          -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
          -H "Content-Type: application/json" \
          -d '{
            "fromCurrency": "CAD",
            "amount": 250,
            "recipientId": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001",
            "narration": "Invoice 2048"
          }'
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    The payout response carries the transaction and its status. See [Payouts](/docs/payouts) for the full object.
  </Step>

  <Step>
    ### Receive webhooks

    Add a webhook endpoint in the dashboard under **Developers → Webhooks** to be told when payouts settle or fail and when collections are paid. See [Webhooks](/docs/webhooks).
  </Step>
</Steps>

## Next steps

<Cards>
  <Card title="Authentication" href="/docs/authentication">
    API keys, scopes and the errors you get without them.
  </Card>

  <Card title="Payouts" href="/docs/payouts">
    Pay saved recipients from your wallets.
  </Card>

  <Card title="Webhooks" href="/docs/webhooks">
    Get notified when payouts and collections change state.
  </Card>
</Cards>


---

# Authentication

Authenticate server-side requests with a secret API key.

Source: https://www.migrapay.com/docs/authentication

<Callout type="warn">
  Keep API keys on your server. Never put them in browser code, mobile apps or public repositories.
</Callout>

## API keys

Create keys in the dashboard under **Developers → API keys**. Each key belongs to one environment, which you can tell from its prefix:

| Environment | Key prefix  |
| ----------- | ----------- |
| Sandbox     | `mpb_test_` |
| Live        | `mpb_live_` |

The full key is shown once, when you create it. MigraPay stores only a hash, so a lost key cannot be recovered: revoke it and create a new one. The dashboard lists each key by its first characters (for example `mpb_live_Q3x9aB`) so you can tell them apart.

## Send an authenticated request

Send the key in the `Authorization` header as a bearer token:

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl https://business-api.migrapay.com/v1/api/wallets \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY"
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/wallets', {
      headers: { Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}` },
    });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    import os, requests

    res = requests.get(
        "https://business-api.migrapay.com/v1/api/wallets",
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
    )
    ```
  </CodeBlockTab>
</CodeBlockTabs>

You can send the key in an `X-API-Key` header instead. If both headers are present, `X-API-Key` is used.

```bash
curl https://business-api.migrapay.com/v1/api/wallets -H "X-API-Key: $MIGRAPAY_API_KEY"
```

## Scopes

Each key carries the scopes you pick when you create it. Give every integration only the scopes it needs: a reporting job needs `payment.view`, not `payment.create`. The [API reference](/docs/api-reference#scopes) lists which endpoints each scope opens.

## Key security

* Store keys in environment variables or a secret manager, never in source code.
* Use separate keys per service so you can revoke one without touching the others.
* Rotate keys by creating a new key, deploying it, then revoking the old one.
* Revoke a key immediately in the dashboard if you think it has leaked.

## Restrict Live keys by IP

Under **Settings → API & integrations** you can list the IP addresses your servers call from. Live keys used from anywhere else get `403 IP_NOT_ALLOWED`. Sandbox keys are never IP-restricted.

## Authentication errors

A missing, unknown or under-scoped key gets a `401` or `403`; the codes are in the [API reference](/docs/api-reference#authentication).


---

# Checkout

Take card, bank transfer, USSD and Interac payments on a hosted page or inside your own site.

Source: https://www.migrapay.com/docs/checkout

A checkout is a payment your customer completes on a MigraPay payment page. You set the price; your customer picks the currency and how to pay. When the money arrives it is credited to your wallet and a `collection.settled` [webhook](/docs/webhooks) fires.

You can show the checkout in two ways:

* **Checkout link**: send your customer to `checkoutUrl`. When they finish, they can return to your `redirectUrl`.
* **Checkout widget**: add one script to your site and the checkout opens over your page. Your customer never leaves your site.

Both use the same checkout, so you can start with the link and add the widget later. [Payment links](/docs/payment-links) and payment requests from the dashboard open this same checkout too.

## 1. Create the checkout on your server

Call [`POST /checkout`](/docs/api-reference/checkout#create-a-checkout) with your secret key, the price and your order reference. Send an `Idempotency-Key` (your order id works) so a retry never creates a second checkout. Keep the returned `id`: it is how you confirm the payment, and webhooks refer to it as `requestId`.

If you cannot run server code, start the checkout from the browser with your [public key](#public-keys) instead.

## 2a. Send a checkout link

Send your customer to `checkoutUrl`. It works on any device and needs nothing on your site. After paying, your customer sees a receipt and, if you set `redirectUrl`, a **Return to merchant** button that opens:

```text
https://your-site.com/orders/1048?status=successful&reference=MP-9683E7B6532A&checkout=pr_d78890ba7b7a49fb97f3228f8008c3ad
```

If they close the checkout instead, `status` is `cancelled`. Treat these parameters as a hint for what to show; [confirm the payment](#confirm-the-payment) before you fulfil the order.

## 2b. Or open it on your page

Add the script once, anywhere on the page:

```html
<script src="https://migrapay.com/checkout.js"></script>
```

### Open a checkout you created

Create the checkout on your server, pass its `token` to the page, and open it:

```html
<button id="pay">Pay NGN 25,000</button>
<script>
  document.getElementById('pay').addEventListener('click', () => {
    MigraPay.checkout({
      token: 'pr_d78890ba7b7a49fb97f3228f8008c3ad',
      onSuccess: (payment) => {
        // { token, reference, amount, currency }: show a thank-you, then confirm on your server
      },
      onClose: (result) => {
        // { status: 'successful' | 'cancelled' }
      },
    });
  });
</script>
```

Or without any JavaScript of your own:

```html
<button data-migrapay-checkout="pr_d78890ba7b7a49fb97f3228f8008c3ad">Pay now</button>
```

### Drop-in button (no server code)

In the dashboard, open **Accept Payments → Checkout widget** and create a widget for each website: choose its currencies, the methods for each currency, the button label and colour, and save. The **HTML** tab gives you code with your public key and the widget's checkout id:

```html
<script src="https://migrapay.com/checkout.js"
        data-key="pk_live_9sK2mQ7xLp4Rt8VwYc3Nb6Hd"
        data-checkout-id="chk_AboFHVCp9PtlB-g5"></script>

<div id="mp-checkout" data-amount="25000" data-currency="NGN"
     data-email="customer@example.com" data-reference="ORDER-1048"></div>
```

The script renders a Pay button in `#mp-checkout`. Set `data-amount` and `data-currency` for each order; `data-email`, `data-name`, `data-phone`, `data-reference`, `data-description` and `data-label` (button text) are optional. The element receives `migrapay:success` and `migrapay:close` events:

```js
document.getElementById('mp-checkout').addEventListener('migrapay:success', (event) => {
  console.log(event.detail.reference);
});
```

The same works from JavaScript:

```js
MigraPay.checkout({
  key: 'pk_live_9sK2mQ7xLp4Rt8VwYc3Nb6Hd',
  checkoutId: 'chk_AboFHVCp9PtlB-g5', // optional
  amount: 25000,
  currency: 'NGN',
  reference: 'ORDER-1048',
  customer: { email: 'ada@example.com', name: 'Ada Okafor' },
  onSuccess: (payment) => {},
});
```

<Callout type="warn">
  With the drop-in button the amount comes from your web page, which your customer can change. Before you fulfil an order, check on your server that the settled `gross` and `currency` match what you charged. If you need a price nobody can alter, create the checkout on your server and open it by `token`.
</Callout>

Every option, attribute and event is in the [checkout.js reference](/docs/api-reference/checkout-js).

## Public keys

A checkout can be started from your server with your secret API key (above) or straight from your website or app with your **public key**. Every business has one live key (`pk_live_…`) and one test key (`pk_test_…`); find them in the dashboard under **Settings → API & integrations**.

A public key is safe in public code. It can do two things only: start a checkout for your business and read which currencies and methods your checkout offers. It cannot read payments, refund, pay out or reach any other part of the API.

* **Allowed websites**: add your domains (`shop.example.com`, or `*.example.com` for every subdomain) and checkouts started from any other site are refused. With no domains set, any site can use the key.
* **Rotate** the key if it is misused; the old key stops working at once.
* `pk_test_` keys start [Sandbox](/docs/sandbox) checkouts.

From the browser, start a checkout with [checkout.js](/docs/api-reference/checkout-js#start-a-checkout-from-the-browser-with-a-public-key), or call [`POST /v1/checkout/sessions`](/docs/api-reference/checkout#start-a-checkout-with-a-public-key) yourself.

## 3. Confirm the payment

The checkout confirms the payment for your customer automatically: card payments as soon as the bank approves them, transfers and USSD when the money arrives, Interac when the e-Transfer is received and matched. Then confirm it on your server before you fulfil the order:

* **Webhook (recommended)**: [`collection.settled`](/docs/api-reference/webhooks) with `requestId` equal to the checkout `id`, the amounts, and a `checkout` object with your original price (`checkout.base`) and your `metadata`.
* **Retrieve**: [`GET /checkout/{id}`](/docs/api-reference/checkout#retrieve-a-checkout) and fulfil when `status` is `settled`.

If the customer pays in a different currency from your price, your wallet in that currency is credited with what they paid.

## Test in Sandbox

Checkouts created with a Sandbox key (`mpb_test_…`) open in **test mode**: the checkout shows a Test mode badge and sample payment details, and nothing real moves.

* **Card**: pay with a test card below. A declining card shows its error on the checkout, as a real decline would.
* **Bank transfer, USSD, Interac**: the checkout shows sample details; press **Simulate payment** to complete it.

When it completes, your Sandbox wallet is credited (less fees) and `collection.settled` fires, exactly as on live. See [Sandbox](/docs/sandbox). Try it on the public [checkout demo](/demo).

Test card numbers are in the [API reference](/docs/api-reference/checkout#test-cards).


---

# Accept payments

Choose how your customers pay you, and how the money reaches your wallet.

Source: https://www.migrapay.com/docs/collections

Every way of getting paid on MigraPay ends the same way: the money, less fees, lands in your wallet in the currency the customer paid, a [transaction](/docs/transactions) is recorded, and `collection.settled` is sent to your [webhooks](/docs/webhooks).

## Pick a way to get paid

| You want to…                                        | Use                                                                                      |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Sell online and let the customer choose how to pay  | [Checkout](/docs/checkout): hosted page or checkout.js on your site                      |
| Share a payment page by message, email or QR code   | [Payment links](/docs/payment-links) (dashboard)                                         |
| Ask for one specific payment by one specific method | A collection: [`POST /collections`](/docs/api-reference/collections#create-a-collection) |
| Give a customer account details they can always pay | [Virtual accounts](/docs/virtual-accounts)                                               |
| Get paid by Interac e-Transfer in Canada            | [Interac e-Transfer](/docs/interac)                                                      |

A checkout lets the customer pick the currency and method; a collection fixes both up front. Both are the same object underneath, so they are retrieved, listed and reported the same way.

## Request a collection

<Steps>
  <Step>
    See which methods you can use and which fields each takes with [`GET /collections/requirements`](/docs/api-reference/collections#collection-requirements).
  </Step>

  <Step>
    Create it with [`POST /collections`](/docs/api-reference/collections#create-a-collection), sending an `Idempotency-Key`. The response has the payment instructions (account details, or where to send an Interac e-Transfer and the code to include) and a `checkoutUrl` you can send instead.
  </Step>

  <Step>
    Show the instructions to your customer, or send them the link.
  </Step>

  <Step>
    Wait for `collection.settled`, then fulfil. The event carries a `funding` object saying how the money arrived (method, sender, bank, session id or card), and any `metadata` you set.
  </Step>
</Steps>

## What happens to a collection

A collection starts `awaiting_payment`, may pass through `processing`, and ends `settled`, `expired` or `failed`; a settled one can later be `refunded`. The [lifecycle](/docs/api-reference/collections#collection-lifecycle) lists each status and the event it sends.

If a customer pays a different amount than you asked for, the collection settles for what actually arrived and the event says so (`amountReceived`, `mismatch: true`).

## Test it

In Sandbox nobody can pay a collection, so settle it yourself with [`POST /collections/{id}/mock-settle`](/docs/api-reference/collections#settle-in-sandbox). Your Sandbox wallet is credited and the webhook fires exactly as on live. See [Sandbox](/docs/sandbox).


---

# Currency conversion

Convert balances between your wallets.

Source: https://www.migrapay.com/docs/currency-conversion

<Callout title="Dashboard today, API coming soon">
  Converting between your wallets is available in the MigraPay business dashboard today. API access is coming; this page will document it when it ships.
</Callout>

Through the API today you can:

* Show live rates with [`GET /rates`](/docs/api-reference/rates#mid-market-rates).
* Quote a cross-currency payout with [`GET /payouts/estimate`](/docs/api-reference/rates#estimate-a-payout).
* Pay a recipient in another currency directly from your wallet: a [payout](/docs/payouts) converts as part of sending.


---

# Environments

Build and test safely in Sandbox, then switch to Live when your integration and business account are approved.

Source: https://www.migrapay.com/docs/environments

MigraPay has two environments. They share the same base URL and the same endpoints; **the API key you send selects the environment**.

| Capability      | Sandbox                                             | Live                                       |
| --------------- | --------------------------------------------------- | ------------------------------------------ |
| Base URL        | `https://business-api.migrapay.com/v1/api`          | `https://business-api.migrapay.com/v1/api` |
| Key prefix      | `mpb_test_`                                         | `mpb_live_`                                |
| Money movement  | Simulated. No real funds move.                      | Real payouts and collections.              |
| Payout outcomes | Controlled by [test values](/docs/sandbox)          | Decided by the receiving network.          |
| Collections     | Settle on demand with `mock-settle`                 | Settle when your customer pays.            |
| Webhooks        | Sent to the endpoints you add while in Sandbox mode | Sent to your Live endpoints.               |
| Data            | Test data                                           | Production data                            |

The key prefix for the environment selected in the top bar is <EnvKey />.

<Callout>
  Sandbox and Live data are fully isolated. Wallets, recipients, payouts, collections and webhook endpoints created in one environment do not exist in the other, and their ids differ.
</Callout>

## Sandbox

Create a Sandbox key in the dashboard under **Developers → API keys** by choosing the Sandbox mode. Everything you do with it happens in a separate test copy of your business, so you can create recipients, send payouts and settle collections without touching real money. See [Sandbox & test values](/docs/sandbox) for how to trigger specific outcomes.

## Move to Live

<Steps>
  <Step>
    ### Complete your integration

    Exercise every flow you use in Sandbox, including failed payouts and webhook handling.
  </Step>

  <Step>
    ### Verify your business account

    Live payouts and collections are available once your business has been reviewed and approved.
  </Step>

  <Step>
    ### Swap in Live credentials

    Create a key with the `mpb_live_` prefix, store it in your production secret manager and add your production webhook endpoint. Recreate any recipients you need in Live: Sandbox recipients do not carry over.
  </Step>
</Steps>


---

# Interac e-Transfer

How Canadian payers pay you by Interac e-Transfer, and how each payment is matched to you.

Source: https://www.migrapay.com/docs/interac

CAD collections on the `interac` rail are paid by Interac e-Transfer to one mailbox, **[etransfer@migrapay.ca](mailto:etransfer@migrapay.ca)**. Every e-Transfer arrives at the same address, so the code in the e-Transfer message is what tells us whose money it is.

Payers must send to **[etransfer@migrapay.ca](mailto:etransfer@migrapay.ca)** exactly. An e-Transfer sent to any other MigraPay address is not credited to your business.

## Request an Interac payment

Create a collection with `rail: "interac"` and `currency: "CAD"` ([`POST /collections`](/docs/api-reference/collections#create-a-collection)), or a [checkout](/docs/checkout), which offers Interac to Canadian payers. Show the payer the address and the payment code from the response, and set `payerEmail` if you know which email they will send from.

## How a payment is matched

* **Payment code.** Each Interac collection gets its own code, your business code followed by a number (`ACME7K-1001`, `ACME7K-1002`, …). It is `instruction.reference` on the create response and `paymentCode` on the collection. Ask the payer to put it in the e-Transfer message.
* **Business code.** Your business also has a standing code (`ACME7K`), shown in the dashboard under **Accept Payments → Direct Interac** and in the Overview **Deposit** drawer. Use it to top up your own CAD wallet, or give it to customers who pay you without a payment request.

When an e-Transfer arrives we match it like this:

1. **A payment code in the message** settles that collection. Codes are read case-insensitively and spaces or dashes are ignored, so `acme7k 1001` works.
2. **Your business code in the message** settles your oldest open Interac collection for exactly that amount. If none matches, the money is credited to your CAD wallet as a deposit, and a new collection with `description` `Interac deposit` is created and settled for it.
3. **No code.** If the sender's email and the exact amount match an open Interac collection created with that `payerEmail`, it settles. Turn on **Require a payment reference for Direct Interac** under **Settings → Payments** to switch this off and accept coded payments only.
4. **Nothing matches.** The payment is held for review and does not reach a wallet until our team attributes it. Contact support with the sender's name, amount and date.

**Amount mismatch.** If the payer sends a different amount than the collection asked for, the collection still settles, for the amount actually received: your wallet is credited that amount less fees, the `collection.settled` webhook carries `amountReceived` and `mismatch: true`, and the payment is flagged for review so you can follow up with the payer. A payment code for a collection that is already paid never credits twice; the second payment is held for review.

## Test it

In Sandbox, settle an Interac collection with [`POST /collections/{id}/mock-settle`](/docs/api-reference/collections#settle-in-sandbox), or press **Simulate payment** on a test checkout.


---

# Introduction

The MigraPay API lets your business hold multi-currency balances, pay recipients across borders and collect payments from customers.

Source: https://www.migrapay.com/docs/introduction

## What you can build

<Cards>
  <Card title="Send payouts" href="/docs/payouts">
    Pay saved bank, mobile money and local-rail recipients from your wallets, with the currency conversion built in.
  </Card>

  <Card title="Collect payments" href="/docs/collections">
    Create payment requests with account details or a hosted checkout link your customer pays into.
  </Card>

  <Card title="Track balances" href="/docs/wallets">
    Read wallet balances and the full transaction history behind them.
  </Card>

  <Card title="Quote rates" href="/docs/rates">
    Show live exchange rates and exact payout estimates before you send.
  </Card>
</Cards>

## How the API works

Your server sends JSON requests over HTTPS to the MigraPay API, authenticated with a secret API key. MigraPay moves the money through its payment network and reports changes back to you with signed [webhooks](/docs/webhooks).

* **Base URL:** `https://business-api.migrapay.com/v1/api`. Every path in these docs is relative to it.
* **JSON everywhere.** Send `Content-Type: application/json` on `POST` requests. Field names are camelCase.
* **Amounts are decimal numbers in major units.** `250.75` means 250 dollars and 75 cents, not 25,075 cents.
* **Currencies** are three-letter ISO 4217 codes such as `CAD`, `USD` or `NGN`.
* **Timestamps** are ISO 8601 strings in UTC, for example `2026-08-01T10:15:30.000Z`.

Every response uses the same `{ status, message, data }` envelope; see the [API reference](/docs/api-reference#requests-and-responses).

## Core concepts

* **Wallets** hold your balance in each currency your business is enabled for.
* **Recipients** are the people and businesses you pay, with the account details for one payout method.
* **Payouts** move money from a wallet to a recipient, converting currency when needed.
* **Collections** are payment requests your customers pay into; settled funds land in your wallet.
* **Transactions** are the ledger entries behind every movement of money.
* **Environments.** Sandbox and Live are separate, fully isolated data sets, selected by your API key. See [Environments](/docs/environments).


---

# Payment links

Shareable payment pages that open the MigraPay checkout.

Source: https://www.migrapay.com/docs/payment-links

A payment link is a page you share by message, email or QR code. Create it in the dashboard under **Accept Payments → Payment links**: set a fixed amount or let the customer enter one, pick the currency and which methods to offer (card, bank transfer and bank USSD for `NGN`; Interac for `CAD`), and choose whether the link takes one payment or many.

When a customer opens the link they fill in only what the link asks for (the amount, and their name and email if you collect them) and land on the same [checkout](/docs/checkout) your API and widget use. A fixed-amount link that collects nothing goes straight to the checkout.

Every payment through a link is a checkout, so it is reported the same way: `collection.settled` [webhooks](/docs/webhooks) and [transactions](/docs/transactions). The link's page in the dashboard shows how many times it was opened, what was collected and each payment.

<Callout title="API">
  Payment links are managed in the dashboard. To create a payment page from your server, create a [checkout](/docs/api-reference/checkout#create-a-checkout) and send your customer its `checkoutUrl`.
</Callout>


---

# Send money

Pay a recipient from your wallet, with conversion built in, and track it to delivery.

Source: https://www.migrapay.com/docs/payouts

A payout debits one of your wallets and pays a [recipient](/docs/recipients). If the wallet currency differs from the recipient's, MigraPay converts as part of sending.

## Send a payout

<Steps>
  <Step>
    **Save the recipient once.** Create a recipient with the destination details its payout method needs. See [Recipients](/docs/recipients).
  </Step>

  <Step>
    **Quote it.** [`GET /payouts/estimate`](/docs/api-reference/rates#estimate-a-payout) shows the rate, the fees and what the recipient receives. Make sure your wallet covers the amount plus fees.
  </Step>

  <Step>
    **Send it.** [`POST /payouts`](/docs/api-reference/payouts#create-a-payout) with `fromCurrency`, `amount` and `recipientId`, and always an `Idempotency-Key`: if the request times out, retry with the same key and body and you get the original payout back instead of paying twice.
  </Step>

  <Step>
    **Track it.** Listen for `payment.settled`, `payment.failed` and `payment.reversed` on your [webhooks](/docs/webhooks), or read it with [`GET /payouts/{id}`](/docs/api-reference/payouts#retrieve-a-payout).
  </Step>
</Steps>

## What happens to a payout

Your wallet is debited when the payout is created (`processing`). It ends `successful`, or `failed` with your wallet credited back. A delivered payout can later be `reversed` by the receiving bank, which also credits you back. Some routes settle instantly, so the create response can already say `successful`. See the [lifecycle](/docs/api-reference/payouts#payout-lifecycle).

### Approvals

If your business has approval rules that match the payout, nothing is debited yet: the response has `"approvalRequired": true` and the status is `pending_approval`. Once an approver signs off in the dashboard the payout proceeds and your webhooks report the outcome.

## When a payout is refused

The create call tells you why with a code you can branch on: not enough balance, below or above the limits for that route, an inactive recipient, a method that is switched off. If the payout was declined after your wallet was debited, the money is credited back before you get the error. Every code is in the [payout errors table](/docs/api-reference/payouts#errors).

## Test it

In Sandbox, the last four digits of the recipient's account number decide the outcome (`0000` declines, `0001` settles a few minutes later, anything else settles at once), so you can test every path. See [Sandbox](/docs/sandbox#payout-outcomes).


---

# Rates

Show live exchange rates and quote cross-currency payouts.

Source: https://www.migrapay.com/docs/rates

Two calls cover rates:

* [`GET /rates`](/docs/api-reference/rates#mid-market-rates) returns mid-market rates on any base currency. Use it to show indicative prices.
* [`GET /payouts/estimate`](/docs/api-reference/rates#estimate-a-payout) quotes a specific payout: the rate you will get, the fees and what the recipient receives. Use it right before [sending a payout](/docs/payouts) so your user sees exactly what will happen.

Rates move. A quote is a preview, not a lock: the payout uses the rate at the moment it is created.


---

# Recipients

Save who you pay and how, once, then pay them as often as you like.

Source: https://www.migrapay.com/docs/recipients

A recipient pairs a currency with a payout method (a **rail**, such as `bank`, `mobile_money` or `interac`) and the destination details that rail needs.

## Add a recipient

<Steps>
  <Step>
    Ask [`GET /payouts/requirements`](/docs/api-reference/recipients#payout-requirements) which rails the currency supports and which `destination` fields each needs. Build your form from it rather than hard-coding fields: it is the source of truth.
  </Step>

  <Step>
    For bank and mobile money rails, get the institution code from [`GET /payouts/banks`](/docs/api-reference/recipients#banks-and-networks).
  </Step>

  <Step>
    Where supported, confirm the account holder's name with [`POST /payouts/validate-account`](/docs/api-reference/recipients#validate-an-account) and show it to your user before saving. It catches typos before money moves.
  </Step>

  <Step>
    Save it with [`POST /recipients`](/docs/api-reference/recipients#create-a-recipient) and keep the returned `id` for payouts.
  </Step>
</Steps>

## Tips

* Validate once, at creation. You do not need to validate again for each payout.
* One recipient per destination. To pay the same person in another currency or by another method, create another recipient.
* List what you have saved with [`GET /recipients`](/docs/api-reference/recipients#list-recipients).

Then [send a payout](/docs/payouts).


---

# Sandbox & test values

Trigger specific payout outcomes and settle collections on demand while you test.

Source: https://www.migrapay.com/docs/sandbox

Everything on this page needs a Sandbox key (prefix `mpb_test_`). Sandbox uses the same base URL and endpoints as Live; see [Environments](/docs/environments). No real money moves.

## Payout outcomes

The last four digits of the recipient's account number (or phone number, IBAN or CLABE) decide what happens to a Sandbox payout:

| Destination ends in | Result                                                                                                                                                     |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0000`              | Declined immediately: `502 PROVIDER_DECLINED`. Your wallet is credited back and `payment.failed` is sent.                                                  |
| `0001`              | Accepted as `processing`, then settles a few minutes later (within about 5 minutes) and `payment.settled` is sent. Use it to test asynchronous settlement. |
| anything else       | Settles immediately: the create response shows `successful` and `payment.settled` is sent.                                                                 |

For example, a Nigerian bank recipient with account number `0123450000` always declines, and `0123450001` settles asynchronously.

```json title="502 Bad Gateway (account ending 0000)"
{
  "status": "error",
  "message": "PROVIDER_DECLINED",
  "data": { "reason": "Sandbox: destination ending 0000 always declines" }
}
```

## Account validation

[`POST /payouts/validate-account`](/docs/api-reference/recipients#validate-an-account) always returns `"accountName": "Sandbox Account"` in Sandbox, for any account number.

## Settle a collection

Customers cannot pay Sandbox collections, so settle them yourself with [`POST /collections/{id}/mock-settle`](/docs/api-reference/collections#settle-in-sandbox). The collection settles, your Sandbox wallet is credited less fees and `collection.settled` is sent, exactly as for a real payment. On a test checkout, press **Simulate payment** or use a [test card](/docs/api-reference/checkout#test-cards).

## A full test run

<Steps>
  <Step>
    List wallets with `GET /wallets` to find your Sandbox balances.
  </Step>

  <Step>
    Create a collection, settle it with `mock-settle`, and check that `collection.settled` reaches your webhook endpoint and the wallet balance went up.
  </Step>

  <Step>
    Create three recipients whose account numbers end in `0000`, `0001` and something else, then pay each to see a decline, an asynchronous settlement and an instant settlement.
  </Step>

  <Step>
    Confirm your system handles each `payment.*` webhook and that `GET /transactions` matches your records.
  </Step>
</Steps>


---

# Transactions

Reconcile every movement of money in and out of your wallets.

Source: https://www.migrapay.com/docs/transactions

Every payout, settled collection, conversion, fee and refund is a transaction against one of your wallets. It is the single list to reconcile against.

## Reconcile

* **Find one payment.** Put your own reference in the payout `narration` or the collection `reference`, then search with `q`. Or read it directly with [`GET /transactions/{id}`](/docs/api-reference/transactions#retrieve-a-transaction), which takes an id or a reference.
* **Daily or monthly reports.** [`GET /transactions`](/docs/api-reference/transactions#list-transactions) with `startDate`, `endDate` and `currency`, page by page. Each row has the wallet `balance` after it.
* **Payouts only, or collections only.** Filter with `activity=transfer` or `activity=collection`.
* **Match a collection to its credit.** The credit's `paymentRequestId` is the collection id.

Webhooks are the fastest way to learn about a change; transactions are the record to reconcile against later.


---

# Virtual accounts

Dedicated receiving account details for your customers.

Source: https://www.migrapay.com/docs/virtual-accounts

<Callout title="Dashboard today, API coming soon">
  Virtual accounts are available in the MigraPay business dashboard today. API access is coming; this page will document it when it ships.
</Callout>

In the meantime you can issue account details for a single payment through the API by creating a [collection](/docs/collections) with `rail` set to `dynamic_virtual_account` (one-time details) or `virtual_account` (your standing account for that currency). The account details come back in `instruction.details`, and a `collection.settled` [webhook](/docs/webhooks) tells you when the payment lands.


---

# Wallets

Your balances, one wallet per currency.

Source: https://www.migrapay.com/docs/wallets

You hold one wallet per currency. Collections are credited to the wallet in the currency the customer paid; payouts debit the wallet you choose with `fromCurrency`.

* **Check balances** with [`GET /wallets`](/docs/api-reference/wallets#list-wallets) before sending a payout, or to show balances in your product.
* **Top up** a wallet by paying into it: an Interac e-Transfer with your business code for CAD (see [Interac e-Transfer](/docs/interac)), or the details in the dashboard Overview **Deposit** drawer.
* **Follow every movement** in [transactions](/docs/transactions): each credit and debit records the balance after it.

Converting between your own wallets is done in the dashboard today; see [Currency conversion](/docs/currency-conversion).


---

# Webhooks

Get notified when payouts and collections change, and verify every delivery.

Source: https://www.migrapay.com/docs/webhooks

Webhooks tell your server when money moves, so you never have to poll. Every event, field and header is listed in the [webhook events reference](/docs/api-reference/webhooks).

## Add an endpoint

Register endpoints in the dashboard under **Developers → Webhooks**: enter the HTTPS URL and pick the events to receive. The endpoint's **signing secret** (it starts with `whsec_`) is shown when you create it; store it next to your API key.

Endpoints belong to an environment like everything else: endpoints you add while the dashboard is in Sandbox mode receive Sandbox events only.

## Verify the signature

Join `X-MigraPay-Timestamp`, a `.` and the **raw body bytes** (before any JSON parsing). Compute the HMAC-SHA256 of that string using the full signing secret, including its `whsec_` prefix, as the key. Hex-encode it and compare with `X-MigraPay-Signature-V2` in constant time.

Reject the request if the signatures differ, or if the timestamp is more than 5 minutes from your clock. The timestamp check stops someone replaying an old delivery they captured.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```js
    import crypto from 'node:crypto';
    import express from 'express';

    const app = express();
    const secret = process.env.MIGRAPAY_WEBHOOK_SECRET; // whsec_...

    // Read the raw body: the signature covers the exact bytes sent.
    app.post('/webhooks/migrapay', express.raw({ type: 'application/json' }), (req, res) => {
      const timestamp = req.get('X-MigraPay-Timestamp') ?? '';
      const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
      const expected = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.`)
        .update(req.body)
        .digest('hex');
      const received = req.get('X-MigraPay-Signature-V2') ?? '';
      const valid =
        fresh &&
        received.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
      if (!valid) return res.sendStatus(400);

      const { event, data } = JSON.parse(req.body.toString('utf8'));
      // Deduplicate on req.get('X-MigraPay-Delivery'), then hand off to a queue.
      res.sendStatus(200);
    });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    import hashlib, hmac, json, os, time
    from flask import Flask, request, abort

    app = Flask(__name__)
    SECRET = os.environ["MIGRAPAY_WEBHOOK_SECRET"].encode()  # whsec_...

    @app.post("/webhooks/migrapay")
    def migrapay_webhook():
        raw = request.get_data()  # raw bytes, before JSON parsing
        timestamp = request.headers.get("X-MigraPay-Timestamp", "")
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
            abort(400)
        expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
        received = request.headers.get("X-MigraPay-Signature-V2", "")
        if not hmac.compare_digest(expected, received):
            abort(400)

        payload = json.loads(raw)
        # Deduplicate on request.headers["X-MigraPay-Delivery"], then enqueue.
        return "", 200
    ```
  </CodeBlockTab>
</CodeBlockTabs>

<Callout type="warn">
  Verify against the raw body. Parsing the JSON and serialising it again can change spacing or key order, and the signature will not match.
</Callout>

## Responding and retries

* Return any **2xx** status within **10 seconds** to acknowledge. Do the real work asynchronously.
* Any other status, a timeout or a connection error is a failed attempt. MigraPay retries up to **5 attempts in total**, waiting 2, 4, 8 and 16 minutes between them.
* After the fifth failed attempt the delivery is marked failed and not retried automatically.
* Deliveries can arrive more than once and out of order. Deduplicate on `X-MigraPay-Delivery`, and treat the payout or collection status from the API as the source of truth when ordering matters.

## Best practices

* Use HTTPS and verify every signature.
* Acknowledge fast, then process from a queue.
* Make your handler idempotent: processing the same delivery twice must not credit a customer twice.
* If you suspect you missed events, reconcile with [`GET /transactions`](/docs/transactions) or [`GET /collections`](/docs/api-reference/collections#list-collections).


---

# Overview

Every MigraPay API endpoint, parameter, response field, error and event.

Source: https://www.migrapay.com/docs/api-reference

This is the complete reference. For step-by-step integration, start with the [Guides](/docs).

## Base URL

```bash
https://business-api.migrapay.com/v1/api
```

Every path in this reference is relative to it, except the two public-key calls under [`/v1/checkout`](/docs/api-reference/checkout#start-a-checkout-with-a-public-key). Sandbox and Live share the base URL: the key you send decides the environment.

## Authentication

| Key            | Prefix                    | Where             | Sent as                                                                                                                                                                           |
| -------------- | ------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Secret API key | `mpb_live_` / `mpb_test_` | Your server only  | `Authorization: Bearer <key>` or `X-API-Key: <key>`                                                                                                                               |
| Public key     | `pk_live_` / `pk_test_`   | Websites and apps | `X-MigraPay-Key: <key>`, only for the [public checkout calls](/docs/api-reference/checkout#start-a-checkout-with-a-public-key) and [checkout.js](/docs/api-reference/checkout-js) |

| Status | `message`            | Cause                                                                                                                                           |
| ------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 401    | `missing_api_key`    | No key in `Authorization` or `X-API-Key`.                                                                                                       |
| 401    | `invalid_api_key`    | The key is unknown, revoked or malformed.                                                                                                       |
| 403    | `insufficient_scope` | The key lacks the scope the endpoint requires.                                                                                                  |
| 403    | `IP_NOT_ALLOWED`     | A Live key was used from an IP address outside your API IP allowlist (**Settings → API & integrations**). Sandbox keys are never IP-restricted. |

## Scopes

Each secret key carries the scopes chosen when it was created.

| Scope               | Endpoints                                                                                                                                                                                                      |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet.view`       | `GET /wallets`                                                                                                                                                                                                 |
| `payment.view`      | `GET /transactions`, `GET /transactions/{id}`, `GET /payouts`, `GET /payouts/{id}`, `GET /payouts/banks`, `GET /payouts/requirements`, `POST /payouts/validate-account`, `GET /payouts/estimate`, `GET /rates` |
| `payment.create`    | `POST /payouts`                                                                                                                                                                                                |
| `recipient.view`    | `GET /recipients`                                                                                                                                                                                              |
| `recipient.create`  | `POST /recipients`                                                                                                                                                                                             |
| `collection.view`   | `GET /collections`, `GET /collections/{id}`, `GET /collections/requirements`, `GET /checkout/{id}`                                                                                                             |
| `collection.create` | `POST /collections`, `POST /collections/{id}/mock-settle`, `POST /checkout`                                                                                                                                    |

## Requests and responses

* Send JSON with `Content-Type: application/json`. Field names are camelCase.
* Amounts are decimal numbers in **major units**: `250.75` is 250 dollars and 75 cents.
* Currencies are ISO 4217 codes (`CAD`, `USD`, `NGN`). Timestamps are ISO 8601 in UTC.
* Every response uses the same envelope:

```json
{
  "status": "success",
  "message": "Wallets retrieved successfully.",
  "data": {}
}
```

| Field     | Type                     | Description                                                                                             |
| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------- |
| `status`  | `"success"` \| `"error"` | `success` for 2xx and 3xx, `error` otherwise.                                                           |
| `message` | string                   | A readable summary on success; the machine-readable [error code](/docs/api-reference/errors) on errors. |
| `data`    | object                   | The resource or list, or error details.                                                                 |

## Endpoints

| Method | Path                            |                                                                                                       |
| ------ | ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `GET`  | `/wallets`                      | [List wallets](/docs/api-reference/wallets#list-wallets)                                              |
| `GET`  | `/rates`                        | [Mid-market rates](/docs/api-reference/rates#mid-market-rates)                                        |
| `GET`  | `/payouts/estimate`             | [Estimate a payout](/docs/api-reference/rates#estimate-a-payout)                                      |
| `GET`  | `/transactions`                 | [List transactions](/docs/api-reference/transactions#list-transactions)                               |
| `GET`  | `/transactions/{id}`            | [Retrieve a transaction](/docs/api-reference/transactions#retrieve-a-transaction)                     |
| `GET`  | `/payouts/requirements`         | [Payout methods and fields](/docs/api-reference/recipients#payout-requirements)                       |
| `GET`  | `/payouts/banks`                | [Banks and networks](/docs/api-reference/recipients#banks-and-networks)                               |
| `POST` | `/payouts/validate-account`     | [Validate an account](/docs/api-reference/recipients#validate-an-account)                             |
| `POST` | `/recipients`                   | [Create a recipient](/docs/api-reference/recipients#create-a-recipient)                               |
| `GET`  | `/recipients`                   | [List recipients](/docs/api-reference/recipients#list-recipients)                                     |
| `POST` | `/payouts`                      | [Create a payout](/docs/api-reference/payouts#create-a-payout)                                        |
| `GET`  | `/payouts/{id}`                 | [Retrieve a payout](/docs/api-reference/payouts#retrieve-a-payout)                                    |
| `GET`  | `/payouts`                      | [List payouts](/docs/api-reference/payouts#list-payouts)                                              |
| `POST` | `/checkout`                     | [Create a checkout](/docs/api-reference/checkout#create-a-checkout)                                   |
| `GET`  | `/checkout/{id}`                | [Retrieve a checkout](/docs/api-reference/checkout#retrieve-a-checkout)                               |
| `POST` | `/v1/checkout/sessions`         | [Start a checkout with a public key](/docs/api-reference/checkout#start-a-checkout-with-a-public-key) |
| `GET`  | `/v1/checkout/config`           | [Read checkout options](/docs/api-reference/checkout#read-checkout-options)                           |
| `POST` | `/collections`                  | [Create a collection](/docs/api-reference/collections#create-a-collection)                            |
| `GET`  | `/collections/{id}`             | [Retrieve a collection](/docs/api-reference/collections#retrieve-a-collection)                        |
| `GET`  | `/collections`                  | [List collections](/docs/api-reference/collections#list-collections)                                  |
| `GET`  | `/collections/requirements`     | [Collection methods and fields](/docs/api-reference/collections#collection-requirements)              |
| `POST` | `/collections/{id}/mock-settle` | [Settle in Sandbox](/docs/api-reference/collections#settle-in-sandbox)                                |

Events are listed under [Webhook events](/docs/api-reference/webhooks). For AI tools, the whole documentation is available as plain text at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt).


---

# Checkout

Create and retrieve checkouts with your secret key, or start them with your public key.

Source: https://www.migrapay.com/docs/api-reference/checkout

<Endpoints
  items={[
  { method: 'POST', path: '/checkout', title: 'Create a checkout', href: '#create-a-checkout' },
  { method: 'GET', path: '/checkout/{id}', title: 'Retrieve a checkout', href: '#retrieve-a-checkout' },
  { method: 'POST', path: '/checkout/sessions', title: 'Start a checkout with a public key', href: '#start-a-checkout-with-a-public-key' },
  { method: 'GET', path: '/checkout/config', title: 'Read checkout options with a public key', href: '#read-checkout-options' },
]}
/>

A checkout is a payment your customer completes on a MigraPay payment page: you set the price, they choose the currency and method. For how to put it on your site, see the [Checkout guide](/docs/checkout) and the [checkout.js reference](/docs/api-reference/checkout-js).

## Payment methods

| Currency | Methods         | How the customer pays                                                                       |
| -------- | --------------- | ------------------------------------------------------------------------------------------- |
| `NGN`    | `card`          | Visa, Mastercard or Verve, with bank verification where the card requires it.               |
| `NGN`    | `bank_transfer` | A one-time account number for this payment, from any Nigerian banking app.                  |
| `NGN`    | `ussd`          | The customer picks their bank and dials a short code from the phone linked to that account. |
| `CAD`    | `interac`       | An Interac e-Transfer with the payment reference in the message.                            |

Only currencies that are enabled for collections on your account are offered. The customer sees the total in each currency before choosing; the amount shown includes all fees.

## Create a checkout

`POST /checkout` requires the `collection.create` scope and supports [`Idempotency-Key`](/docs/api-reference/idempotency): retrying with the same key returns the same checkout.

<TypeTable
  type={{
  amount: { type: 'number', description: 'Your price, in major units, at most 2 decimals.', required: true },
  currency: { type: 'string', description: 'The currency of your price, for example `NGN`.', required: true },
  currencies: {
    type: 'string[]',
    description: 'Currencies your customer may pay in. Defaults to every currency you can collect. Amounts in other currencies are converted from your price at the current rate.',
  },
  methods: {
    type: '("card" | "bank_transfer" | "ussd" | "interac")[]',
    description: 'Payment methods to offer. Defaults to every method of the chosen currency.',
  },
  redirectUrl: {
    type: 'string',
    description: 'Where "Return to merchant" sends your customer. Must be `https`. We add `status`, `reference` and `checkout` query parameters.',
  },
  customer: { type: '{ email?, name?, phone? }', description: 'Shown on the checkout and the receipt.' },
  reference: { type: 'string', description: 'Your own reference, for example an order number. Shown as "Order #…".' },
  description: { type: 'string', description: 'What the payment is for.' },
  metadata: { type: 'object', description: 'Anything you want back on the webhook, up to 4 KB.' },
  expiresInMinutes: { type: 'integer', description: 'How long the checkout can be paid. Default 1440 (24 hours), at most 7 days.' },
}}
/>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -X POST https://business-api.migrapay.com/v1/api/checkout \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
      -H "Idempotency-Key: ORDER-1048" \
      -H "Content-Type: application/json" \
      -d '{
        "amount": 25000,
        "currency": "NGN",
        "currencies": ["NGN", "CAD"],
        "reference": "1048",
        "description": "Naija Market Day order",
        "customer": { "email": "ada@example.com", "name": "Ada Okafor" },
        "redirectUrl": "https://your-site.com/orders/1048",
        "metadata": { "orderId": 1048 }
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/checkout', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}`,
        'Idempotency-Key': 'ORDER-1048',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: 25000,
        currency: 'NGN',
        reference: '1048',
        customer: { email: 'ada@example.com', name: 'Ada Okafor' },
        redirectUrl: 'https://your-site.com/orders/1048',
        metadata: { orderId: 1048 },
      }),
    });
    const { data } = await res.json();
    // data.checkoutUrl → send your customer here, or open data.token in the widget
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="Response"
{
  "status": "success",
  "message": "Checkout created successfully.",
  "data": {
    "id": "9683e7b6-532a-4a5b-9c51-ee404b960f24",
    "token": "pr_d78890ba7b7a49fb97f3228f8008c3ad",
    "checkoutUrl": "https://migrapay.com/checkout/pr_d78890ba7b7a49fb97f3228f8008c3ad",
    "status": "awaiting_payment",
    "amount": 25000,
    "currency": "NGN",
    "expiresAt": "2026-11-04T07:51:57.256Z"
  }
}
```

Keep `id`: it is how you confirm the payment and how webhooks refer to it (`requestId`).

## Retrieve a checkout

`GET /checkout/{id}` returns the checkout as a [collection object](/docs/api-reference/collections#retrieve-a-collection): its `status`, the amounts, `payer`, `funding`, `metadata` and history. Requires `collection.view`.

<TypeTable
  type={{
  id: { type: 'string (uuid)', description: 'The checkout `id` from the create response.', required: true },
}}
/>

```bash
curl https://business-api.migrapay.com/v1/api/checkout/9683e7b6-532a-4a5b-9c51-ee404b960f24 \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

Fulfil the order when `status` is `settled`. If the customer paid in another currency than your price, `currency` and `amount` are what they paid; your original price is on the webhook as `checkout.base`. Unknown ids return `404 REQUEST_NOT_FOUND`.

## Start a checkout with a public key

`POST /v1/checkout/sessions` starts a checkout from your website or app. It is authenticated by your **public key** in the `X-MigraPay-Key` header, not by your secret key, and is not under `/api`. A `pk_test_` key starts a Sandbox checkout.

<TypeTable
  type={{
  amount: { type: 'number', description: 'Your price, in major units, at most 2 decimals.', required: true },
  currency: { type: 'string', description: 'The currency of your price.', required: true },
  checkoutId: { type: 'string', description: 'A saved checkout widget (`chk_…`). Its currencies, methods and return URL apply.' },
  currencies: { type: 'string[]', description: 'Currencies the customer may pay in.' },
  methods: { type: '("card" | "bank_transfer" | "ussd" | "interac")[]', description: 'Payment methods to offer.' },
  redirectUrl: { type: 'string', description: 'Where "Return to merchant" sends the customer. Must be `https`.' },
  customer: { type: '{ email?, name?, phone? }', description: 'Payer details.' },
  reference: { type: 'string', description: 'Your own reference.' },
  description: { type: 'string', description: 'What the payment is for.' },
  metadata: { type: 'object', description: 'Returned on the webhook, up to 4 KB.' },
}}
/>

```bash
curl -X POST https://business-api.migrapay.com/v1/checkout/sessions \
  -H "X-MigraPay-Key: pk_live_9sK2mQ7xLp4Rt8VwYc3Nb6Hd" \
  -H "Content-Type: application/json" \
  -d '{"amount": 25000, "currency": "NGN", "reference": "ORDER-1048",
       "customer": {"email": "ada@example.com", "name": "Ada Okafor"}}'
```

```json title="201 Created"
{
  "status": "success",
  "message": "Checkout created successfully.",
  "data": {
    "token": "pr_d78890ba7b7a49fb97f3228f8008c3ad",
    "checkoutUrl": "https://migrapay.com/checkout/pr_d78890ba7b7a49fb97f3228f8008c3ad"
  }
}
```

Open `token` with [checkout.js](/docs/api-reference/checkout-js) or send the customer to `checkoutUrl`.

**Errors:** `401 INVALID_PUBLIC_KEY` (missing, unknown or rotated key), `403 ORIGIN_NOT_ALLOWED` (the calling site is not in the key's allowed websites), `400 VALIDATION_ERROR`, `429` when called too often (30 a minute).

<Callout type="warn">
  Anything started from a browser can be changed by the person using it. Treat the amount as untrusted and confirm the payment on your server with [`GET /checkout/{id}`](#retrieve-a-checkout) or the webhook before you fulfil the order.
</Callout>

## Read checkout options

`GET /v1/checkout/config` with the same `X-MigraPay-Key` header returns your business's display name, the currencies and methods a checkout can offer, and the key's mode. Use it to build your own payment picker.

```json title="200 OK"
{
  "status": "success",
  "message": "Checkout config retrieved successfully.",
  "data": {
    "merchantName": "Acme Ltd",
    "currencies": [
      { "currency": "NGN", "methods": ["card", "bank_transfer", "ussd"] },
      { "currency": "CAD", "methods": ["interac"] }
    ],
    "mode": "live"
  }
}
```

`mode` is `live` or `sandbox`. Errors are the same as for sessions; it allows 60 calls a minute.

## Lifecycle

<Lifecycle
  stages={[
  { status: 'awaiting_payment', text: 'Checkout open; waiting for the customer.', tone: 'neutral' },
  { status: 'processing', text: 'Payment received and being confirmed.', tone: 'info' },
  { status: 'settled', text: 'Funds credited to your wallet. collection.settled fires.', tone: 'success' },
  { status: 'expired', text: 'Not paid in time. collection.expired fires.', tone: 'neutral' },
  { status: 'refunded', text: 'Returned to the payer after settling. collection.refunded fires.', tone: 'danger' },
]}
/>

## Test cards

Any name and any future expiry date work in Sandbox; keep amounts small on the test environment (for example NGN 100), where larger test charges may be declined.

| Card number           | Expiry  | CVV   | Result                                              |
| --------------------- | ------- | ----- | --------------------------------------------------- |
| `4084 0840 8408 4081` | `12/30` | `408` | Paid                                                |
| `4187 4274 1556 4246` | `09/32` | `828` | Paid (asks for OTP `12345` on the test environment) |
| `5399 8383 8383 8381` | `12/30` | `470` | Paid                                                |
| `5258 5859 2266 6506` | `09/31` | `883` | Declined: insufficient funds                        |
| `5143 0105 2233 9965` | `08/31` | `276` | Declined: do not honour                             |

When a card needs bank verification, the checkout shows the bank's one-time-password step in the same window; enter the OTP and the checkout continues to the receipt. Any other card number is declined.

<Callout type="info">
  Test cards only work on the test environment. On live, real cards are charged.
</Callout>


---

# checkout.js

Every option, callback, attribute and event of the MigraPay checkout script.

Source: https://www.migrapay.com/docs/api-reference/checkout-js

`checkout.js` opens the MigraPay checkout in a full-screen frame over your page. It never redirects the page. Load it once:

```html
<script src="https://migrapay.com/checkout.js"></script>
```

It defines `window.MigraPay` with two functions, `checkout(options)` and `close()`. For when to use each mode, see the [Checkout guide](/docs/checkout).

<Callout type="warn" title="Confirm on your server">
  `onSuccess` runs in the browser, where anyone can call it. Fulfil an order only after your server has confirmed the payment, with [`GET /checkout/{id}`](/docs/api-reference/checkout#retrieve-a-checkout) or the [`collection.settled`](/docs/api-reference/webhooks) webhook.
</Callout>

## MigraPay.checkout(options)

Opens the checkout. Pass **either** a `token` from a checkout your server created, **or** your public `key` with the payment details. If a checkout is already open, the call does nothing.

### Open a checkout your server created

<TypeTable
  type={{
  token: { type: 'string', description: 'The `token` from [`POST /checkout`](/docs/api-reference/checkout#create-a-checkout), for example `pr_9Fh2kLm7Qx3Vb8Ns`.', required: true },
  onSuccess: { type: '(payment) => void', description: 'Called once when the payment succeeds. See [onSuccess](#onsuccess).' },
  onClose: { type: '(result) => void', description: 'Called when the checkout closes. See [onClose](#onclose).' },
}}
/>

```js
MigraPay.checkout({
  token: 'pr_9Fh2kLm7Qx3Vb8Ns',
  onSuccess: (payment) => showThankYou(payment.reference),
  onClose: (result) => console.log(result.status),
});
```

### Start a checkout from the browser with a public key

<TypeTable
  type={{
  key: { type: 'string', description: 'Your public key, `pk_live_…` or `pk_test_…`. Optional when the script tag has `data-key`.', required: true },
  amount: { type: 'number', description: 'Amount to charge, in major units of `currency`.' },
  currency: { type: 'string', description: 'Currency of `amount`, for example `NGN` or `CAD`.' },
  reference: { type: 'string', description: 'Your order reference. Returned to `onSuccess` and on the webhook.' },
  description: { type: 'string', description: 'What the payment is for, shown to the payer.' },
  customer: { type: '{ email?, name?, phone? }', description: 'Payer details, used to prefill the checkout.' },
  metadata: { type: 'object', description: 'Your own key-value data, returned exactly as sent on the webhook as `data.metadata`. Keep it small: it travels in the URL.' },
  checkoutId: { type: 'string', description: 'A saved checkout widget id (`chk_…`, from **Accept Payments → Checkout widget**). Its currencies, methods and branding apply.' },
  currencies: { type: 'string[]', description: 'Limit the currencies the payer can choose, for example `["NGN"]`.' },
  methods: { type: 'string[]', description: 'Limit the payment methods, for example `["card", "bank_transfer"]`.' },
  onSuccess: { type: '(payment) => void', description: 'Called once when the payment succeeds.' },
  onClose: { type: '(result) => void', description: 'Called when the checkout closes.' },
}}
/>

```js
MigraPay.checkout({
  key: 'pk_live_…',
  amount: 25000,
  currency: 'NGN',
  reference: 'ORDER-1048',
  customer: { email: 'ada@example.com', name: 'Ada Obi' },
  metadata: { orderId: '1048' },
  onSuccess: (payment) => confirmOnServer(payment.reference),
});
```

A public key can only start checkouts for your business. Restrict it to your own websites under **Settings → API & integrations → Public keys**; checkouts opened from any other site are refused.

Calling `checkout()` with neither a `token` nor a key throws `MigraPay.checkout needs your public key (key: "pk_…") or a checkout token`.

### onSuccess

Called once, as soon as the payment succeeds, with:

<TypeTable
  type={{
  token: { type: 'string', description: 'The checkout token.' },
  reference: { type: 'string', description: 'The checkout reference: yours, or the one MigraPay generated.' },
  amount: { type: 'number', description: 'Amount paid.' },
  currency: { type: 'string', description: 'Currency paid in.' },
}}
/>

### onClose

Called when the frame closes, with `{ status }`:

| `status`     | Meaning                                                               |
| ------------ | --------------------------------------------------------------------- |
| `successful` | The payer paid, then closed the checkout.                             |
| `cancelled`  | The payer closed it without paying, or you called `MigraPay.close()`. |

## MigraPay.close()

Closes an open checkout. `onClose` runs with `{ status: 'cancelled' }`.

## HTML attributes

### Pay button for a server-created checkout

Any element with `data-migrapay-checkout` opens that checkout when clicked:

```html
<button data-migrapay-checkout="pr_9Fh2kLm7Qx3Vb8Ns">Pay now</button>
```

### Drop-in button (no JavaScript)

Put your public key, and optionally a widget id, on the script tag. Each `<div id="mp-checkout">` (or any element with `data-migrapay-widget`) gets a Pay button styled from your widget settings.

```html
<script
  src="https://migrapay.com/checkout.js"
  data-key="pk_live_…"
  data-checkout-id="chk_…"
></script>

<div id="mp-checkout" data-amount="25000" data-currency="NGN" data-reference="ORDER-1048"></div>
```

| Attribute                               | On               | Meaning                                                                    |
| --------------------------------------- | ---------------- | -------------------------------------------------------------------------- |
| `data-key`                              | `<script>`       | Your public key. Required for the drop-in button.                          |
| `data-checkout-id`                      | `<script>`       | A saved checkout widget: its button label, colour, currencies and methods. |
| `data-amount`                           | button container | Amount to charge.                                                          |
| `data-currency`                         | button container | Currency of the amount.                                                    |
| `data-reference`                        | button container | Your order reference.                                                      |
| `data-description`                      | button container | What the payment is for.                                                   |
| `data-email`, `data-name`, `data-phone` | button container | Payer details to prefill.                                                  |
| `data-label`                            | button container | Button text. Defaults to the widget's label, else `Pay now`.               |

### DOM events

The container element dispatches bubbling events:

| Event              | `event.detail`                              |
| ------------------ | ------------------------------------------- |
| `migrapay:success` | The same object as [onSuccess](#onsuccess). |
| `migrapay:close`   | `{ status: 'successful' \| 'cancelled' }`   |

```js
document.getElementById('mp-checkout').addEventListener('migrapay:success', (e) => {
  confirmOnServer(e.detail.reference);
});
```


---

# Collections

Request payments from your customers and get paid into your wallet.

Source: https://www.migrapay.com/docs/api-reference/collections

<Endpoints
  items={[
  { method: 'POST', path: '/collections', title: 'Create a collection', href: '#create-a-collection' },
  { method: 'GET', path: '/collections/{id}', title: 'Retrieve a collection', href: '#retrieve-a-collection' },
  { method: 'GET', path: '/collections', title: 'List collections', href: '#list-collections' },
  { method: 'GET', path: '/collections/requirements', title: 'Methods and fields', href: '#collection-requirements' },
  { method: 'POST', path: '/collections/{id}/mock-settle', title: 'Settle in Sandbox', href: '#settle-in-sandbox' },
]}
/>

A collection is a payment request for a set amount. MigraPay returns payment instructions (account details or an Interac e-Transfer destination) plus a hosted checkout link you can send to your customer. When the customer pays, the amount less fees is credited to your wallet in that currency and a `collection.settled` [webhook](/docs/api-reference/webhooks) fires.

To choose between collections, checkouts and payment links, see the [Collections guide](/docs/collections).

## Create a collection

`POST /collections` requires the `collection.create` scope and supports [`Idempotency-Key`](/docs/api-reference/idempotency).

<TypeTable
  type={{
  currency: { type: 'string', description: 'Currency to collect, for example `CAD` or `NGN`.', required: true },
  amount: { type: 'number', description: 'Amount to collect, in major units, at most 2 decimals.', required: true },
  rail: {
    type: '"interac" | "ach" | "eft" | "virtual_account" | "dynamic_virtual_account"',
    description: 'How the customer pays. `dynamic_virtual_account` issues account details for this one payment; `virtual_account` uses your standing account for the currency.',
    required: true,
  },
  reference: { type: 'string', description: 'Your own reference for the collection.' },
  description: { type: 'string', description: 'What the payment is for. Shown to the payer and used as the transaction narration.' },
  expiresInMinutes: { type: 'integer', description: 'How long the payment instructions stay valid.' },
  metadata: { type: 'object', description: 'Your own data (order id, cart, customer id…), up to 4 KB. Returned exactly as sent on every webhook for this payment and on `GET /collections/{id}`.' },
  subMerchantId: { type: 'string (uuid)', description: 'Collect on behalf of one of your sub-merchants.' },
}}
/>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -X POST https://business-api.migrapay.com/v1/api/collections \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
      -H "Idempotency-Key: 4f1c2b8e-9d3a-4e7f-8b21-6a5c0d9e7f13" \
      -H "Content-Type: application/json" \
      -d '{
        "currency": "NGN",
        "amount": 25000,
        "rail": "dynamic_virtual_account",
        "reference": "INV-2048",
        "description": "Invoice 2048",
        "expiresInMinutes": 60
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    import { randomUUID } from 'node:crypto';

    const res = await fetch('https://business-api.migrapay.com/v1/api/collections', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}`,
        'Idempotency-Key': randomUUID(),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        currency: 'NGN',
        amount: 25000,
        rail: 'dynamic_virtual_account',
        reference: 'INV-2048',
        description: 'Invoice 2048',
      }),
    });
    const { data } = await res.json();
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    import uuid

    res = requests.post(
        "https://business-api.migrapay.com/v1/api/collections",
        headers={
            "Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={
            "currency": "NGN",
            "amount": 25000,
            "rail": "dynamic_virtual_account",
            "reference": "INV-2048",
            "description": "Invoice 2048",
        },
    )
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="201 Created"
{
  "status": "success",
  "message": "Payment request created successfully.",
  "data": {
    "id": "6b3e8f1a-2d4c-4e9b-8a7f-000000000001",
    "status": "awaiting_payment",
    "currency": "NGN",
    "amount": 25000,
    "rail": "dynamic_virtual_account",
    "instruction": {
      "method": "virtual_account",
      "details": {
        "accountNumber": "9912345678",
        "bankName": "Example Bank",
        "accountName": "Acme Ltd Checkout",
        "expiresAt": "2026-08-12T18:15:00.000Z"
      },
      "reference": "MPC-7KD2QX9A"
    },
    "publicToken": "pr_9Fh2kLm7Qx3Vb8Ns",
    "checkoutUrl": "https://pay.migrapay.com/pay/pr_9Fh2kLm7Qx3Vb8Ns"
  }
}
```

| Field         | Description                                                                                                                                                                                      |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `instruction` | How the customer pays. `method` is `virtual_account` or `interac`; `details` holds what to show them; `reference` is the payment reference the customer should quote, where the method uses one. |
| `checkoutUrl` | A hosted page with the same instructions. Send it to your customer instead of building your own screen.                                                                                          |
| `publicToken` | The token in `checkoutUrl`.                                                                                                                                                                      |

The fields in `instruction.details` depend on the method:

* **Account details** (`virtual_account`): `accountNumber`, `bankName`, `accountName` and, for one-time accounts, `expiresAt`. Some methods return a `paymentUrl` instead of `accountName`.
* **Interac** (`interac`): `depositMailbox` (the email address to send the e-Transfer to), `sendAmount`, `currency`, `reference` (the payment code for this request, the same value as `instruction.reference`) and `instructions`, a sentence you can show the payer as is. See [Interac e-Transfer](#interac-e-transfer).

```json title="instruction for a CAD Interac collection"
{
  "method": "interac",
  "details": {
    "depositMailbox": "etransfer@migrapay.ca",
    "sendAmount": 250,
    "currency": "CAD",
    "reference": "ACME7K-1001",
    "instructions": "Send exactly 250.00 CAD by Interac e-Transfer to etransfer@migrapay.ca and put ACME7K-1001 in the message."
  },
  "reference": "ACME7K-1001"
}
```

**Errors:** `400 VALIDATION_ERROR`, `404 SUB_MERCHANT_NOT_FOUND`, `422 SUB_MERCHANT_INACTIVE`, `422 BUSINESS_NOT_ACTIVE`, `422 UNSUPPORTED_PAYIN_RAIL` (collections are not available in that currency).

## Retrieve a collection

`GET /collections/{id}` requires `collection.view`.

```bash
curl https://business-api.migrapay.com/v1/api/collections/6b3e8f1a-2d4c-4e9b-8a7f-000000000001 \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Payment request retrieved successfully.",
  "data": {
    "id": "6b3e8f1a-2d4c-4e9b-8a7f-000000000001",
    "subMerchantId": null,
    "currency": "NGN",
    "amount": 25000,
    "rail": "dynamic_virtual_account",
    "status": "settled",
    "reference": "INV-2048",
    "description": "Invoice 2048",
    "customerName": "Chidi Eze",
    "createdAt": "2026-08-12T17:15:00.000Z",
    "grossAmount": 25000,
    "fee": 250,
    "net": 24750,
    "payer": { "name": "Chidi Eze", "email": null, "phone": null },
    "paymentCode": null,
    "source": {
      "sourceAccount": { "name": "CHIDI EZE", "bank": "Example Bank", "accountNumberMasked": "******4821" }
    },
    "settledAt": "2026-08-12T17:21:40.000Z",
    "refundedTotal": 0,
    "refundable": 25000
  }
}
```

| Field         | Description                                                                                                                                                                                                                                                          |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paymentCode` | Interac only: the code the payer was asked to put in the e-Transfer message. `null` for other rails.                                                                                                                                                                 |
| `payer`       | Who the collection was requested from: `name`, `email` and `phone`, as given when it was created.                                                                                                                                                                    |
| `source`      | What arrived with the money, once paid. Interac: `senderName`, `senderEmail`, `interacReference` (the e-Transfer's own reference number) and `message`. NGN account transfers: `sourceAccount` with `name`, `bank` and `accountNumberMasked`. `null` before payment. |

Unknown ids return `404 REQUEST_NOT_FOUND`. To find the wallet credit for a settled collection, search [transactions](/docs/transactions) with `activity=collection`; its `paymentRequestId` is the collection id.

## List collections

`GET /collections` returns your collections newest first, [paginated](/docs/api-reference/pagination). Requires `collection.view`.

<TypeTable
  type={{
  page: { type: 'integer', description: 'Page number, from 1.', default: '1' },
  pageSize: { type: 'integer', description: 'Items per page, up to 100.', default: '100' },
}}
/>

```json title="200 OK"
{
  "status": "success",
  "message": "Payment requests retrieved successfully.",
  "data": {
    "requests": [
      {
        "id": "6b3e8f1a-2d4c-4e9b-8a7f-000000000001",
        "currency": "NGN",
        "amount": 25000,
        "rail": "dynamic_virtual_account",
        "status": "settled",
        "customerName": "Chidi Eze",
        "createdAt": "2026-08-12T17:15:00.000Z",
        "reference": "INV-2048",
        "publicToken": "pr_9Fh2kLm7Qx3Vb8Ns"
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 100,
    "totalPages": 1
  }
}
```

`data.items` carries the same list as `data.requests`.

## Collection requirements

`GET /collections/requirements` lists, per currency and country, which collection methods you can request and the body fields each one takes. Requires `collection.view`.

<TypeTable
  type={{
  currency: { type: 'string', description: 'Only this currency, for example `CAD`.' },
  method: { type: 'string', description: 'Only this method: `interac`, `eft`, `ach`, `virtual_account` or `dynamic_virtual_account`.' },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/collections/requirements?currency=CAD&method=interac" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Collection requirements retrieved successfully.",
  "data": {
    "corridors": [
      {
        "currency": "CAD",
        "country": "Canada",
        "countryCode": "CA",
        "limits": { "min": 1, "max": null },
        "available": true,
        "rails": [
          {
            "key": "interac",
            "label": "Interac e-Transfer",
            "available": true,
            "notes": "Interac e-Transfer in Canadian dollars. The response tells the payer where to send the transfer and the code to include.",
            "fields": [
              { "name": "reference", "label": "Reference", "type": "string", "required": false, "example": "INV-1042", "helpText": "Your own reference for this collection." },
              { "name": "description", "label": "Description", "type": "string", "required": false, "example": "Invoice 1042", "helpText": "What the payment is for." },
              { "name": "customerName", "label": "Payer name", "type": "string", "required": false, "maxLength": 200, "example": "Ada Obi" },
              { "name": "payerEmail", "label": "Payer email", "type": "email", "required": false, "example": "ada@example.com", "helpText": "The email the payer sends the e-Transfer from. Used to match the payment to this request." },
              { "name": "payerPhone", "label": "Payer phone", "type": "phone", "required": false, "maxLength": 32, "example": "+2348012345678" },
              { "name": "expiresInMinutes", "label": "Expires in (minutes)", "type": "number", "required": false, "pattern": "^[1-9][0-9]*$", "example": "60", "helpText": "Whole minutes until the request expires. Leave out for no expiry." }
            ]
          }
        ]
      }
    ]
  }
}
```

Each field has `name`, `label`, `type` (`string`, `number`, `select`, `phone` or `email`), `required`, an `example`, and where relevant `pattern`, `minLength`, `maxLength` and `helpText`. `limits.min` is the smallest amount you can request. When a currency or a method is switched off, `available` is `false` and `reason` is `CURRENCY_DISABLED` or `RAIL_DISABLED`.

## Settle in Sandbox

`POST /collections/{id}/mock-settle` settles a Sandbox collection as if the customer had paid. Requires `collection.create` and a Sandbox key; a Live key gets `403 MOCK_REQUIRES_SANDBOX`.

The collection moves to `settled`, its amount less your collection fee is credited to your Sandbox wallet, a `collection` [transaction](/docs/api-reference/transactions) is recorded, and `collection.settled` is sent to your webhooks with `"mode": "sandbox"` and a realistic `funding` object.

<TypeTable
  type={{
  amount: { type: 'number', description: 'Amount the payer sent, if different from the collection amount.' },
  customerName: { type: 'string', description: 'Payer name recorded on the transaction.', default: '"Mock Payer"' },
  customerEmail: { type: 'string', description: 'Payer email recorded on the transaction.', default: '"mock.payer@example.com"' },
}}
/>

```bash
curl -X POST https://business-api.migrapay.com/v1/api/collections/6b3e8f1a-2d4c-4e9b-8a7f-000000000001/mock-settle \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "customerName": "Chidi Eze", "customerEmail": "chidi@example.com" }'
```

```json title="200 OK"
{
  "status": "success",
  "message": "Collection mock-settled successfully.",
  "data": {
    "collectionId": "6b3e8f1a-2d4c-4e9b-8a7f-000000000001",
    "net": 24750,
    "fee": 250
  }
}
```

`net` is what was credited and `fee` the collection fee. Calling it again on a settled collection returns the same result without crediting twice. Expired, failed or refunded collections return `422 NOT_COLLECTABLE`; unknown ids return `404 REQUEST_NOT_FOUND`.

## Collection lifecycle

<Lifecycle
  stages={[
  { status: 'awaiting_payment', text: 'Instructions issued; waiting for the customer.', tone: 'neutral' },
  { status: 'processing', text: 'Payment received and being confirmed.', tone: 'info' },
  { status: 'settled', text: 'Funds credited to your wallet. collection.settled fires.', tone: 'success' },
  { status: 'expired', text: 'Not paid in time.', tone: 'neutral' },
  { status: 'failed', text: 'The payment could not be completed.', tone: 'danger' },
  { status: 'refunded', text: 'Returned to the payer after settling. collection.refunded fires.', tone: 'danger' },
]}
/>


---

# Errors

MigraPay uses standard HTTP status codes and a consistent JSON envelope to help you diagnose failed requests.

Source: https://www.migrapay.com/docs/api-reference/errors

## Error response

Failed requests use the same envelope as successful ones. `status` is `"error"` and `message` is a machine-readable error code you can branch on. `data` adds detail where it helps, often a readable `message` you can show or log.

```json title="422 Unprocessable Entity"
{
  "status": "error",
  "message": "RECIPIENT_INACTIVE",
  "data": { "message": "This recipient is inactive. Reactivate it to send money." }
}
```

### Validation errors

When the body or query fails validation, `data` maps each invalid field (as a dotted path) to the first problem found with it:

```json title="400 Bad Request"
{
  "status": "error",
  "message": "VALIDATION_ERROR",
  "data": {
    "currency": "String must contain exactly 3 character(s)",
    "destination.accountNumber": "Account number is required"
  }
}
```

## HTTP status codes

| Status | Meaning              | What to do                                                                                                                  |
| ------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| 400    | Bad Request          | The request is invalid. Fix the fields listed in `data` before retrying.                                                    |
| 401    | Unauthorized         | The API key is missing or invalid. Check the key and its environment.                                                       |
| 403    | Forbidden            | The key lacks the scope, or the action is not allowed for this key.                                                         |
| 404    | Not Found            | The resource does not exist in this environment. Check the id.                                                              |
| 409    | Conflict             | The value is already in use (`ALREADY_EXISTS`), or an `Idempotency-Key` clash.                                              |
| 422    | Unprocessable Entity | Well-formed but not possible right now, such as an insufficient balance, an unsupported corridor or an account restriction. |
| 429    | Too Many Requests    | You hit the [rate limit](/docs/api-reference/rate-limits) (`RATE_LIMITED`). Wait for `Retry-After`.                         |
| 500    | Server Error         | Something went wrong on our side (`SOMETHING_WENT_WRONG`).                                                                  |
| 502    | Bad Gateway          | The receiving network rejected or failed the payout. Funds are returned to your wallet.                                     |
| 503    | Service Unavailable  | Rates or a payout route are temporarily unavailable. Retry later.                                                           |

## Common error codes

| Code                      | Status | Returned when                                                                                                                                                                                                                                                                               |
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`        | 400    | Required fields are missing or malformed.                                                                                                                                                                                                                                                   |
| `INVALID_JSON`            | 400    | The request body is not valid JSON.                                                                                                                                                                                                                                                         |
| `IDEMPOTENCY_KEY_INVALID` | 400    | The `Idempotency-Key` is longer than 255 characters.                                                                                                                                                                                                                                        |
| `IDEMPOTENCY_KEY_REUSED`  | 409    | The `Idempotency-Key` was already used with a different body.                                                                                                                                                                                                                               |
| `IDEMPOTENCY_KEY_IN_USE`  | 409    | A request with the same `Idempotency-Key` is still running.                                                                                                                                                                                                                                 |
| `RATE_LIMITED`            | 429    | Too many requests. See [Rate limits](/docs/api-reference/rate-limits).                                                                                                                                                                                                                      |
| `missing_api_key`         | 401    | No API key was sent.                                                                                                                                                                                                                                                                        |
| `invalid_api_key`         | 401    | The API key is unknown or revoked.                                                                                                                                                                                                                                                          |
| `insufficient_scope`      | 403    | The key lacks the scope for this endpoint.                                                                                                                                                                                                                                                  |
| `INSUFFICIENT_BALANCE`    | 422    | The source wallet cannot cover the payout and its fees.                                                                                                                                                                                                                                     |
| `AMOUNT_BELOW_MINIMUM`    | 400    | The payout is under the corridor minimum (`data.min`, `data.currency`).                                                                                                                                                                                                                     |
| `AMOUNT_ABOVE_MAXIMUM`    | 400    | The payout is over the corridor maximum (`data.max`, `data.currency`).                                                                                                                                                                                                                      |
| `UNSUPPORTED_CORRIDOR`    | 422    | Payouts to this currency or country are not available.                                                                                                                                                                                                                                      |
| `RAIL_NOT_AVAILABLE`      | 422    | The payout method is not available for this currency.                                                                                                                                                                                                                                       |
| `INSTITUTION_UNKNOWN`     | 422    | The bank or mobile money code is not recognised. Use [`GET /payouts/banks`](/docs/api-reference/recipients#banks-and-networks).                                                                                                                                                             |
| `ACCOUNT_NOT_VERIFIED`    | 422    | The recipient's bank could not confirm the account (NGN bank recipients and payouts, `validate-account`). `data.message` is a sentence you can show your user. Check the bank code and account number. See [Bank-confirmed names](/docs/api-reference/recipients#bank-confirmed-names-ngn). |
| `WALLET_NOT_FOUND`        | 404    | You have no wallet in that currency.                                                                                                                                                                                                                                                        |
| `RECIPIENT_NOT_FOUND`     | 404    | No recipient with that id in this environment.                                                                                                                                                                                                                                              |
| `TRANSACTION_NOT_FOUND`   | 404    | No payout or transaction with that id or reference.                                                                                                                                                                                                                                         |
| `REQUEST_NOT_FOUND`       | 404    | No collection with that id.                                                                                                                                                                                                                                                                 |
| `business_restricted`     | 422    | Payouts are paused on your account. Contact support.                                                                                                                                                                                                                                        |
| `limit_exceeded`          | 422    | The payout is over a limit set on your account.                                                                                                                                                                                                                                             |
| `PAYOUT_FAILED`           | 502    | The receiving network declined the payout. Your wallet is credited back.                                                                                                                                                                                                                    |
| `RATES_UNAVAILABLE`       | 503    | Exchange rates are temporarily unavailable.                                                                                                                                                                                                                                                 |
| `TEMPORARILY_UNAVAILABLE` | 503    | This route is briefly unavailable. Retry later.                                                                                                                                                                                                                                             |
| `UPSTREAM_UNAVAILABLE`    | 503    | We could not reach the payout network. Retry later.                                                                                                                                                                                                                                         |
| `SOMETHING_WENT_WRONG`    | 500    | An unexpected error.                                                                                                                                                                                                                                                                        |

Each endpoint page lists the errors specific to it.

## Retry guidance

* **Do not retry 4xx errors unchanged.** Fix the request first. The exception is 429: wait for `Retry-After`, then retry.
* **Retry 500 and 503 with exponential backoff** for reads, and for `POST /collections` and `POST /payouts` sent with an [`Idempotency-Key`](/docs/api-reference/idempotency).
* **Never retry a payout sent without an `Idempotency-Key`** until you have checked [`GET /payouts`](/docs/api-reference/payouts#list-payouts). A timeout does not mean it failed, and you may pay twice.
* Log the full response body (status, `message` and `data`) of every failed call; include it when you contact support.


---

# Idempotency

Retry requests safely without creating duplicates.

Source: https://www.migrapay.com/docs/api-reference/idempotency

Networks fail. An idempotency key lets you retry a request after a timeout without creating the same thing twice.

## Which endpoints support it

| Endpoint            | `Idempotency-Key`                       |
| ------------------- | --------------------------------------- |
| `POST /collections` | Supported                               |
| `POST /payouts`     | Supported                               |
| `POST /recipients`  | Not supported                           |
| All `GET` requests  | Not needed: reads never change anything |

## Using a key

Send a unique value, such as a UUID v4 you generate, in the `Idempotency-Key` header:

```bash
curl -X POST https://business-api.migrapay.com/v1/api/collections \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
  -H "Idempotency-Key: 4f1c2b8e-9d3a-4e7f-8b21-6a5c0d9e7f13" \
  -H "Content-Type: application/json" \
  -d '{ "currency": "NGN", "amount": 25000, "rail": "dynamic_virtual_account" }'
```

Keys belong to your business: another business using the same value never collides with yours.

## Collections

If you send the same key again, MigraPay returns the collection created by the first request (the same `id`) instead of creating a new one.

* Generate a new key for every new collection, and reuse it only to retry that exact request.
* Collection keys do not expire. Never reuse one for a different collection.
* Without the header, every request creates a new collection.

## Payouts

Send an `Idempotency-Key` with every `POST /payouts`. Keys are kept for **24 hours**.

| You send                                  | You get                                                                                     |
| ----------------------------------------- | ------------------------------------------------------------------------------------------- |
| A new key                                 | The payout is created as normal.                                                            |
| The same key and the same body            | The original response again, with the header `Idempotent-Replayed: true`. No second payout. |
| The same key and a different body         | `409 IDEMPOTENCY_KEY_REUSED`. Nothing is created.                                           |
| The same key while the first request runs | `409 IDEMPOTENCY_KEY_IN_USE`. Wait a moment and retry.                                      |
| A key longer than 255 characters          | `400 IDEMPOTENCY_KEY_INVALID`.                                                              |

If the first attempt failed (any non-2xx response), the key is released, so you can fix the request and retry with the same key.

<Callout type="info">
  The body comparison is exact: send the same fields in the same order when you retry.
</Callout>

## Without a key

A payout sent without `Idempotency-Key` is not protected. If such a request times out, check whether it went through before sending it again: look in [`GET /payouts`](/docs/api-reference/payouts#list-payouts), or search [`GET /transactions?activity=transfer&q=…`](/docs/transactions) by the `narration` you sent.

<Callout type="warn">
  Retrying a payout without a key, and without checking first, can pay the recipient twice.
</Callout>


---

# Pagination

Page through long lists with page and pageSize.

Source: https://www.migrapay.com/docs/api-reference/pagination

[`GET /transactions`](/docs/transactions) is paginated with page numbers. Other list endpoints return a bounded list in one response, described below.

## Parameters

<TypeTable
  type={{
  page: {
    type: 'integer',
    description: 'The page to return, starting at 1.',
    default: '1',
  },
  pageSize: {
    type: 'integer',
    description: 'Items per page. Maximum 100.',
    default: '20',
  },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/transactions?page=2&pageSize=50" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

## Response shape

Results are newest first. The page sits in `data.docs`, alongside the totals you need to build a pager:

```json
{
  "status": "success",
  "message": "Transactions retrieved successfully.",
  "data": {
    "docs": [],
    "totalDocs": 132,
    "page": 2,
    "totalPages": 3,
    "counts": { "all": 132, "transfer": 97, "collection": 30, "fee": 5 }
  }
}
```

| Field        | Description                                                                                    |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `docs`       | The items on this page.                                                                        |
| `totalDocs`  | Items matching your filters, across all pages.                                                 |
| `page`       | The page returned.                                                                             |
| `totalPages` | Pages available at the current `pageSize`.                                                     |
| `counts`     | Totals per activity type for the whole account. Not affected by filters, handy for tab badges. |

To read everything, request `page=1, 2, 3…` until `page` equals `totalPages`. New transactions arriving while you page shift later pages, so when syncing, filter by a fixed `endDate` first.

## Bounded lists

| Endpoint           | Returns                                                                                |
| ------------------ | -------------------------------------------------------------------------------------- |
| `GET /payouts`     | The 100 most recent payouts. For older ones use `GET /transactions?activity=transfer`. |
| `GET /collections` | The 200 most recent collections.                                                       |
| `GET /recipients`  | All recipients.                                                                        |
| `GET /wallets`     | All wallets.                                                                           |


---

# Payouts

Send funds from a wallet to a saved recipient, with currency conversion built in.

Source: https://www.migrapay.com/docs/api-reference/payouts

<Endpoints
  items={[
  { method: 'POST', path: '/payouts', title: 'Create a payout', href: '#create-a-payout' },
  { method: 'GET', path: '/payouts/{id}', title: 'Retrieve a payout', href: '#retrieve-a-payout' },
  { method: 'GET', path: '/payouts', title: 'List payouts', href: '#list-payouts' },
]}
/>

A payout debits one of your wallets and pays a [recipient](/docs/recipients). If the wallet currency differs from the recipient's, MigraPay converts at the rate you can preview with [`GET /payouts/estimate`](/docs/api-reference/rates#estimate-a-payout).

## Create a payout

`POST /payouts` requires the `payment.create` scope.

<TypeTable
  type={{
  fromCurrency: { type: 'string', description: 'Currency of the wallet to debit, for example `CAD`.', required: true },
  amount: { type: 'number', description: 'Amount to send, in major units of `fromCurrency`. Fees are added on top.', required: true },
  recipientId: { type: 'string (uuid)', description: 'The recipient to pay. Its currency decides what they receive.', required: true },
  narration: { type: 'string', description: 'Free-text description, also searchable with `GET /transactions?q=`. Put your own reference here.' },
}}
/>

<Callout type="info" title="Send an Idempotency-Key">
  Add an `Idempotency-Key` header to every payout. If the request times out, retry with the same key and body: you get the original payout back instead of paying twice. See [Idempotency](/docs/api-reference/idempotency#payouts).
</Callout>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -X POST https://business-api.migrapay.com/v1/api/payouts \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "fromCurrency": "CAD",
        "amount": 250,
        "recipientId": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001",
        "narration": "INV-2048"
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/payouts', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fromCurrency: 'CAD',
        amount: 250,
        recipientId: '8d1e5b2a-6c4f-4a9e-b3d7-000000000001',
        narration: 'INV-2048',
      }),
    });
    const { data } = await res.json();
    console.log(data.transaction.status);
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    res = requests.post(
        "https://business-api.migrapay.com/v1/api/payouts",
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
        json={
            "fromCurrency": "CAD",
            "amount": 250,
            "recipientId": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001",
            "narration": "INV-2048",
        },
    )
    print(res.json()["data"]["transaction"]["status"])
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="201 Created"
{
  "status": "success",
  "message": "Payment created successfully.",
  "data": {
    "transaction": {
      "id": "a41c7e92-3b5d-4f08-8c6e-000000000001",
      "reference": "20260812160811-K7Q2M9X4TD",
      "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
      "walletId": "3f6c2a10-8b1d-4e5f-9a7c-000000000001",
      "transactionType": "debit",
      "businessTransactionType": "transfer",
      "channel": "bank",
      "status": "processing",
      "statusLog": [
        { "status": "pending", "createdAt": "2026-08-12T16:08:11.000Z" },
        { "status": "processing", "createdAt": "2026-08-12T16:08:12.000Z" }
      ],
      "amount": 250,
      "netAmount": 252.5,
      "fee": 2.5,
      "transactionFees": [{ "feeType": "userFee", "fee": 2.5, "currency": "CAD" }],
      "currency": "CAD",
      "balance": 12332,
      "paymentInitiated": true,
      "narration": "INV-2048",
      "customerEmail": null,
      "customerName": null,
      "metadata": {
        "toCurrency": "NGN",
        "toAmount": 271050,
        "rate": 1084.2,
        "formattedRate": "CAD 1.00 = NGN 1,084.20"
      },
      "paymentRequestId": null,
      "recipientId": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001",
      "conversionPairId": null,
      "approvalRequestId": null,
      "createdBy": "e5b8c3d1-7f2a-4b6e-a9d4-000000000001",
      "createdAt": "2026-08-12T16:08:11.000Z",
      "updatedAt": "2026-08-12T16:08:12.000Z"
    },
    "approvalRequired": false
  }
}
```

The payout is a [transaction](/docs/api-reference/transactions#the-transaction-object): `amount` is what you asked to send, `fee` the fees, and `netAmount` the total debited from your wallet. What the recipient gets is in `metadata.toAmount` (`metadata.toCurrency`), rounded to that currency's precision.

### Approvals

If your business has approval rules that match this payout, nothing is debited yet. The response has `"approvalRequired": true`, an `approvalRequestId`, and the transaction status `pending_approval`. Once an approver signs off in the dashboard the payout proceeds and your [webhooks](/docs/webhooks) report the outcome.

## Payout lifecycle

<Lifecycle
  stages={[
  { status: 'pending_approval', text: 'Waiting for an approver. Only when approval rules apply.', tone: 'neutral' },
  { status: 'processing', text: 'Wallet debited; funds are on their way.', tone: 'info' },
  { status: 'successful', text: 'Delivered. payment.settled fires.', tone: 'success' },
  { status: 'failed', text: 'Not delivered. Your wallet is credited back; payment.failed fires.', tone: 'danger' },
  { status: 'reversed', text: 'Returned after sending. Wallet credited; payment.reversed fires.', tone: 'danger' },
]}
/>

Some routes settle instantly, so a payout can already be `successful` in the create response. Use [webhooks](/docs/webhooks) rather than polling to follow the rest.

<Callout type="warn">
  Delivery time depends on the payout route and the recipient's bank or network. Use webhooks to track status changes.
</Callout>

### Errors

| Status   | Code                     | When                                                                                                                         |
| -------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| 400      | `VALIDATION_ERROR`       | `fromCurrency`, `amount` or `recipientId` is missing.                                                                        |
| 400      | `INVALID_AMOUNT`         | `amount` is not a positive number.                                                                                           |
| 400      | `AMOUNT_TOO_SMALL`       | The converted amount rounds to zero.                                                                                         |
| 400      | `AMOUNT_BELOW_MINIMUM`   | Below the corridor minimum. `data` has `min` and `currency`.                                                                 |
| 400      | `AMOUNT_ABOVE_MAXIMUM`   | Above the corridor maximum. `data` has `max` and `currency`.                                                                 |
| 404      | `RECIPIENT_NOT_FOUND`    | No recipient with that id in this environment.                                                                               |
| 404      | `WALLET_NOT_FOUND`       | You have no wallet in `fromCurrency`.                                                                                        |
| 409      | `IDEMPOTENCY_KEY_REUSED` | The `Idempotency-Key` was already used with a different body.                                                                |
| 409      | `IDEMPOTENCY_KEY_IN_USE` | A request with this `Idempotency-Key` is still running.                                                                      |
| 422      | `INSUFFICIENT_BALANCE`   | The wallet cannot cover `amount` plus fees.                                                                                  |
| 422      | `BUSINESS_NOT_ACTIVE`    | Your business is not yet approved for payouts.                                                                               |
| 422      | `business_restricted`    | Payouts are paused on your account.                                                                                          |
| 422      | `limit_exceeded`         | Over a limit set on your account. `data` has `limit`, `currency`, `value` and `projected`.                                   |
| 422      | `RECIPIENT_INACTIVE`     | The recipient has been deactivated.                                                                                          |
| 422      | `RECIPIENT_FLAGGED`      | The recipient cannot be paid.                                                                                                |
| 422      | `UNSUPPORTED_CORRIDOR`   | Payouts to this currency, country or rail are not available.                                                                 |
| 422      | `RAIL_DISABLED`          | The recipient's payout method is switched off for this currency.                                                             |
| 422      | `BANK_NOT_SUPPORTED`     | This bank or network cannot be paid right now.                                                                               |
| 502      | `PROVIDER_DECLINED`      | The payout was declined before it was sent. Your wallet has been credited back.                                              |
| 502      | `PAYOUT_FAILED`          | The receiving network declined the payout. Your wallet has been credited back.                                               |
| 502, 503 | `PROVIDER_ERROR`         | The payout could not be sent. Your wallet has been credited back. `data.retryable` says whether trying again later may help. |

## Retrieve a payout

`GET /payouts/{id}` accepts the payout's `id` or its `reference`. Requires `payment.view`.

```bash
curl https://business-api.migrapay.com/v1/api/payouts/20260812160811-K7Q2M9X4TD \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Payment retrieved successfully.",
  "data": {
    "transaction": { "id": "a41c7e92-3b5d-4f08-8c6e-000000000001", "status": "successful", "…": "…" },
    "history": [
      { "status": "pending", "createdAt": "2026-08-12T16:08:11.000Z" },
      { "status": "processing", "createdAt": "2026-08-12T16:08:12.000Z" },
      { "status": "successful", "createdAt": "2026-08-12T16:09:40.000Z" }
    ]
  }
}
```

`history` is the payout's status timeline. Unknown ids return `404 TRANSACTION_NOT_FOUND`.

### Interac security question

CAD payouts sent by Interac e-Transfer carry a security question and answer. Recipients whose bank account has Interac autodeposit receive the money without it; everyone else is asked the question before they can accept the transfer. `GET /payouts/{id}` returns the pair as `interac` so you can share it with the recipient, for example in your own payout notification:

```json title="200 OK (CAD Interac payout)"
{
  "status": "success",
  "message": "Payment retrieved successfully.",
  "data": {
    "transaction": { "id": "1ac49cce-8df6-480b-9ce2-0733e081535c", "channel": "interac", "currency": "CAD", "…": "…" },
    "history": [{ "status": "pending", "createdAt": "2026-10-16T12:33:25.000Z" }],
    "interac": {
      "securityQuestion": "What is the security code?",
      "securityAnswer": "3JMNBX5"
    }
  }
}
```

If the recipient was saved with its own `securityQuestion` and `securityAnswer`, that pair is used; otherwise one is generated for each payout. The pair is fixed once the payout is sent and stays the same if it is retried. `interac` is absent for every other payout, and only this endpoint returns it (never lists or webhooks). Treat the answer like a password: share it with the recipient only.

## List payouts

`GET /payouts` returns your 100 most recent payouts, newest first, as `data.payments`. Requires `payment.view`. It takes no filters; to filter or page further back, use [`GET /transactions?activity=transfer`](/docs/transactions).

```bash
curl https://business-api.migrapay.com/v1/api/payouts \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Payments retrieved successfully.",
  "data": { "payments": [{ "id": "a41c7e92-3b5d-4f08-8c6e-000000000001", "…": "…" }] }
}
```

<Callout title="Older integrations">
  `/payments` is an alias of `/payouts` (same requests, same responses). `POST /payouts` also accepts the older field names `currency` or `sourceCurrency` for `fromCurrency`, `sourceAmount` for `amount` and `beneficiaryId` for `recipientId`. New integrations should use `/payouts` and the names above.
</Callout>


---

# Rate limits

How many requests you can make and how to tell when you are close to the limit.

Source: https://www.migrapay.com/docs/api-reference/rate-limits

The API allows **300 requests per minute per client IP address**, counted across all endpoints in fixed one-minute windows. The limit is per IP, not per API key, so services that share an outbound IP share the budget.

## Rate limit headers

Every response tells you where you stand:

| Header                | Description                                                   |
| --------------------- | ------------------------------------------------------------- |
| `RateLimit-Limit`     | Requests allowed in the window (`300`).                       |
| `RateLimit-Remaining` | Requests left in the current window.                          |
| `RateLimit-Reset`     | Seconds until the window resets.                              |
| `RateLimit-Policy`    | The policy in effect, for example `300;w=60`.                 |
| `Retry-After`         | Only on `429` responses: seconds to wait before trying again. |

```http
HTTP/1.1 200 OK
RateLimit-Policy: 300;w=60
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 41
```

## When you hit the limit

Requests over the limit get `429 Too Many Requests` with a `Retry-After` header and the usual JSON envelope:

```json title="429 Too Many Requests"
{
  "status": "error",
  "message": "RATE_LIMITED",
  "data": { "message": "Too many requests. Slow down and retry after the time in the RateLimit-Reset header." }
}
```

## Staying under the limit

* Wait for `Retry-After` seconds before retrying a 429, then back off exponentially if it repeats.
* Watch `RateLimit-Remaining` and slow down before it reaches zero.
* Use [webhooks](/docs/webhooks) instead of polling for payout and collection status.
* Cache data that rarely changes, such as [bank lists](/docs/api-reference/recipients#banks-and-networks) and [payout requirements](/docs/api-reference/recipients#payout-requirements).


---

# Rates & estimates

Show live exchange rates and the exact amount a recipient will get before you send.

Source: https://www.migrapay.com/docs/api-reference/rates

<Endpoints
  items={[
  { method: 'GET', path: '/rates', title: 'Mid-market rates', href: '#mid-market-rates' },
  { method: 'GET', path: '/payouts/estimate', title: 'Estimate a payout', href: '#estimate-a-payout' },
]}
/>

Both endpoints require the `payment.view` scope.

## Mid-market rates

`GET /rates` returns indicative mid-market rates against a base currency. Use them for display; the rate a payout actually gets comes from [an estimate](#estimate-a-payout).

<TypeTable
  type={{
  base: { type: 'string', description: 'Base currency code.', default: 'USD' },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/rates?base=CAD" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Exchange rates retrieved successfully.",
  "data": {
    "base": "CAD",
    "rates": { "CAD": 1, "USD": 0.731402, "NGN": 1096.512344, "GHS": 8.946021 },
    "previous": { "CAD": 1, "USD": 0.729861, "NGN": 1093.208713, "GHS": 8.951180 },
    "refreshedAt": "2026-08-12T16:00:00.000Z"
  }
}
```

`rates` maps each currency to the amount of it one unit of `base` buys (up to 6 decimals). `previous` holds the prior snapshot so you can show movement. Errors: `400 UNSUPPORTED_BASE_CURRENCY`, `503 RATES_UNAVAILABLE`.

## Estimate a payout

`GET /payouts/estimate` prices a payout with MigraPay's rate and fees, either from the amount you send or from the amount the recipient should get.

<TypeTable
  type={{
  from: { type: 'string', description: 'Source currency (your wallet).', required: true },
  to: { type: 'string', description: 'Destination currency (the recipient’s).', required: true },
  amount: { type: 'number', description: 'Greater than 0. Interpreted according to `action`.', required: true },
  action: {
    type: '"youSend" | "receiverGets"',
    description: '`youSend`: `amount` is what leaves your wallet. `receiverGets`: `amount` is what the recipient should receive.',
    default: '"youSend"',
  },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/payouts/estimate?from=CAD&to=NGN&amount=250" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Estimate computed successfully.",
  "data": {
    "fromCurrency": "CAD",
    "fromAmount": 250,
    "toCurrency": "NGN",
    "toAmount": 271050,
    "fees": 0,
    "amountWithFee": 250,
    "commission": 0,
    "commissionPercentage": 0,
    "payoutRate": 1084.2,
    "payinRate": 0.000909,
    "formattedRate": "CAD 1.00 = NGN 1,084.20",
    "isToCurrencyHigherInValue": false,
    "reversedEstimate": null,
    "action": "youSend"
  }
}
```

| Field                       | Description                                                                                                                                                                             |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fromAmount`, `toAmount`    | What leaves your wallet and what the recipient gets, in major units.                                                                                                                    |
| `fees`                      | Fixed fee for this corridor, in the source currency.                                                                                                                                    |
| `amountWithFee`             | Total debited from your wallet.                                                                                                                                                         |
| `payoutRate`                | Destination units per one source unit, after MigraPay’s margin.                                                                                                                         |
| `formattedRate`             | Rate ready for display.                                                                                                                                                                 |
| `isToCurrencyHigherInValue` | `true` when the destination currency is worth more per unit (for example NGN → CAD). The display rate then reads the other way round, and `reversedEstimate` carries the inverse quote. |

<Callout title="Holding a quote">
  Each estimate is held for **60 seconds**. A payout for the same currencies and the same source amount (`fromAmount`) created within that minute delivers the quoted `toAmount`, as long as the rate has not moved. With `receiverGets`, send the returned `fromAmount` as the payout `amount`.
</Callout>

**Errors:** `400 VALIDATION_ERROR`, `400 INVALID_AMOUNT`, `503 RATES_UNAVAILABLE`, `503 RATE_UNAVAILABLE` (`data.currency` names the currency without a rate).


---

# Recipients

Save the people and businesses you pay, with the account details for one payout method.

Source: https://www.migrapay.com/docs/api-reference/recipients

<Endpoints
  items={[
  { method: 'POST', path: '/recipients', title: 'Create a recipient', href: '#create-a-recipient' },
  { method: 'GET', path: '/recipients', title: 'List recipients', href: '#list-recipients' },
  { method: 'GET', path: '/payouts/requirements', title: 'Payout methods and required fields', href: '#payout-requirements' },
  { method: 'GET', path: '/payouts/banks', title: 'Banks and mobile money networks', href: '#banks-and-networks' },
  { method: 'POST', path: '/payouts/validate-account', title: 'Look up an account holder', href: '#validate-an-account' },
]}
/>

A recipient pairs a currency with a payout method (a **rail**, such as `bank`, `mobile_money` or `interac`) and the destination details that rail needs. Create the recipient once and pay it as often as you like with [payouts](/docs/payouts).

The recommended flow:

<Steps>
  <Step>
    Call [`GET /payouts/requirements`](#payout-requirements) to see which rails a currency supports and which `destination` fields each needs.
  </Step>

  <Step>
    For bank and mobile money rails, get the institution code from [`GET /payouts/banks`](#banks-and-networks).
  </Step>

  <Step>
    Where supported, confirm the account holder's name with [`POST /payouts/validate-account`](#validate-an-account).
  </Step>

  <Step>
    [Create the recipient](#create-a-recipient).
  </Step>
</Steps>

## Payout requirements

`GET /payouts/requirements` describes every payout corridor: the rails available for a currency, the fields each rail's `destination` needs, validation patterns, examples and limits. Requires `payment.view`. Build your recipient forms from it rather than hard-coding fields; it changes as new corridors open.

<TypeTable
  type={{
  currency: { type: 'string', description: 'Only this currency.' },
  rail: { type: 'string', description: 'Only this rail, for example `bank`.' },
  available: { type: '"true"', description: 'Only corridors and rails you can pay into right now. By default unavailable ones are included and flagged.' },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/payouts/requirements?currency=NGN&available=true" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Recipient requirements retrieved successfully.",
  "data": {
    "corridors": [
      {
        "currency": "NGN",
        "country": "Nigeria",
        "countryCode": "NG",
        "prefix": "+234",
        "supportsAccountResolve": true,
        "limits": { "min": 1000, "max": 10000000 },
        "available": true,
        "rails": [
          {
            "key": "bank",
            "label": "Bank transfer",
            "fields": [
              {
                "name": "bankCode",
                "label": "Bank",
                "type": "select",
                "required": true,
                "pattern": "^[A-Za-z0-9][A-Za-z0-9 _.-]{1,63}$",
                "minLength": 2,
                "maxLength": 64,
                "example": "058",
                "helpText": "The recipient's bank, as a code from the bank list for the payout currency. Use the code exactly as the list returns it rather than typing your own.",
                "optionsSource": "banks"
              },
              {
                "name": "accountNumber",
                "label": "Account number",
                "type": "string",
                "required": true,
                "pattern": "^[0-9]{10}$",
                "minLength": 10,
                "maxLength": 10,
                "example": "0123456789",
                "helpText": "The Nigerian account number (NUBAN), exactly 10 digits. Verify the account first to confirm the account holder's name."
              },
              {
                "name": "accountName",
                "label": "Account name",
                "type": "string",
                "required": true,
                "pattern": "^[A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9 .,'&-]{1,99}$",
                "minLength": 2,
                "maxLength": 100,
                "example": "Ada Obi",
                "helpText": "The account holder's name exactly as the bank holds it. Where account verification is available, we look the name up for you; otherwise type it carefully."
              }
            ],
            "notes": "Pay out to a local bank account. You need the recipient's bank, account number and account name exactly as the bank holds it. For Nigerian naira accounts you can look up the account name before sending.",
            "available": true
          }
        ]
      }
    ]
  }
}
```

The example is trimmed to the three required `bank` fields; the real response lists the optional ones too. Each field has a `name` (the key to use in `destination`), `label`, `type` (`string`, `number`, `select`, `phone` or `email`), `required` and `example`, and may add `pattern`, `minLength`, `maxLength`, `helpText`, `options`, `optionsSource` (`banks` or `momo_networks`: fetch them from [`/payouts/banks`](#banks-and-networks)), `checksum` (`iban`, `clabe` or `country`) and `aliases`. Unavailable corridors and rails carry `available: false` and a `reason` (`CURRENCY_DISABLED` or `RAIL_DISABLED`). `limits` are per-payout amounts in the destination currency.

### Rails

| Rail                  | Typical use              | Required `destination` fields                                                                     |
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
| `bank`                | Local bank transfer      | `bankCode`, `accountNumber`, `accountName`                                                        |
| `mobile_money`        | Mobile money wallet      | `mno`, `accountNumber`, `accountName`                                                             |
| `interac`             | Interac e-Transfer (CAD) | `email`                                                                                           |
| `eft`                 | Canadian EFT             | `institutionNumber`, `transitNumber`, `accountNumber`, `institutionName`                          |
| `ach`, `ach_same_day` | US ACH                   | `accountNumber`, `routingNumber`, `accountName`                                                   |
| `wire`                | Domestic wire            | `accountNumber`, `routingNumber`                                                                  |
| `swift`               | International wire       | `bic`, `accountNumber`                                                                            |
| `sepa`                | SEPA (EUR)               | `iban`, `accountName`                                                                             |
| `faster_payments`     | UK Faster Payments       | `sortCode`, `accountNumber`, `accountName`                                                        |
| `spei`                | SPEI (MXN)               | `clabe`                                                                                           |
| `pix`                 | PIX (BRL)                | `pixKey`, `accountName`                                                                           |
| `co_bank_transfer`    | Colombian bank transfer  | `documentType`, `documentNumber`, `accountNumber`, `bankCode`, `accountTypeDetail`, `phoneNumber` |
| `bre_b`               | Bre-B (COP)              | `breBKey`, `accountName`                                                                          |

Which rails you can use depends on the currency and on what is enabled for your account; the requirements endpoint is the source of truth.

## Banks and networks

`GET /payouts/banks` lists the banks and mobile money networks you can pay into for a currency. Requires `payment.view`. Use the returned `code` as `destination.bankCode` (banks) or `destination.mno` (mobile money).

<TypeTable
  type={{
  currency: { type: 'string', description: 'Destination currency.', required: true },
  country: { type: 'string', description: 'ISO country code, for currencies used in several countries (for example XOF).' },
  type: { type: '"bank" | "mobile_money"', description: 'Only banks or only mobile money networks. Both by default.' },
}}
/>

```bash
curl "https://business-api.migrapay.com/v1/api/payouts/banks?currency=NGN&type=bank" \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Banks retrieved successfully.",
  "data": {
    "banks": [
      { "code": "044", "name": "Access Bank", "kind": "bank", "country": "NG" },
      { "code": "058", "name": "Guaranty Trust Bank", "kind": "bank", "country": "NG" }
    ]
  }
}
```

Missing `currency` returns `400 CURRENCY_REQUIRED`. The list changes rarely; cache it for a few hours.

## Validate an account

`POST /payouts/validate-account` looks up the name on a bank account so you can show it to your user before saving the recipient. Requires `payment.view`. Only corridors with `supportsAccountResolve: true` in the requirements support it.

<TypeTable
  type={{
  currency: { type: 'string', description: 'Destination currency.', required: true },
  bankCode: { type: 'string', description: 'Code from `/payouts/banks`.', required: true },
  accountNumber: { type: 'string', description: 'The account number to look up.', required: true },
  country: { type: 'string', description: 'ISO country code, where the currency spans several countries.' },
}}
/>

```bash
curl -X POST https://business-api.migrapay.com/v1/api/payouts/validate-account \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "currency": "NGN", "bankCode": "058", "accountNumber": "0123456789" }'
```

```json title="200 OK"
{
  "status": "success",
  "message": "Account validated successfully.",
  "data": { "account": { "accountName": "ADA OKAFOR" } }
}
```

**Errors:** `400 VALIDATION_ERROR` (a required field is missing), `400 ACCOUNT_RESOLVE_NOT_SUPPORTED`, `422 UNSUPPORTED_CORRIDOR`, `422 ACCOUNT_NOT_VERIFIED` (the bank could not confirm the account; `data.message` is a sentence you can show your user).

```json title="422 Unprocessable Entity"
{
  "status": "error",
  "message": "ACCOUNT_NOT_VERIFIED",
  "data": { "message": "We couldn't confirm this account. Check the bank and account number." }
}
```

### Bank-confirmed names (NGN)

For NGN bank recipients the account name is confirmed with the recipient's bank, and **the bank's name is the one we use**. When you create the recipient we look the account up and store the name the bank holds in `destination.accountName`, replacing anything you sent (you may leave `accountName` out). The recipient comes back with `verified: true`, `verifiedName` (the bank's name) and `verifiedAt`.

If the bank cannot confirm the account, the recipient is not created and you get `422 ACCOUNT_NOT_VERIFIED`. Check the bank code and account number and try again. Call [`validate-account`](#validate-an-account) first to show your user the name before they save. A payout to an NGN bank recipient that was saved before this check runs the same lookup first and is refused with the same error, before anything is debited.

## Create a recipient

`POST /recipients` saves a recipient. Requires `recipient.create`.

<TypeTable
  type={{
  displayName: { type: 'string', description: 'Name shown in your dashboard and statements.', required: true },
  ownerType: { type: '"individual" | "business"', description: 'Who owns the account.', required: true },
  rail: { type: 'string', description: 'Payout method, one of the [rails](#rails).', required: true },
  currency: { type: 'string', description: 'Currency the recipient is paid in.', required: true },
  destination: { type: 'object', description: 'Account details for the rail, using the field names from `/payouts/requirements`. Unknown fields are rejected.', required: true },
  label: { type: 'string', description: 'Your own label, up to 100 characters.' },
  email: { type: 'string', description: 'Recipient email address.' },
}}
/>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -X POST https://business-api.migrapay.com/v1/api/recipients \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "displayName": "Ada Okafor",
        "ownerType": "individual",
        "rail": "bank",
        "currency": "NGN",
        "destination": {
          "bankCode": "058",
          "accountNumber": "0123456789",
          "accountName": "Ada Okafor"
        },
        "label": "Lagos contractor",
        "email": "ada@example.com"
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/recipients', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        displayName: 'Ada Okafor',
        ownerType: 'individual',
        rail: 'bank',
        currency: 'NGN',
        destination: { bankCode: '058', accountNumber: '0123456789', accountName: 'Ada Okafor' },
      }),
    });
    const { data } = await res.json();
    const recipientId = data.recipient.id;
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    res = requests.post(
        "https://business-api.migrapay.com/v1/api/recipients",
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
        json={
            "displayName": "Ada Okafor",
            "ownerType": "individual",
            "rail": "bank",
            "currency": "NGN",
            "destination": {
                "bankCode": "058",
                "accountNumber": "0123456789",
                "accountName": "Ada Okafor",
            },
        },
    )
    recipient_id = res.json()["data"]["recipient"]["id"]
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="201 Created"
{
  "status": "success",
  "message": "Recipient created successfully.",
  "data": {
    "recipient": {
      "id": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001",
      "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
      "ownerType": "individual",
      "displayName": "Ada Okafor",
      "rail": "bank",
      "currency": "NGN",
      "destination": {
        "bankCode": "058",
        "accountNumber": "0123456789",
        "accountName": "ADA OKAFOR"
      },
      "institutionId": "c2a9f4e7-1b3d-4f6a-9e8c-000000000001",
      "screeningStatus": "clear",
      "coolingUntil": null,
      "status": "active",
      "label": "Lagos contractor",
      "email": "ada@example.com",
      "lastUsedAt": null,
      "verifiedName": "ADA OKAFOR",
      "verifiedAt": "2026-08-12T16:05:42.000Z",
      "verified": true,
      "createdBy": "e5b8c3d1-7f2a-4b6e-a9d4-000000000001",
      "createdAt": "2026-08-12T16:05:42.000Z"
    }
  }
}
```

Phone numbers in `destination` are stored in international (E.164) format. `createdBy` is the id of the API key that created the recipient. `verified` is `true` when the account name was confirmed with the bank; `verifiedName` and `verifiedAt` are `null` otherwise. For NGN bank recipients `destination.accountName` is the bank's name, as [above](#bank-confirmed-names-ngn).

**Errors**

| Status | Code                   | When                                                                              |
| ------ | ---------------------- | --------------------------------------------------------------------------------- |
| 400    | `VALIDATION_ERROR`     | A field is missing or invalid. `data` keys look like `destination.accountNumber`. |
| 422    | `RAIL_NOT_AVAILABLE`   | This rail is not available for the currency.                                      |
| 422    | `INSTITUTION_UNKNOWN`  | `bankCode` or `mno` is not recognised. `data.field` names the field.              |
| 422    | `ACCOUNT_NOT_VERIFIED` | NGN bank: the bank could not confirm the account. `data.message` explains.        |

## List recipients

`GET /recipients` returns all your recipients, newest first. Requires `recipient.view`.

```bash
curl https://business-api.migrapay.com/v1/api/recipients \
  -H "Authorization: Bearer $MIGRAPAY_API_KEY"
```

```json title="200 OK"
{
  "status": "success",
  "message": "Recipients retrieved successfully.",
  "data": { "recipients": [{ "id": "8d1e5b2a-6c4f-4a9e-b3d7-000000000001", "displayName": "Ada Okafor", "…": "…" }] }
}
```

Each item is a full recipient object, as above.


---

# Transactions

Every movement of money in and out of your wallets, filterable and paginated.

Source: https://www.migrapay.com/docs/api-reference/transactions

<Endpoints
  items={[
  { method: 'GET', path: '/transactions', title: 'List transactions', href: '#list-transactions' },
  { method: 'GET', path: '/transactions/{id}', title: 'Retrieve a transaction', href: '#retrieve-a-transaction' },
]}
/>

Each payout, settled collection, conversion, fee and refund is recorded as a transaction against one of your wallets. Payouts are transactions too, so the object below is the same one [payout endpoints](/docs/payouts) return.

## List transactions

`GET /transactions` returns transactions newest first, [paginated](/docs/api-reference/pagination). Requires `payment.view`.

<TypeTable
  type={{
  page: { type: 'integer', description: 'Page number, from 1.', default: '1' },
  pageSize: { type: 'integer', description: 'Items per page, up to 100.', default: '20' },
  activity: { type: '"transfer" | "withdrawal" | "collection" | "conversion" | "refund" | "fee"', description: 'Kind of movement. `transfer` is a payout to a recipient; `withdrawal` a payout to your own settlement account.' },
  status: { type: 'string', description: 'One of `successful`, `pending`, `pending_approval`, `processing`, `failed`, `reversed`, `declined`, `cancelled`.' },
  type: { type: '"credit" | "debit"', description: 'Money in or money out.' },
  channel: { type: '"bank" | "mobile_money" | "interac" | "virtual_account" | "card" | "crypto"', description: 'How the money moved.' },
  currency: { type: 'string', description: 'Wallet currency, for example `CAD`.' },
  startDate: { type: 'string', description: 'Created at or after this ISO 8601 date or time.' },
  endDate: { type: 'string', description: 'Created at or before this time. A bare date such as `2026-08-31` includes that whole day (UTC).' },
  minAmount: { type: 'number', description: 'Minimum `amount`, in major units.' },
  maxAmount: { type: 'number', description: 'Maximum `amount`, in major units.' },
  q: { type: 'string', description: 'Case-insensitive search across `reference`, `narration` and `customerName`. Up to 100 characters.' },
}}
/>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl "https://business-api.migrapay.com/v1/api/transactions?activity=transfer&status=successful&startDate=2026-08-01&endDate=2026-08-31" \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY"
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const params = new URLSearchParams({ activity: 'transfer', startDate: '2026-08-01', pageSize: '100' });
    const res = await fetch(`https://business-api.migrapay.com/v1/api/transactions?${params}`, {
      headers: { Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}` },
    });
    const { data } = await res.json();
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    res = requests.get(
        "https://business-api.migrapay.com/v1/api/transactions",
        params={"activity": "transfer", "startDate": "2026-08-01", "pageSize": 100},
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
    )
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="200 OK"
{
  "status": "success",
  "message": "Transactions retrieved successfully.",
  "data": {
    "docs": [
      {
        "id": "f2d9b6a4-0c1e-4b7d-9a35-000000000001",
        "reference": "20260812171502-R4N8V2C6PJ",
        "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
        "walletId": "3f6c2a10-8b1d-4e5f-9a7c-000000000002",
        "transactionType": "credit",
        "businessTransactionType": "collection",
        "channel": "virtual_account",
        "status": "successful",
        "statusLog": [{ "status": "successful", "createdAt": "2026-08-12T17:15:02.000Z" }],
        "amount": 25000,
        "netAmount": 24750,
        "fee": 250,
        "transactionFees": [{ "feeType": "collection", "fee": 250, "currency": "NGN" }],
        "currency": "NGN",
        "balance": 1874750,
        "paymentInitiated": true,
        "narration": "Invoice 2048",
        "customerEmail": "payer@example.com",
        "customerName": "Chidi Eze",
        "metadata": null,
        "paymentRequestId": "6b3e8f1a-2d4c-4e9b-8a7f-000000000001",
        "recipientId": null,
        "conversionPairId": null,
        "approvalRequestId": null,
        "createdBy": null,
        "createdAt": "2026-08-12T17:15:02.000Z",
        "updatedAt": "2026-08-12T17:15:02.000Z"
      }
    ],
    "totalDocs": 1,
    "page": 1,
    "totalPages": 1,
    "counts": { "all": 132, "transfer": 97, "withdrawal": 0, "collection": 30, "conversion": 0, "fee": 5 }
  }
}
```

`counts` holds totals per activity for your whole account and ignores your filters. An invalid filter returns `400 VALIDATION_ERROR`.

## Retrieve a transaction

`GET /transactions/{id}` returns one transaction. Requires `payment.view`.

<TypeTable
  type={{
  id: { type: 'string', description: 'The transaction `id` or its `reference`.', required: true },
}}
/>

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl https://business-api.migrapay.com/v1/api/transactions/20261103223912-9AF588FF8E \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY"
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/transactions/20261103223912-9AF588FF8E', {
      headers: { Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}` },
    });
    const { data } = await res.json();
    console.log(data.status, data.railLabel);
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    res = requests.get(
        "https://business-api.migrapay.com/v1/api/transactions/20261103223912-9AF588FF8E",
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
    )
    print(res.json()["data"]["status"])
    ```
  </CodeBlockTab>
</CodeBlockTabs>

The response is the [transaction object](#the-transaction-object) plus `rail` and `railLabel`, the payment method it used. A transaction that does not exist, or belongs to another business, returns `404 TRANSACTION_NOT_FOUND`.

## The transaction object

<TypeTable
  type={{
  id: { type: 'string (uuid)', description: 'Transaction id.' },
  reference: { type: 'string', description: 'Unique reference. Accepted wherever an id is, for example `GET /payouts/{id}`.' },
  walletId: { type: 'string | null', description: 'The wallet that moved.' },
  transactionType: { type: '"credit" | "debit"', description: 'Money in or out of the wallet.' },
  businessTransactionType: { type: 'string', description: '`collection`, `transfer`, `withdrawal`, `conversion`, `fee`, `refund` or `adjustment`.' },
  channel: { type: 'string | null', description: '`bank`, `mobile_money`, `interac`, `virtual_account`, `card` or `crypto`.' },
  status: { type: 'string', description: 'Current status. See the list above.' },
  statusLog: { type: 'array', description: 'Every status the transaction has had, as `{ status, createdAt }`.' },
  amount: { type: 'number', description: 'The amount, in major units of `currency`.' },
  fee: { type: 'number | null', description: 'Total fees.' },
  netAmount: { type: 'number | null', description: 'For payouts, the total debited (amount plus fees). For collections, what was credited after fees.' },
  transactionFees: { type: 'array', description: 'Fee breakdown, as `{ feeType, fee, currency }`.' },
  currency: { type: 'string', description: 'Wallet currency.' },
  balance: { type: 'number | null', description: 'Wallet balance after this transaction, when known.' },
  narration: { type: 'string | null', description: 'Your description or reference.' },
  customerName: { type: 'string | null', description: 'Payer name, for collections.' },
  customerEmail: { type: 'string | null', description: 'Payer email, for collections.' },
  metadata: { type: 'object | null', description: 'For payouts: `toCurrency`, `toAmount`, `rate` and `formattedRate`.' },
  recipientId: { type: 'string | null', description: 'The recipient, for payouts.' },
  paymentRequestId: { type: 'string | null', description: 'The collection, for collection credits.' },
  approvalRequestId: { type: 'string | null', description: 'Set when the payout went through approval.' },
  createdBy: { type: 'string | null', description: 'Who created it: a dashboard user id, or the API key id for API calls.' },
  rail: { type: 'string | null', description: 'Only on `GET /transactions/{id}`: the payment method key, for example `interac` or `bank`.' },
  railLabel: { type: 'string | null', description: 'Only on `GET /transactions/{id}`: the method’s display name.' },
  createdAt: { type: 'string', description: 'ISO 8601 creation time.' },
  updatedAt: { type: 'string', description: 'ISO 8601 time of the last change.' },
}}
/>

The object also carries `businessId`, `paymentInitiated` and `conversionPairId`.


---

# Wallets

Read your balance in every currency your business holds.

Source: https://www.migrapay.com/docs/api-reference/wallets

<Endpoints
  items={[
  { method: 'GET', path: '/wallets', title: 'List wallets', href: '#list-wallets' },
]}
/>

Your business has one wallet per enabled currency, plus a primary wallet. Payouts debit a wallet and settled collections credit one. To see the movements behind a balance, use [Transactions](/docs/transactions).

## List wallets

`GET /wallets` returns every wallet, sorted by currency code. Requires the `wallet.view` scope. It takes no parameters.

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl https://business-api.migrapay.com/v1/api/wallets \
      -H "Authorization: Bearer $MIGRAPAY_API_KEY"
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const res = await fetch('https://business-api.migrapay.com/v1/api/wallets', {
      headers: { Authorization: `Bearer ${process.env.MIGRAPAY_API_KEY}` },
    });
    const { data } = await res.json();
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    res = requests.get(
        "https://business-api.migrapay.com/v1/api/wallets",
        headers={"Authorization": f"Bearer {os.environ['MIGRAPAY_API_KEY']}"},
    )
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```json title="200 OK"
{
  "status": "success",
  "message": "Wallets retrieved successfully.",
  "data": {
    "wallets": [
      {
        "id": "3f6c2a10-8b1d-4e5f-9a7c-000000000001",
        "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
        "currency": "CAD",
        "country": "CA",
        "balance": 12584.5,
        "ledgerBalance": 12584.5,
        "primary": true,
        "enabled": true,
        "status": "active",
        "createdAt": "2026-08-01T10:15:30.000Z",
        "updatedAt": "2026-08-12T16:02:11.000Z"
      },
      {
        "id": "3f6c2a10-8b1d-4e5f-9a7c-000000000002",
        "businessId": "b7e4d2c1-5a3f-4c8e-8d2b-000000000001",
        "currency": "NGN",
        "country": "NG",
        "balance": 1850000,
        "ledgerBalance": 1850000,
        "primary": false,
        "enabled": true,
        "status": "active",
        "createdAt": "2026-08-01T10:15:30.000Z",
        "updatedAt": "2026-08-10T09:44:05.000Z"
      }
    ]
  }
}
```

## The wallet object

<TypeTable
  type={{
  id: { type: 'string (uuid)', description: 'Wallet id.' },
  businessId: { type: 'string (uuid)', description: 'The business that owns the wallet. Differs between Sandbox and Live.' },
  currency: { type: 'string', description: 'ISO 4217 currency code.' },
  country: { type: 'string | null', description: 'ISO country code the wallet is tied to, when there is one.' },
  balance: { type: 'number', description: 'Available balance in major units. This is what payouts can spend.' },
  ledgerBalance: { type: 'number', description: 'Ledger balance in major units, including movements that have not settled yet.' },
  primary: { type: 'boolean', description: 'Whether this is the business’s primary wallet.' },
  enabled: { type: 'boolean', description: 'Whether the wallet can be used.' },
  status: { type: 'string', description: 'Wallet status, for example `active`.' },
  createdAt: { type: 'string', description: 'ISO 8601 creation time.' },
  updatedAt: { type: 'string', description: 'ISO 8601 time of the last change.' },
}}
/>

<Callout title="Amounts">
  All amounts in the API are numbers in major units: `12584.5` is 12,584.50 CAD.
</Callout>


---

# Webhook events

Every event MigraPay sends, the request it arrives in, and every field.

Source: https://www.migrapay.com/docs/api-reference/webhooks

MigraPay sends an HTTPS `POST` to your endpoints when a payout or collection changes. To set up an endpoint and verify deliveries, see the [Webhooks guide](/docs/webhooks).

## The request

```http
POST /webhooks/migrapay HTTP/1.1
Content-Type: application/json
X-MigraPay-Signature: 5d0b6f0a3c1e9b7d2f48a6c5e1d3b9f07a2c4e6d8b0f1a3c5e7d9b1f3a5c7e9d
X-MigraPay-Timestamp: 1786543691
X-MigraPay-Signature-V2: 9a4e1c7b2d5f8e0a3c6b9d2f5e8a1c4b7d0e3f6a9c2b5e8d1f4a7c0b3e6d9f2a
X-MigraPay-Event: payment.settled
X-MigraPay-Mode: live
X-MigraPay-Delivery: 0c7d4e2f-9a1b-4c3d-8e5f-000000000001
X-MigraPay-Request-Id: 7e2a9c4b-1d3f-4a5e-b6c8-000000000001

{"id":"evt_6178cec07838ef6eff03321e","event":"payment.settled","mode":"live","createdAt":"2026-08-12T16:08:11.204Z","data":{"transactionId":"a41c7e92-3b5d-4f08-8c6e-000000000001","reference":"20260812160811-K7Q2M9X4TD","currency":"CAD","amount":250,"toCurrency":"NGN","toAmount":271050}}
```

| Header                    | Description                                                                                        |
| ------------------------- | -------------------------------------------------------------------------------------------------- |
| `X-MigraPay-Signature-V2` | Hex-encoded HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with your signing secret. Use this one. |
| `X-MigraPay-Timestamp`    | When the delivery was signed, in Unix seconds. Covered by the V2 signature.                        |
| `X-MigraPay-Signature`    | Hex-encoded HMAC-SHA256 of the raw body alone. Kept for existing integrations.                     |
| `X-MigraPay-Event`        | The event name, same as `event` in the body.                                                       |
| `X-MigraPay-Mode`         | `live` or `sandbox`, same as `mode` in the body.                                                   |
| `X-MigraPay-Delivery`     | The delivery id. Stays the same across retries, so use it to ignore duplicates.                    |
| `X-MigraPay-Request-Id`   | Unique per attempt. Useful when matching an attempt to your logs.                                  |

The body is always:

```json
{ "id": "evt_…", "event": "<name>", "mode": "live", "createdAt": "<ISO 8601>", "data": { … } }
```

`id` identifies the event and is the same on every endpoint and every retry. `mode` is `live` or `sandbox`: [Sandbox](/docs/sandbox) events are sent exactly like live ones, with the same fields, and differ only in `mode`, so check it before you act on money.

## Events

| Event                 | Sent when                                                                  | `data`                                                                                                                                                               |
| --------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payment.settled`     | A payout has been delivered.                                               | `transactionId`, `reference`, `currency`, `amount`, `toCurrency`, `toAmount`, `rail`, `recipientId`, `narration`                                                     |
| `payment.failed`      | A payout could not be delivered. Your wallet has been credited back.       | `transactionId`, `reference`, `reason` (`failed`), `currency`, `amount`, `toCurrency`, `toAmount`, `rail`, `recipientId`, `narration`                                |
| `payment.reversed`    | A delivered payout was returned. Your wallet has been credited back.       | `transactionId`, `reference`, `reason` (`returned_by_bank`), `currency`, `amount`, `toCurrency`, `toAmount`, `rail`, `recipientId`, `narration`                      |
| `collection.settled`  | A customer paid a collection and the funds are in your wallet.             | `requestId`, `gross`, `fee`, `net`, `currency`, `reference`, `description`, `rail`, `paymentCode`, `payer`, `message`, `sourceAccount`, `amountReceived`, `mismatch` |
| `collection.refunded` | All or part of a settled collection was refunded to the payer.             | `requestId`, `refundId`, `amount`, `gross`, `refundedTotal`, `partial`, `currency`                                                                                   |
| `collection.expired`  | A collection or [checkout](/docs/checkout) was not paid before it expired. | `requestId`, `reference`, `currency`, `amount`, `rail`, `checkout`                                                                                                   |

Payout events use `transactionId` and `reference` from the [payout](/docs/payouts). Collection events use `requestId`, the [collection](/docs/collections) `id`. `amount` is in the wallet currency; `toAmount` is what the recipient received. In rare cases where a payout settles after it was already returned to your wallet, `payment.settled` arrives without `toCurrency` and `toAmount`.

Fields that do not apply to an event are left out of `data` rather than sent as `null`. New fields may be added at any time, so ignore keys you do not know.

### Payout fields

| Field                    | Description                                                                                                   |
| ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `rail`                   | How the payout was sent: `bank`, `interac`, `mobile_money`, `crypto`, …                                       |
| `recipientId`            | The [recipient](/docs/recipients) it was sent to.                                                             |
| `narration`              | The description you gave the payout.                                                                          |
| `currency`, `amount`     | What left your wallet. Also on `payment.failed` and `payment.reversed`, where it is the amount credited back. |
| `toCurrency`, `toAmount` | What the recipient receives (or would have received), after conversion.                                       |

### Collection fields

| Field                      | Description                                                                                                                                                                                                                                                              |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `gross`, `fee`, `net`      | What the payer sent, our fee, and what was credited to your wallet.                                                                                                                                                                                                      |
| `reference`, `description` | Your own reference and description from the collection. Self-deposits made with your Interac business code carry the business code as `reference` and `Interac deposit` as `description`; deposits to a standing NGN account carry `Bank deposit`.                       |
| `rail`                     | `interac`, `virtual_account`, `dynamic_virtual_account`, …                                                                                                                                                                                                               |
| `paymentCode`              | Interac only: the collection's payment code (for example `ACME7K-1001`).                                                                                                                                                                                                 |
| `payer`                    | `{ name, email }` of who paid, as seen on the payment. Either may be `null`.                                                                                                                                                                                             |
| `message`                  | Interac only: the message the payer wrote on the e-Transfer.                                                                                                                                                                                                             |
| `sourceAccount`            | NGN account transfers: `{ name, bank, accountNumberMasked }` of the account the money came from.                                                                                                                                                                         |
| `amountReceived`           | The amount that actually arrived (same as `gross`).                                                                                                                                                                                                                      |
| `mismatch`                 | `true` when `amountReceived` differs from the amount the collection asked for. The collection settles for what arrived and is flagged for review; see [Interac e-Transfer](/docs/interac). Absent otherwise.                                                             |
| `paymentMethod`            | How the customer paid: `card`, `bank_transfer`, `ussd`, `interac`, `virtual_account`, `ach`, `eft` or `wire`.                                                                                                                                                            |
| `funding`                  | How the money arrived, the same shape for every method. See [The funding object](#the-funding-object).                                                                                                                                                                   |
| `metadata`                 | The `metadata` you sent when you created the collection or checkout, returned exactly as you sent it (`null` if you sent none). Payment links add `paymentLinkId` and `slug`; checkout widgets add `widgetId`. Also on `collection.refunded` and `collection.expired`.   |
| `checkout`                 | [Checkouts](/docs/checkout) only: `{ base: { currency, amount }, metadata, method }`. `base` is the price you set (the customer may have paid in another currency); `metadata` is what you sent; `method` is how they paid (`card`, `bank_transfer`, `ussd`, `interac`). |

### The funding object

Every `collection.settled` carries `funding`, built the same way whichever bank, card network or rail the money came through:

| Field        | Description                                                                                                                                                             |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `method`     | Same as `paymentMethod`.                                                                                                                                                |
| `sessionId`  | The bank network's trace for the transfer: the 30-digit NIP session id for Nigerian transfers and USSD, the Interac reference number for e-Transfers. `null` for cards. |
| `reference`  | The payment reference on the sender's side.                                                                                                                             |
| `narration`  | The narration or message on the transfer, when the bank sends one.                                                                                                      |
| `sender`     | `{ name, bankName, accountNumber }` of the account the money came from. Fields the bank does not report are `null`; `sender` is `null` for cards.                       |
| `card`       | Cards only: `{ brand, last4 }`, each `null` when the card network does not report it. `null` for every other method.                                                    |
| `receivedAt` | When the money arrived (ISO 8601, UTC).                                                                                                                                 |

A Nigerian bank transfer:

```json
"funding": {
  "method": "bank_transfer",
  "sessionId": "000013260621182330000013203297",
  "reference": "000013203297",
  "narration": "NIP Transfer to Naija Market Day. 000013260621182330000013203297",
  "sender": { "name": "OKOYE CHIZOBA ANTHONY", "bankName": "GT Bank", "accountNumber": "0697122703" },
  "card": null,
  "receivedAt": "2026-06-21T17:23:37.653Z"
}
```

A card payment:

```json
"funding": {
  "method": "card",
  "sessionId": null,
  "reference": "MP-58D492ECD829",
  "narration": null,
  "sender": null,
  "card": { "brand": null, "last4": null },
  "receivedAt": "2026-11-03T15:08:00.927Z"
}
```

An Interac e-Transfer:

```json
"funding": {
  "method": "interac",
  "sessionId": "CA1MRdK8yG7x",
  "reference": "ACME7K-1001",
  "narration": "Invoice 1048",
  "sender": { "name": "Jordan Smith", "bankName": null, "accountNumber": null },
  "card": null,
  "receivedAt": "2026-08-12T16:08:11.000Z"
}
```

On `collection.refunded`, `amount` (and `gross`, kept for older integrations) is the amount of this refund; `refundedTotal` is the total refunded so far, and `partial` is `true` while part of the collection is still unrefunded.

### Sample payloads

<a href="/docs/webhook-samples.json" download>Download sample webhooks (JSON)</a>: one realistic delivery per scenario, with the exact headers and body we send. It covers `collection.settled` for a CAD Interac collection paid with its payment code, a self-deposit with your business code, an amount mismatch, a standing NGN account and a one-time NGN account; a partial `collection.refunded`; `payment.settled` for a CAD Interac payout and for a CAD to NGN bank payout; `payment.failed`; and `payment.reversed`.

Each entry is `{ "headers": { … }, "body": { "id": …, "event": …, "mode": …, "createdAt": …, "data": { … } } }`. The ids and amounts are made up, but the signatures are real: they are computed with the signing secret `whsec_3f9a1c7e5b2d8f4a6c0e9b1d7f3a5c8e` over `JSON.stringify(body)`, so you can replay a sample against your endpoint with that secret to test your verification. Their timestamps are in the past, so a correct verifier rejects them as stale: relax the age check while you test, never in production.
