# Authentication
Source: https://docs.afriex.com/api-reference/authentication
Get an API key, send it with every request, and keep it safe.
Every API endpoint needs an API key. Send it in the `x-api-key` header.
## Get an API key
1. Log in to the [Dashboard](https://business.afriex.com/).
2. Go to **Developer** and open the **API Keys** tab.
3. Generate a key, choose its [permissions](/api-reference/permissions), and store it securely.
The dashboard has a **Test / Live** toggle. Keys you create in test mode work against the sandbox, and keys you create in live mode work against production. Each mode has its own keys.
## Send the key
```bash theme={null}
curl -H "x-api-key: YOUR_API_KEY" \
https://sandbox.api.afriex.com/api/v1/customer
```
Use the base URL that matches your key:
| Environment | Base URL | Keys from |
| - | - | - |
| Sandbox | `https://sandbox.api.afriex.com` | Test mode |
| Production | `https://api.afriex.com` | Live mode |
Never use a production API key in your testing environment.
## Permissions
A business can have several API keys, and each key has its own set of [permissions](/api-reference/permissions), such as reading transactions or creating deposits. You choose the permissions when you create a key. To remove a key's access, delete the key. Manage your keys under **Developer → API keys** in the dashboard.
* A key that is missing the permission an endpoint needs is rejected with `401 Unauthorized`.
* An unrecognised, malformed, or revoked key returns `401 Unauthorized` as well.
These two cases look the same on purpose. If a request that used to work starts returning `401`, check the key's permissions in the dashboard before you assume the key itself is bad.
See [Permissions](/api-reference/permissions) for the permission each endpoint requires.
## IP allowlist
One allowlist covers every API key in your business. Once you add an address, the API accepts requests only from the addresses on your list. See [IP allowlisting](/api-reference/ip-allowlisting).
## Request signing
Some businesses have payload signing enabled. When it is, send the request signature in the optional `x-api-signature` header:
```
x-api-signature: your-request-signature
```
If signing is not enabled for your business, this header is not required. Contact [support@afriex.com](mailto:support@afriex.com) to enable it.
## Protecting your keys
* Keep API keys on your server. Never put one in a mobile app, a browser, or a public repository.
* Use sandbox keys against the sandbox and production keys against production.
* Give each key only the permissions it needs. If a key may be exposed, [rotate it](#rotate-a-key) straight away.
* Restrict API access to your servers with an [IP allowlist](/api-reference/ip-allowlisting).
## Rotate a key
Rotate a key on a schedule, when someone with access to it leaves, or straight away if it may have been exposed. A business can hold several keys at once, so you can switch with no downtime:
In the dashboard, go to **Developer → API keys**, click **Create new keys**, give the key the same [permissions](/api-reference/permissions) as the old one, and click **Generate key**. Copy it into your secret store.
Point every server that calls the API at the new key, and deploy.
Confirm your requests succeed with the new key and that nothing in your logs still sends the old one.
Click **Revoke** on the old key and confirm. Revoking takes effect at once and can't be undone, and any request that still sends the old key gets `401`.
If a key was exposed, don't wait for a deploy window. Revoke it as soon as the new key is live, and accept a few failed requests over leaving the old key usable.
# Get Balance
Source: https://docs.afriex.com/api-reference/endpoint/balance/get
GET /api/v1/org/balance
Returns one wallet's balances in the provided currencies, as a flat currency-to-amount map: the **Payout (Withdraw) wallet** by default, the **Fee wallet** with `walletType=FEE`, or the **Collection (Deposit) wallet** with `walletType=DEPOSIT`. The Collection wallet is the one that `settlement: "request"` withdrawals debit and that pool-account deposits credit.
## Choosing a wallet
Your business can hold more than one wallet. Pass `walletType` to choose which one to read:
| `walletType` | Wallet | What it holds |
| - | - | - |
| `WITHDRAW` (default) | Payout wallet | The balance ordinary payouts debit |
| `DEPOSIT` | Collection wallet | The balance `settlement: "request"` withdrawals debit and pool-account deposits credit |
| `FEE` | Fee wallet | The balance that settles transaction fees. It reads as zero if your business does not settle fees from a Fee wallet |
Leave `walletType` out to read the Payout wallet, which is what this endpoint has always returned. The response shape is the same for every wallet.
## Fee wallet and payouts
If your business settles fees from the Fee wallet and that wallet falls too far below zero, a payout is refused with the error code `BUSINESS_FEE_WALLET_OVERDRAFT_LIMIT_EXCEEDED`. Read the wallet with `walletType=FEE` to see where you stand. In sandbox, fund it with [Top Up Sandbox Balance](/api-reference/endpoint/balance/topup).
# Top Up Sandbox Balance
Source: https://docs.afriex.com/api-reference/endpoint/balance/topup
POST /api/v1/org/balance/topup
Credits the business's Payout wallet, or its Fee or Collection wallet with `walletType: FEE` or `DEPOSIT`, with the specified amount and currency. **This endpoint is only available in the sandbox environment and will return a `403 Forbidden` response in production.** Use it during integration development to top up your test balance without making real transfers.
Pass `walletType` to choose the wallet to credit:
| `walletType` | Wallet credited |
| - | - |
| `WITHDRAW` (default) | Payout wallet |
| `DEPOSIT` | Collection wallet |
| `FEE` | Fee wallet, the wallet that settles transaction fees |
Read a wallet back with [Get Balance](/api-reference/endpoint/balance/get) and the same `walletType`, so a top-up and a balance read always agree.
# Create Checkout Session
Source: https://docs.afriex.com/api-reference/endpoint/checkout-sessions/create
POST /api/v1/checkout-session
Creates a hosted checkout session for a customer and returns a `checkoutUrl` that the customer should be redirected to in order to complete payment. The session captures the merchant intent (amount, currency, merchant reference, customer details, and allowed payment channels) and is identified end-to-end by the merchant-supplied `merchantReference`.
**Available in sandbox and production.** Create a session with a sandbox API key to try the full flow in test mode, and with a production API key to take real payments.
Want to see the hosted checkout end to end before you write any code? Try the [sandbox demo](https://sandbox.pay.afriex.com/demo), pick a currency and amount, and click **Proceed to payment**, no API key required.
**Testing in sandbox?** A checkout session's underlying transaction settles automatically in about 5 minutes and triggers the same webhook and redirect behavior as a real payment (see **How it works** below). Pick the outcome with the payer's last four digits (e.g. `0001` fails, `0002` triggers the mobile-money OTP page, `0003` stays pending until you call [`/simulate`](/api-reference/endpoint/transactions/simulate)); add `SIMULATE_INSTANT` anywhere in `merchantReference` to settle in about 30 seconds. Sandbox sessions also enable in-page test-mode helpers (test-number hints, OTP hint, simulate-transfer button, pre-filled test card) so payers can drive outcomes without leaving the page. Full reference: [Testing in Sandbox](/guides/testing-in-sandbox#hosted-checkout-in-sandbox).
## How it works
1. Call this endpoint with the amount you want to collect, the customer's details, your `merchantReference`, and a `redirectUrl` that starts with `https://`.
2. Afriex returns a `checkoutUrl` and a `checkoutId`.
3. Redirect the customer to that URL.
4. When the payment succeeds or fails, Afriex redirects the customer back to your `redirectUrl` with `status` (`success` or `failed`), `session_id`, and `merchant_reference` added as query parameters.
5. The payment itself is reported by [Transaction Events](/api-reference/endpoint/webhooks/transaction-events) on the deposit the session creates.
A checkout stays open for 24 hours by default. It closes when its payment is credited, so a paid checkout can't be opened again.
Afriex sends one checkout webhook, [`CHECKOUT_SESSION.CREATED`](/api-reference/endpoint/webhooks/checkout-session-events), when you create the session. It says nothing about payment. Don't treat the redirect as proof of payment either: confirm it from the transaction.
## Identifying the session
The `merchantReference` you supply is the end-to-end identifier, and it must be unique. You can't reuse it while an earlier checkout with that reference is still open, or after a transaction has used it. Both return `409`. The `checkoutId` in the response is the last part of the `checkoutUrl`, and it is the `sessionId` in the `CHECKOUT_SESSION.CREATED` webhook, so use it to match that webhook to the checkout you created. There is no endpoint to fetch a session. To find the deposit a session created, call [List Transactions](/api-reference/endpoint/transactions/list) with `?reference=` set to your `merchantReference`.
## Amounts are in minor units
`amount` is denominated in the smallest unit of the currency. For example, kobo for `NGN` or cents for `USD`. To charge ₦5,000.00, pass `500000`. Minimum value is `100` (one major unit).
## Choosing payment channels
`channels` is **required** and lists the deposit rails you are willing to offer the customer. Supported values are `VIRTUAL_BANK_ACCOUNT`, `MOBILE_MONEY`, and `CARD`; pass at least one.
**Cards are hosted-checkout only.** `CARD` is available here but not on [Create Payment Method](/api-reference/endpoint/payment-methods/create); to accept card payments, always route them through a checkout session.
**Treat it as a cap, not an exact list.** Afriex decides which channels the customer sees when they open the checkout page, once their currency is known. Channels the `currency` cannot collect on are dropped then, so you can send the same list on every corridor and let Afriex offer the right subset. For example, `["VIRTUAL_BANK_ACCOUNT", "MOBILE_MONEY", "CARD"]` becomes mobile money for a KES checkout and cards for a USD one, all from one integration.
The response returns `channels` as the list you sent. Creating a checkout does not narrow it.
Because the channels are decided when the customer opens the checkout page, creating a checkout does not fail when none of your `channels` suit the `currency`. Test each currency you support in sandbox.
# Checkout Flow Guide
Source: https://docs.afriex.com/api-reference/endpoint/checkout-sessions/user-flow-guide
Step-by-step guide for the hosted Afriex Checkout payment experience
## Overview
Afriex Checkout is a hosted payment experience that allows customers to complete payments using supported methods such as Card, Bank Transfer, and Mobile Money.
After a merchant [creates a checkout session](/api-reference/endpoint/checkout-sessions/create) through the API, the customer is redirected to the hosted checkout page to complete payment.
Walk through the exact flow described below without writing any code. The demo is sandbox-only and uses the real hosted checkout, with all three channels covered per currency: **mobile money** (KES, GHS and others), **bank transfer** (NGN), and **card** (USD).
## Before you begin
A checkout session must already be created by the merchant or application. The hosted checkout page displays the payment amount, available payment methods, session timer, and payment instructions.
## Select a payment method
The customer sees the available payment methods on the checkout page.
Available methods may include:
* Card
* Bank Transfer
* Mobile Money
The customer:
* Reviews the payment amount
* Selects a preferred payment method
The checkout session also displays an expiration timer.
After selecting a method, the customer follows one of the payment paths below.
## Pay by card
When Card is selected, Afriex displays a secure card payment form for the payment.
The page displays:
* Amount to pay
* Card number field
* Card expiry field
* CVC field
* Cardholder name field
* Billing address fields
* Pay button
Customer actions:
1. Enter the card number.
2. Enter the card expiry date and CVC.
3. Enter the cardholder name and billing address.
4. Click **Pay**.
5. Complete any card authorization prompt if required.
Afriex confirms the card payment and updates the checkout page automatically.
## Pay by bank transfer
When Bank Transfer is selected, Afriex displays one-time bank transfer details for the payment.
The page displays:
* Amount to pay
* Bank name
* Account number
* Account name
* Copy account number action
* Payment instructions
* Session expiration timer
Customer actions:
1. Open a banking app, internet banking, or USSD.
2. Transfer the exact amount shown.
3. Return to the checkout page.
4. Wait for automatic payment confirmation.
The customer must send the exact amount shown. Incorrect amounts may cause delays or require a refund.
## Pay by mobile money
When Mobile Money is selected, the customer enters their mobile money details.
The page displays:
* Network selector
* Account holder name field
* Mobile money phone number field
* Pay now button
Customer actions:
1. Select a mobile money network.
2. Enter the account holder name.
3. Enter the mobile money phone number.
4. Click **Pay now**.
Afriex sends a payment authorization prompt to the customer's phone.
### Approve the payment on your phone
After the customer clicks **Pay now**, the checkout page enters a pending state while waiting for payment authorization.
The customer:
* Checks their phone
* Approves the mobile money payment prompt
* Waits for the checkout page to update automatically
The session timer continues while Afriex waits for confirmation from the network or payment provider.
## Payment outcomes
Regardless of the payment method, the checkout session ends in one of two outcomes.
### Successful payment
When payment is confirmed, the customer sees a success message.
At this stage:
* The payment has been confirmed
* The customer sees a success confirmation
* The customer is redirected back to the merchant's redirect URL automatically
### Failed payment
If payment cannot be confirmed, the customer sees a failed payment screen.
Possible failure reasons include:
* Declined or failed card payment
* Incorrect transfer amount
* Declined mobile money authorization
* Expired checkout session
* Network or payment provider issue
* Payment could not be confirmed
The customer may:
* Contact support
* Return to the merchant
* Retry payment if the merchant provides a new checkout session
## Checkout session expiration
A checkout stays open for 24 hours by default. Customers should complete payment before it expires. A checkout also closes once its payment is credited, so a paid checkout can't be opened again.
If the checkout expires, the customer may need to restart checkout or request a new checkout session.
## Redirect behavior
After a successful or failed payment, Afriex redirects the customer to the `redirectUrl` you set on the session, with these query parameters added:
| Parameter | Value |
| - | - |
| `status` | `success` or `failed` |
| `session_id` | The checkout session ID |
| `merchant_reference` | Your `merchantReference` |
A customer can change these values, so use the redirect to show a result page, not to confirm payment.
To reconcile the payment independently of the redirect, subscribe to [`TRANSACTION.UPDATED`](/api-reference/endpoint/webhooks/transaction-events) on the transaction the session produces. That is the event that reports payment progress and completion.
`CHECKOUT_SESSION.CREATED` is **not** a payment-completion signal. It fires the moment you create the session, in the same call, and carries no information about whether the customer has paid. Do not wait on it to confirm a payment.
## What the payer sees in sandbox
When a checkout session is created with a sandbox API key, the hosted page opens in **test mode** with a few in-page helpers so the payer can drive different outcomes without leaving the flow:
* A **Test mode** badge in the page header.
* On the mobile-money screen, the sandbox **test numbers** are listed inline (`0001` fails, `0002` asks for an OTP, `0005` is declined). Any other valid mobile number succeeds.
* The **OTP hint** (`123456`) is shown whenever an OTP step is triggered.
* On the bank-transfer screen, **Simulate transfer** and **Simulate failed transfer** buttons finalize the deposit without needing to move money from a real bank.
* The card form is pre-filled with a valid **test card** in read-only fields, so a `Pay` click is enough.
These helpers only appear in sandbox; the production checkout looks identical to a real payment.
## Notes for merchants
* Customers should pay the exact amount shown on the checkout page.
* Checkout sessions should be tested in sandbox before going live. See the [Testing in Sandbox guide](/guides/testing-in-sandbox#hosted-checkout-in-sandbox) for the outcome-selection table (last four digits of the payer's account, phone, or card) and how to speed up settlement.
* The redirect URL should be configured correctly so customers return to the merchant application after payment.
## Related
Create a hosted checkout session and get the `checkoutUrl` to redirect your customer to.
Receive real-time notifications when a checkout session is completed.
# Create Customer
Source: https://docs.afriex.com/api-reference/endpoint/customers/create
POST /api/v1/customer
Creates a new customer for a business. `phone` is validated against `countryCode` and stored in E.164 format. If you post the email of a customer that was previously deleted, that customer is reactivated (its stored `countryCode` is kept) and the call still returns `201`.
You send the customer's name as `fullName`, and responses return it as `name`. The same applies to [Update Customer](/api-reference/endpoint/customers/update).
# Delete Customer
Source: https://docs.afriex.com/api-reference/endpoint/customers/delete
DELETE /api/v1/customer/{customerId}
Deletes a specific customer by their unique identifier.
Deleting a customer can't be undone.
# Get Customer
Source: https://docs.afriex.com/api-reference/endpoint/customers/get
GET /api/v1/customer/{customerId}
Retrieves a specific customer by their unique identifier. The response includes the customer's `reference`, which is the value to supply as the pool-account `reference` when submitting a payment proof.
# List Customers
Source: https://docs.afriex.com/api-reference/endpoint/customers/list
GET /api/v1/customer
Retrieves a paginated list of customers.
Results are paginated. See [Pagination](/api-reference/pagination) for the response shape and the order the customers come back in.
# Update Customer
Source: https://docs.afriex.com/api-reference/endpoint/customers/update
PATCH /api/v1/customer/{customerId}
Partially updates a customer's profile. Send at least one of `fullName`, `email`, or `phone`; omitted fields are left unchanged. Unrecognised keys are ignored, and a body with none of the three recognised keys fails with `422`. `phone` is validated against the customer's stored `countryCode` (it cannot be changed here) and stored in E.164 format.
# Update Customer KYC
Source: https://docs.afriex.com/api-reference/endpoint/customers/update-kyc
PATCH /api/v1/customer/{customerId}/kyc
Partially updates a customer's KYC information.
Use it to add or update a customer's verification documents.
# Verify Customer
Source: https://docs.afriex.com/api-reference/endpoint/customers/verify
POST /api/v1/customer/{customerId}/verify
Runs an identity verification against a customer document. Today the only supported `docType` is `BVN` (Nigeria); `docValue` is the document number. Verification is rate limited per business. The `200` response carries the customer's stored KYC documents in `meta.kyc.data`.
Verification is limited to 100 requests per 24 hours for each business, in production and sandbox. If you exceed the limit, the endpoint returns `429 Too Many Requests` with a `Retry-After` header. See [Rate limits](/api-reference/rate-limits).
If the customer already has a document of this type on file, `docValue` must match it. A different value returns `400` with `INVALID_KYC_DOCUMENT_TYPE`.
# Generate Upload URL
Source: https://docs.afriex.com/api-reference/endpoint/media/generate-upload-url
POST /api/v1/media/url
Generates a presigned S3 URL that the caller can use to upload a file (e.g. a KYC document) directly to Afriex's secure storage. The returned URL is time-limited and scoped to the requested operation. The file name is sanitised before it is used: any directory part is dropped, characters outside `A-Z a-z 0-9 . _ -` become `_`, and the result is cut to 200 characters. The object is stored as `{businessId}/{sanitisedName}`, so always use the returned `key`, not the name you sent, when you reference the file in a later request.
Use it for KYC documents on customers or for transaction-related files such as SWIFT invoices.
Call `POST /api/v1/media/url` with the target `fileName` and a `type` (`user` for identity documents, `transaction` for transaction files). The response contains a presigned `url`, the stable object `key`, and `expiresIn` (seconds the URL remains valid; typically `300`).
Send an HTTP `PUT` to the `url` with the file bytes as the body within the `expiresIn` window. If the window elapses, request a fresh URL.
Attach the returned `key` to any follow-up request (for example, the `invoice` field on a SWIFT withdraw, or a document field in [SME Registration](/api-reference/endpoint/sme-registration/register)). Downstream endpoints only accept the object `key`; the presigned `url` itself is never accepted.
# Add Recipient to Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/add-recipient
POST /api/v1/payment-batch/{batchId}/recipients
Pass `paymentMethodId` to update that saved account of the business and add it to the batch, instead of creating a new one. Requires the `PAYMENT_BATCH.UPDATE` key permission.
Adds a single recipient to a batch. For adding many at once, use [bulk add recipients](/api-reference/endpoint/payment-batches/add-recipients-bulk).
# Bulk Add Recipients to Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/add-recipients-bulk
POST /api/v1/payment-batch/{batchId}/recipients/bulk
Adds each recipient independently and reports which were added. The results are keyed by account number. Requires the `PAYMENT_BATCH.UPDATE` key permission.
# Create Payment Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/create
POST /api/v1/payment-batch
Creates an empty batch. Add recipients with the recipient endpoints, then start the payouts with POST /api/v1/payment-batch/{batchId}/withdraw. Requires the `PAYMENT_BATCH.CREATE` key permission.
Next, add recipients with [add recipient](/api-reference/endpoint/payment-batches/add-recipient) or [bulk add recipients](/api-reference/endpoint/payment-batches/add-recipients-bulk), then start the payouts with [withdraw](/api-reference/endpoint/payment-batches/withdraw).
# Delete Payment Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/delete
DELETE /api/v1/payment-batch/{batchId}
Requires the `PAYMENT_BATCH.DELETE` key permission.
Deletes a payment batch. The saved payment methods for its recipients are kept; only the batch grouping is removed.
# Get Payment Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/get
GET /api/v1/payment-batch/{batchId}
Requires the `PAYMENT_BATCH.READ` key permission.
Returns a single payment batch by id, including its funding method and any last-run outcomes.
# List Payment Batches
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/list
GET /api/v1/payment-batch
Returns the batches belonging to the calling business. Requires the `PAYMENT_BATCH.READ` key permission.
If [request signing](/api-reference/authentication#request-signing) is enabled, sign the query parameters with defaults applied: a call with no parameters signs `{"limit":100,"page":0}`.
# List Batch Recipients
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/list-recipients
GET /api/v1/payment-batch/{batchId}/recipients
Requires the `PAYMENT_BATCH.READ` key permission.
Lists the recipients currently in the batch, with their account details and per-recipient amount.
# List Batch Sessions
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/list-sessions
GET /api/v1/payment-batch/{batchId}/sessions
Requires the `TRANSACTION.HISTORY.READ` key permission.
Returns the past runs (sessions) of a batch. Each session records one execution of [withdraw](/api-reference/endpoint/payment-batches/withdraw), the recipient outcomes for that run, and its overall status.
# Remove Recipient from Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/remove-recipient
DELETE /api/v1/payment-batch/{batchId}/recipients/{recipientId}
Removes the recipient from this batch only; the saved account is kept. A `recipientId` that is not in this batch is refused with `400`, not `404`. Requires the `PAYMENT_BATCH.UPDATE` key permission.
# Update Payment Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/update
PATCH /api/v1/payment-batch/{batchId}
Replaces the batch's name and funding method. Send both: the batch is updated whole. Requires the `PAYMENT_BATCH.UPDATE` key permission.
# Update Recipient in Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/update-recipient
PATCH /api/v1/payment-batch/{batchId}/recipients/{recipientId}
Replaces the recipient's account details and amount. Pass `paymentMethodId` to update that saved account in place. A `recipientId` that is not in this batch is refused with `400`, not `404`. Requires the `PAYMENT_BATCH.UPDATE` key permission.
Omit `paymentMethodId` to leave the account alone and change only the amount.
# Withdraw Batch
Source: https://docs.afriex.com/api-reference/endpoint/payment-batches/withdraw
POST /api/v1/payment-batch/{batchId}/withdraw
Creates a payout for every recipient in the batch in one call and records the run as a session. The payouts settle asynchronously, so a webhook callback URL must be configured. Requires the `TRANSACTION.WITHDRAW.CREATE` key permission.
Each call is a new run and pays every recipient again. To retry only the payouts that failed, pass the run's `sessionId`. A call made while a run of the same batch is still in progress is refused with `409`. A batch with no recipients is refused with `400`.
A **webhook callback URL must be configured** before you call this, otherwise you have no way to observe individual payout outcomes. Subscribe to [Transaction Events](/api-reference/endpoint/webhooks/transaction-events) for the per-recipient status updates, and poll [List Batch Sessions](/api-reference/endpoint/payment-batches/list-sessions) for the overall run.
If [request signing](/api-reference/authentication#request-signing) is enabled, sign the query parameters with defaults applied: a call with no `sessionId` signs `{}`.
# Create Payment Method
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/create
POST /api/v1/payment-method
Creates a new payment method. `accountName`, `accountNumber` and `institution` are required for every channel except UPI and INTERAC, which take no `institution`, and VIRTUAL_BANK_ACCOUNT, which takes none of the three.
Account numbers are normalised before they are stored and returned: a mobile money number is rewritten to E.164 with a leading `+`, and an IBAN is returned in its normalised form.
Supported channels include bank accounts, mobile money, SWIFT, UPI, Interac, and WeChat.
**Cards are not created here.** `CARD` is a hosted-checkout channel only, available through [Create Checkout Session](/api-reference/endpoint/checkout-sessions/create); this endpoint does not accept it.
> Please note that fields without values are not included in the response. This includes fields with empty strings, null values, and undefined values.
**`accountName` character rules.** For every channel except `WE_CHAT` and `ALIPAY`, the account holder's name must use Latin-script letters (A-Z / a-z, including accented forms such as `é`, `ñ`, `ü`, `ç`) **or Chinese (Han) characters**, digits (0-9), spaces, and the characters `&`, `(`, `)`, `+`, `,`, `-`, `.`, `/`, `:`, `?`, and apostrophe (`'`). At least one letter is required. Names in other scripts (e.g. Arabic, Cyrillic) or containing other symbols are rejected. For `WE_CHAT` and `ALIPAY` payouts, submit the beneficiary's local-script name (e.g. Chinese characters) as it appears on their wallet.
# Get or Create Crypto Wallet
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/crypto-wallet
GET /api/v1/payment-method/crypto-wallet
Retrieves an existing crypto wallet or creates a new one for the specified crypto asset. **Important:** This endpoint is **only active in production** and does **not work in sandbox**. Supports idempotency to prevent duplicate wallet creation: a call made while another crypto-wallet call from your business is still being processed returns `400 BAD_REQUEST_ERROR`, so retry it once the first has finished. Currently supports USDT and USDC assets (`USD` is accepted as an alias for `USDC`). Send a GET request with the required `asset` parameter (and optional `customerId`). The system will either return existing wallet addresses or automatically create and return new ones if none exist for that asset/customer (or business if no customerId is provided).
Want to see this in action? Learn how to build payment links using this endpoint:\
[https://dev.to/afriex/send-a-link-get-paid-building-payment-links-with-afriex-13ak](https://dev.to/afriex/send-a-link-get-paid-building-payment-links-with-afriex-13ak)
# Delete Payment Method
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/delete
DELETE /api/v1/payment-method/{paymentMethodId}
Removes a payment method from the system.
Deleting a payment method can't be undone.
# Get Payment Method
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/get
GET /api/v1/payment-method/{paymentMethodId}
Retrieves a specific payment method by its unique identifier.
# List Institutions
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/institution
GET /api/v1/payment-method/institution
Retrieves a list of banks or mobile money providers available for a specific country.
Pass `channel` as well as `countryCode` to list only the providers for that payment channel.
# Institution Codes
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/institution-codes
GET /api/v1/payment-method/institution/codes
Resolves a bank code (SWIFT code or US routing number) to the corresponding bank or institution name.
Pass the SWIFT/BIC code or US routing number as `searchTerm`, with the matching `codeType`.
Use this when you hold a code and need the institution behind it, for example resolving a SWIFT code to a bank name before creating a `SWIFT` payment method.
This is not the same as [Resolve Payment Method](/api-reference/endpoint/payment-methods/resolve), which takes an account number and returns the account holder's details. This endpoint resolves the *institution*, not the account.
# List Payment Methods
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/list
GET /api/v1/payment-method
Retrieves a paginated list of payment methods.
Results are paginated. See [Pagination](/api-reference/pagination) for the response shape and the order the payment methods come back in.
# Get Pool Account
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/pool-account
GET /api/v1/payment-method/pool-account
Returns the pool account for the supplied `country`. Use the
`reference` on the response to reconcile incoming deposits; it is a
shortened form such as `afx1234` (with a customer, `afx1234012`).
Each call also sends a `PAYMENT_METHOD.UPDATED` webhook carrying the
pool account.
A pool account is a shared, Afriex-managed account that customers pay into. Afriex uses the `reference` to attribute incoming funds to your business.
**Important:** Pool accounts are **only active in production** and do **not work in sandbox**.
***
## How the call works
1. Call this endpoint with `country` (required). Optionally pass `customerId` to scope the response to a specific end-user.
2. Afriex returns the pool account (account name, account number, institution) as a `PaymentMethod`, along with a `reference`.
3. Share the account details with your customer so they can pay in. When Afriex receives the inbound funds, it uses the `reference` to attribute the deposit on your account.
## The `reference` field
Every response includes a `reference` string. Persist it alongside the deposit you expect and use it to reconcile incoming pool deposits.
When you pass a `customerId`, the response `reference` matches it, so the deposit can be attributed back to that customer on your side.
## Currency is derived from country
Unlike dedicated virtual accounts, this endpoint does not take a `currency` parameter. The currency is determined by the country code (for example, `NG` resolves to `NGN`). Pool accounts are only available in countries where Afriex operates a pool; an unsupported country code returns a `400`.
## When you have no pool account
If your business has no pool account in the country you ask for, the call returns `404` with `BUSINESS_PAYMENT_METHOD_NOT_FOUND`. Retrying returns the same answer until one is set up for your business, so don't retry it. Contact [support@afriex.com](mailto:support@afriex.com) if you expect to have one.
***
## Related
* [List Virtual Accounts](/api-reference/endpoint/payment-methods/virtual-account) for dedicated per-customer accounts.
* [Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create) to mint a new dedicated account.
# Resolve Payment Method
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/resolve
GET /api/v1/payment-method/resolve
Resolves payment method user information for bank accounts or mobile money.
It validates the account number and institution code and returns the recipient's details.
Nigerian mobile money wallets (MTN, Airtel, Glo, and 9mobile) can be name resolved: pass `channel=MOBILE_MONEY` and `countryCode=NG`, with the provider's institution code from [List Institutions](/api-reference/endpoint/payment-methods/institution). Bank account resolution is available for `NG` and `GH`.
In a country with no account name lookup, the request returns `400` and says so. Ask your customer for the recipient's name instead.
# List Virtual Accounts
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/virtual-account
GET /api/v1/payment-method/virtual-account
Returns every active virtual account for the resolved
customer-or-owner and currency. Read-only: when no virtual account
exists, the response is **200** with `data: []`.
Pass `customerId` to scope the list to a specific end-user; omit it
to list the virtual accounts belonging to the business owner.
**Breaking change from earlier versions:** the previous endpoint
returned a single virtual account and would create one on the fly
when absent. It now returns a list and never creates. The create
path lives at `POST /api/v1/payment-method/virtual-account`; the
`POOL_ACCOUNT` variant has moved to
`GET /api/v1/payment-method/pool-account`.
An empty result is a `200` with `data: []`, not a `404`.
Virtual accounts are available in `NGN`, `GHS`, `KES`, and `USD`. See [Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create#supported-currencies) for which currencies your customers can hold and which are business-only. They also work in sandbox, where you fund them with [Simulate Virtual Account Transfer](/api-reference/endpoint/payment-methods/virtual-account-simulate-transfer).
Want to see virtual accounts in action? Learn how to build payment links using them: [https://dev.to/afriex/send-a-link-get-paid-building-payment-links-with-afriex-13ak](https://dev.to/afriex/send-a-link-get-paid-building-payment-links-with-afriex-13ak)
***
## Breaking change from earlier versions
Previously this endpoint returned a single virtual account and would create one on the fly when none existed. It now returns a list and never creates. The behaviour has been split into three explicit endpoints:
| Endpoint | What it does |
| - | - |
| `GET /api/v1/payment-method/virtual-account` | Lists existing virtual accounts (this page). Read-only. |
| `POST /api/v1/payment-method/virtual-account` | Creates a new virtual account. See [Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create). |
| `GET /api/v1/payment-method/pool-account` | Returns the business pool account for a country. Use the `reference` on the response to reconcile incoming deposits. See [Get Pool Account](/api-reference/endpoint/payment-methods/pool-account). |
If you were relying on the get-or-create behaviour, call `GET` first; when `data` is empty, follow up with `POST` to create one.
***
## Virtual accounts are for end customers
Virtual accounts are designed for your **customers as end users**, meaning the people your business is acting on behalf of. Each virtual account is linked to a `customerId` (or to the business owner if no `customerId` is provided). Payments received into a virtual account are attributed to the associated customer in your Afriex system.
Make sure you have created a Customer record before listing or creating virtual accounts for that person. See [Create Customer](/api-reference/endpoint/customers/create).
# Create Virtual Account
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/virtual-account-create
POST /api/v1/payment-method/virtual-account
Creates a new virtual account for the resolved customer-or-owner and
currency. Pass `customerId` to mint the VA for a specific end-user;
omit it to mint one for the business owner.
**Supported currencies.** A virtual account for one of your customers
(`customerId` supplied) can be created in `NGN` or `GHS`. Any other
currency returns `422 UNSUPPORTED_VIRTUAL_ACCOUNT_CURRENCY`; create it for
the business itself by omitting `customerId`, which is how `KES` and
`USD` accounts are created.
Subject to a per-(business, customer, currency) limit. Requests that
would exceed the limit return `403 VIRTUAL_ACCOUNT_LIMIT_REACHED`.
**NGN and BVN.** A permanent `NGN` account (no `amount`) is opened
against the account holder's BVN. If the holder has none, the request
returns `VIRTUAL_ACCOUNT_BVN_REQUIRED`; pass `amount` to get a one-time
account instead, which does not need a BVN.
**Compliance review:** every currency requires a compliance-approved
application before issuance. The first request for a not-yet-approved
business/currency starts that review and returns
`VIRTUAL_ACCOUNT_PENDING_COMPLIANCE_REVIEW`; retry once approved.
**Accounts opened asynchronously:** for some currencies the issuing
bank opens the account after the request returns. The response is
then `201` with an empty `data` object, and the account is delivered
through the `PAYMENT_METHOD.CREATED` webhook once it is open.
**Label vs amount:** `label` groups static virtual accounts by
purpose (e.g. `SALES`, `OPERATIONS`). It is **mutually exclusive
with `amount`**, which routes to a dynamic (ephemeral) virtual
account. Supplying both is rejected at validation.
## Supported currencies
You can create virtual accounts in **`NGN`, `GHS`, `KES`, and `USD`**. The currency decides who can hold the account:
| Currency | Who can hold it | Notes |
| - | - | - |
| `NGN` (Nigeria) | Your customers or your business | Static accounts for a customer need a BVN. See [BVN requirement](#bvn-requirement-for-ngn-static-virtual-accounts). |
| `GHS` (Ghana) | Your customers or your business | |
| `KES` (Kenya) | Your business only (omit `customerId`) | To have KES accounts issued under your own legal entity, complete [SME registration](/api-reference/endpoint/sme-registration/register). |
| `USD` | Your business only (omit `customerId`) | |
More currencies are on the way. See [Supported currencies](/guides/supported-currencies) for every country and rail, including what is coming soon.
**Customers can hold `NGN` and `GHS` accounts.** Requesting any other currency with a `customerId` returns `UNSUPPORTED_VIRTUAL_ACCOUNT_CURRENCY` with `422`. To create a `KES`, `USD`, or other supported currency account, omit `customerId` so the account is minted for the business itself.
Some currencies offer only one account type, either permanent (`label`) or one-time (`amount`). Requesting a type that is not offered for the currency returns `UNSUPPORTED_VIRTUAL_ACCOUNT_TYPE` with `422`.
**Testing in sandbox.** You can create virtual accounts in sandbox for the currencies available in production, and no real money moves. A sandbox account receives nothing on its own, so use [Simulate Virtual Account Transfer](/api-reference/endpoint/payment-methods/virtual-account-simulate-transfer) to pay into it. See [Testing in Sandbox](/guides/testing-in-sandbox).
***
## Static vs dynamic virtual accounts
You choose between two flavours of virtual account by what you pass alongside `currency` and `customerId`.
### Static (label)
Pass a `label` to group static virtual accounts by purpose. Static accounts are permanently assigned to the customer and never expire. The same account number remains valid for repeated funding.
Allowed labels: `SALES`, `OPERATIONS`, `PAYROLL`, `COLLECTIONS`, `VENDOR_PAYMENTS`, `TAX`, `REFUNDS`, `MARKETING`, `TREASURY`, `GENERAL`.
Use static accounts when you want stable, long-lived account numbers a customer can top up over time, organised by purpose.
### Dynamic (amount)
Pass an `amount` to mint a dynamic (ephemeral) virtual account tied to that specific amount. Dynamic accounts expire after a short window. The `amount` must be greater than 0 and at most 5,000,000.
Use dynamic accounts when you need to collect a specific, known amount within a defined window, like a one-time payment for a particular order.
`label` and `amount` are mutually exclusive. Supplying both is rejected at validation.
***
## BVN requirement for NGN static virtual accounts
For NGN static virtual accounts (label-based, Nigeria), the customer **must have a Bank Verification Number (BVN)** on file. This is a regulatory requirement for permanently assigned accounts in Nigeria. If the BVN is missing, the API returns `400` with `VIRTUAL_ACCOUNT_BVN_REQUIRED`.
You can supply a BVN when creating a new customer or add it to an existing customer at any time using the [Update Customer KYC](/api-reference/endpoint/customers/update-kyc) endpoint.
NGN static virtual accounts will fail if the customer has not provided their BVN. This requirement does not apply to dynamic virtual accounts or to virtual accounts in other currencies.
***
## Limits
Creation is subject to a per-(business, customer, currency) cap. Requests that would exceed the cap return `VIRTUAL_ACCOUNT_LIMIT_REACHED` with `403`. Call [List Virtual Accounts](/api-reference/endpoint/payment-methods/virtual-account) first to check what already exists for the customer.
# Simulate Virtual Account Transfer
Source: https://docs.afriex.com/api-reference/endpoint/payment-methods/virtual-account-simulate-transfer
POST /api/v1/payment-method/virtual-account/simulate-transfer
Sandbox only; returns 403 in production. Credits a bank transfer into one of your sandbox virtual accounts, the way the account provider's deposit notification would, so balances and your transaction webhook behave as in production. Pass the account number; for an account created with an `amount` (a one-time account), also pass the `reference` it was issued with. The deposit is reported by the transaction webhook.
Sandbox virtual accounts receive no money on their own, so use this endpoint whenever you want to test what happens after a customer funds one.
## Choosing the account
* **Reusable virtual account** (created without `amount`): pass the `accountNumber` and `amount` to credit.
* **One-time virtual account** (created with `amount`): pass the same `reference` the account was issued with, in addition to `accountNumber` and `amount`.
## Controlling the outcome
`outcome` defaults to `success`. Pass `"outcome": "failed"` to simulate a failed inflow.
The deposit lands as a transaction and is reported by webhook. See the [Sandbox testing guide](/guides/testing-in-sandbox) for how virtual accounts fit into the broader sandbox model.
# Get Exchange Rates
Source: https://docs.afriex.com/api-reference/endpoint/rates/get
GET /api/v1/org/rates
Retrieves real-time exchange rates.
Use the optional `fromSymbols` and `toSymbols` query parameters to filter results to specific currency pairs.
# Register SME
Source: https://docs.afriex.com/api-reference/endpoint/sme-registration/register
POST /api/v1/sme-registration
Registers a business as its own SME so that dedicated virtual accounts are issued under the business's own legal entity rather than the shared default. The flow is stateful and driven by this single endpoint: the `step` field selects the payload carried in `data`. Call the steps in order: `INITIATE` (triggers a one-time passcode to the supplied mobile number), `CONFIRM_OTP`, then `SUBMIT` (company details, directors and documents). Upload each document first via `POST /api/v1/media/url` and pass the returned object keys in the `SUBMIT` payload. Calling `INITIATE` again while the registration is still waiting for its passcode returns that same registration with a `200`. Requires the `COMPLIANCE.KYB.SUBMIT` key permission.
**Only relevant if you want dedicated KES virtual accounts.** This flow is currently used only to register your business so that **KES virtual accounts** can be issued under your own legal entity in Kenya. It is **not required for any other corridor** (NGN, USD, ZAR, GHS, etc.); skip this page if you are not planning to collect KES via dedicated virtual accounts.
Only Limited Liability Companies are supported today, so `businessType` must be `2` in the `INITIATE` step.
## Flow
Send the business `mobile`, `email`, and `businessType`. Afriex creates an onboarding request, returns an `onboardingRequestId`, and sends a one-time passcode to the supplied mobile number.
Post back the `onboardingRequestId` from step 1 and the 6-digit `otp` the customer received.
Submit company details, `directors[]`, and optional `organizationShareholders[]` / `media[]`. Every file field is an **object key** returned by [Generate Upload URL](/api-reference/endpoint/media/generate-upload-url). Upload each file first, then reference the returned `key` here.
Track progress in `status`: `OTP_PENDING` after `INITIATE`, `DETAILS_PENDING` after `CONFIRM_OTP`, and `SUBMITTED` after `SUBMIT`. The `nextStep` field tells you what to call next; `expiresAt` is the deadline for that step (OTP window after `INITIATE`, submit window after `CONFIRM_OTP`).
## Mobile number format
Every mobile number, the applicant's on `INITIATE` and each director's on `SUBMIT`, must be a valid **Kenyan** number. Any of these forms is accepted and normalised to E.164:
| Format | Example |
| - | - |
| E.164 with `+` | `+254712345678` |
| International without `+` | `254712345678` |
| National with leading zero | `0712345678` |
| National without leading zero | `712345678` |
A non-Kenyan number is rejected with `422`. Directors do not need a `countryCode`, it is derived from the number, and is ignored if you send one.
Once submitted, poll [Get SME Registration Status](/api-reference/endpoint/sme-registration/status) for the review outcome. Dedicated KES virtual accounts under your entity are issued only after the review reaches `APPROVED`.
# Get SME Registration Status
Source: https://docs.afriex.com/api-reference/endpoint/sme-registration/status
GET /api/v1/sme-registration/status
Returns the most recent SME registration status for the authenticated business. Once the registration has been submitted, the response also includes the latest review outcome. Requires the `COMPLIANCE.KYB.READ` key permission.
Use the review outcome to gate downstream flows on approval, such as issuing dedicated KES virtual accounts.
Only relevant if you are pursuing dedicated **KES virtual accounts** via [Register SME](/api-reference/endpoint/sme-registration/register). Other corridors do not use this flow. If the business has never registered, `onboardingRequestId` and `status` come back `null`.
## Review outcomes
`reviewStatus` is present only after `SUBMIT`. Dedicated KES virtual accounts under your entity are issued only after `APPROVED`.
| Value | Meaning |
| - | - |
| `SUBMITTED` | Registration has been received and is waiting to be picked up for review. |
| `PROCESSING` | The review is in progress. |
| `APPROVED` | Approved. Dedicated KES virtual accounts will now be issued under your entity. |
| `REJECTED` | Rejected. See `rejectReasons[]` for the human-readable reasons. |
| `CLOSED` | The reviewing party marked the account as closed. Contact support. |
| `UNDER_REVIEW` | Held for a manual review; no action from you until the outcome flips. |
| `UNKNOWN` | Forward-compatibility slot for any outcome Afriex has not mapped yet. Treat as "no decision yet" and poll again later. |
When the review is `REJECTED`, `rejectReasons[]` carries human-readable reasons you can surface to the operator.
The set of `reviewStatus` values may grow over time as new outcomes are surfaced. Existing values will not change meaning; handle unfamiliar values by falling back to `UNKNOWN`.
## Freshness
The response may also include `isReviewStatusStale: true`. That flag means Afriex could not reach the review provider on this read, so the `reviewStatus` reflects the last known value (or `UNKNOWN` if none is cached) rather than the current one. Poll again later for a fresh read; the field is absent when the read succeeded.
# Get Settlement Advice
Source: https://docs.afriex.com/api-reference/endpoint/transactions/advice
GET /api/v1/transaction/{transactionId}/advice
Returns a short-lived presigned download URL for the settlement & remittance advice PDF auto-generated for a USD withdrawal from your collection wallet (a withdrawal created with `settlement: "request"`). The advice is issued with status `PENDING` when the withdrawal is created and regenerated with status `COMPLETED` (same reference, incremented version) once the withdrawal succeeds. Scoped to your business: you can only fetch advices for your own transactions. This document is a settlement and remittance advice, not a tax invoice.
The download URL is valid for 5 minutes.
Advices are only generated for USD withdrawals with `meta.settlement: "request"`. Requesting an advice for any other transaction returns `404`.
## Lifecycle
The advice is issued twice for the same transaction:
When the withdrawal is created, an advice is generated with `status: "PENDING"` and `version: 1`.
Once the withdrawal succeeds, the advice is regenerated as `status: "COMPLETED"` with an incremented `version`. The `reference` stays the same across regenerations.
## The document
This is a **settlement and remittance advice**, not a tax invoice. Use it to reconcile the settlement leg of a collection-wallet USD withdrawal.
## Scope and access
Advices are scoped to your business: you can only fetch advices for transactions you created. The download `url` is a presigned S3 URL valid for 5 minutes. Fetch a fresh URL by calling this endpoint again when the current one expires.
# Authorize Transaction
Source: https://docs.afriex.com/api-reference/endpoint/transactions/authorize
POST /api/v1/transaction/{transactionId}/authorize
Completes a transaction that was created in a `CUSTOMER_ACTION_REQUIRED` state and needs an extra authorization step (for example, a one-time password on a mobile-money deposit). The body is discriminated by `type`; today the only supported variant is `OTP`. Your business must have a webhook URL set, otherwise the request is rejected with a 400.
A deposit needs this step when the create-transaction response comes back with status `CUSTOMER_ACTION_REQUIRED` and `meta.otpRequired` set to `true`.
**Testing in sandbox?** Submit the OTP `123456` to complete the deposit. Any other value is rejected so you can test the wrong-OTP path. See [Simulating OTP-required deposits](/development#simulating-otp-required-deposits) for the full sandbox flow.
# Create Transaction
Source: https://docs.afriex.com/api-reference/endpoint/transactions/create
POST /api/v1/transaction
Create a new transaction to process a payment for a customer. Use `type: WITHDRAW` to send funds to a destination payment method (requires `destinationId`), `type: DEPOSIT` to pull funds from a source payment method (requires `sourceId`), or `type: SWAP` to convert funds between currencies within the Afriex wallet (requires `sourceCurrency`, `destinationCurrency`, `meta`, and exactly one of `sourceAmount` or `destinationAmount`). A DEPOSIT settles in one currency, so `sourceCurrency` and `destinationCurrency` must be the same. Card deposits cannot be created through the API. Your business must have a webhook URL set, otherwise the request is rejected with a 400.
For a `SWAP`, the API computes the amount you leave out at the live exchange rate. A `SWAP` with both amounts is rejected.
Want to show a customer what a withdrawal will cost before you create it? [Quote a Withdrawal](/api-reference/endpoint/transactions/quote) returns the rate, fee, and both amounts without creating anything, and a create with the same inputs charges the quoted amounts.
**Testing in sandbox?** Sandbox transactions settle automatically in about 5 minutes and send the `TRANSACTION.UPDATED` webhook. Pick the outcome with the payment method's last four digits (e.g. `0001` fails, `0002` requires OTP for mobile money, `0003` stays pending until you call [`/simulate`](/api-reference/endpoint/transactions/simulate)). Add `SIMULATE_INSTANT` anywhere in `meta.reference` to settle in about 30 seconds. Full reference: [Testing in Sandbox](/guides/testing-in-sandbox).
# Get Transaction
Source: https://docs.afriex.com/api-reference/endpoint/transactions/get
GET /api/v1/transaction/{transactionId}
Retrieves a specific transaction by its unique identifier.
# List Transactions
Source: https://docs.afriex.com/api-reference/endpoint/transactions/list
GET /api/v1/transaction
Retrieves a paginated list of transactions.
Results are paginated. See [Pagination](/api-reference/pagination) for the response shape and the order the transactions come back in.
# Submit Pool-Account Payment Proof
Source: https://docs.afriex.com/api-reference/endpoint/transactions/pool-account-proof
POST /api/v1/transaction/pool-account
Submits proof of a deposit made to the business pool account. The request creates a business deposit transaction in `IN_REVIEW` status; an operator confirms the bank inflow before the funds are credited. The response returns that transaction. Duplicate submissions of the same business, customer, amount, reference, timestamp and sender details within the configured idempotency window are rejected. The `timestamp` cannot be in the future. Your business must have a webhook URL set, otherwise the request is rejected with a 400.
## Flow
Call [Generate Upload URL](/api-reference/endpoint/media/generate-upload-url) with `type: "transaction"` and `PUT` the proof file (bank receipt, screenshot, etc.) to the returned presigned URL.
The `type` matters. A URL requested with `type: "user"` uploads to a different bucket; passing that `key` here is rejected as not found.
Call this endpoint with the deposit `amount`, the `countryCode` of the pool account, the pool-account `reference` (see below), the S3 `fileKey` returned in step 1, and the payment `timestamp`. Optionally attach `senderDetails` (name, account number, bank, country) to help reconciliation.
The response is a transaction in `IN_REVIEW`. Subscribe to [Transaction Events](/api-reference/endpoint/webhooks/transaction-events) to be notified when the operator confirms and the status transitions.
## Choosing the `reference`
* Credit an **end customer**: pass the customer's `reference` (the shortened value returned on customer reads; falls back to the customer id when none is assigned). `customerId` must match that customer.
* Credit the **business itself**: pass the business's own pool-account reference from [Get Pool Account](/api-reference/endpoint/payment-methods/pool-account). Routing does not use `customerId` in this case.
## Idempotency
Duplicate submissions with the same business, `customerId`, `amount`, `reference`, `timestamp`, and `senderDetails` within the configured window are rejected with `409 DUPLICATE_REQUEST`. Include a distinct `timestamp` per real payment.
# Quote a Withdrawal
Source: https://docs.afriex.com/api-reference/endpoint/transactions/quote
GET /api/v1/transaction/quote
Returns what a withdrawal would cost, priced by the same code that charges it, without creating anything. Type exactly one of `sourceAmount` (what you send) or `destinationAmount` (what the recipient receives); the other is derived from it. The side you type is returned as `anchor`.
`rate`, `fee` and the amounts mean exactly what they mean on the transaction a create returns, so a create with the same inputs charges the quoted amounts. A quote does not check limits or customer verification and does not need an idempotency key or reference; those apply when you create the transaction, so a quote can succeed where the create is refused. Quotes are not reserved: rates and promotions can change before you create.
Name the destination either with `destinationId` (a saved payment method) or with `destinationChannel` and `destinationCountry` (when the customer has no account to send to yet), not both. `destinationCountry` can be left out for `SWIFT` and `CRYPTO`. A quote priced from a channel alone comes back with `indicative: true`: the account is not known, so the payout route, and any fee that depends on it, may differ when you create. `customerId` is optional; without it the quote is priced for the business owner, so a fee or promotion that depends on the customer's country can differ when you create.
Requires the `TRANSACTION.QUOTE.READ` key permission. See [Permissions](/api-reference/permissions).
## How to use it
Send exactly one of `sourceAmount` (what you send) or `destinationAmount` (what the recipient receives). The API derives the other side. The response `anchor` tells you which side is exact: `source` or `destination`.
Send either `destinationId` (a saved payment method) or `destinationChannel` plus `destinationCountry` (when the customer has no account to send to yet), never both. `destinationCountry` can be left out for `SWIFT` and `CRYPTO`.
`customerId` is optional. Without it, the quote is priced for the business owner.
## Example
```bash theme={null}
curl -G "https://sandbox.api.afriex.com/api/v1/transaction/quote" \
-H "x-api-key: YOUR_API_KEY" \
--data-urlencode "sourceCurrency=NGN" \
--data-urlencode "destinationCurrency=USD" \
--data-urlencode "sourceAmount=100000" \
--data-urlencode "destinationId=PAYMENT_METHOD_ID"
```
```json theme={null}
{
"data": {
"anchor": "source",
"sourceAmount": "100000",
"sourceCurrency": "NGN",
"destinationAmount": "65.76696108",
"destinationCurrency": "USD",
"rate": "0.0006576696108"
}
}
```
`rate` reads as `1 sourceCurrency = rate destinationCurrency`. `fee` is in `sourceCurrency` and is left out when no fee applies.
## Things to know
* **Quotes are not reserved.** Rates and promotions can change before you create, so quote again close to the send if the price matters.
* **A quote can succeed where the create is refused.** A quote does not check limits or customer verification, and it needs no idempotency key or reference. Those apply when you create the transaction.
* **`indicative: true`.** When you price from `destinationChannel` alone, the account is not known yet, so the payout route and any fee that depends on it can differ when you create. The response carries `indicative: true` to tell you so.
* **Pass `customerId` for an exact price.** A fee or promotion that depends on the customer's country can differ from a business-owner quote.
## Errors
A `422` means the request is invalid or can't be quoted: you sent both amounts or neither, both `destinationId` and `destinationChannel` or neither, a required parameter is missing, a currency, channel, or country is not recognized, or the destination country or channel is not currently available. `details.friendlyMessage` says which.
# Simulate Transaction Outcome
Source: https://docs.afriex.com/api-reference/endpoint/transactions/simulate
POST /api/v1/transaction/{transactionId}/simulate
Sandbox only; returns 403 in production. Completes a pending sandbox deposit or payout with the outcome you choose, and your balance and transaction webhook behave as they would in production. The transaction must still be `PENDING`, `PROCESSING` or `UNKNOWN`: a deposit waiting for an OTP must be authorized first. Use it for a payment method whose account number, phone number or card ends in `0003`, which stays pending until simulated, or to complete any other pending sandbox transaction early. Only sandbox transactions that are still pending can be simulated. The result arrives by webhook shortly after this call returns. Your business must have a webhook URL set, otherwise the request is rejected with a 400.
Use this whenever you want a deterministic outcome without waiting on the default sandbox settlement window (\~5 minutes):
* **Pending on purpose.** A payment method whose account number, phone number or card ends in `0003` stays `PENDING` until you call this endpoint.
* **Speed up any other pending sandbox transaction.** Call it to finalize sooner than the \~5 minute default.
The transaction must still be `PENDING`, `PROCESSING` or `UNKNOWN`. A deposit waiting on OTP (`CUSTOMER_ACTION_REQUIRED`) must be authorized via [Authorize Transaction](/api-reference/endpoint/transactions/authorize) first.
See the [Sandbox testing guide](/guides/testing-in-sandbox) for the full test-value taxonomy.
# Checkout Session Events
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/checkout-session-events
webhook checkout_session.events
Afriex delivers a webhook to your configured URL when a hosted checkout session is created. It is the only checkout session event: it is sent once, when you create the session, and says nothing about payment. Track the payment with transaction events, and find the session's deposit with `GET /api/v1/transaction?reference=`.
**Security**: Every request includes an `x-webhook-signature` header. You **must** verify this signature using Afriex's public key before processing. The key is an elliptic-curve (secp384r1 / P-384) key, not RSA. The signature is ECDSA with SHA-256, DER-encoded and then base64-encoded, computed over the exact raw request body, so verify it before parsing or re-serialising the JSON. See the [Webhooks Overview & Security](https://docs.afriex.com/api-reference/endpoint/webhooks/introduction) section for verification instructions.
## Event Types
* `CHECKOUT_SESSION.CREATED` - A new hosted checkout session has been created
## Sample Payload
### Checkout Session Created
```json theme={null}
{
"id": "a3cd37d4-ce8b-5b56-b298-67201b00cf5e",
"event": "CHECKOUT_SESSION.CREATED",
"createdAt": "2025-12-29T13:30:22.554Z",
"data": {
"sessionId": "394102",
"merchantReference": "order-12345",
"amount": 5000,
"currency": "NGN",
"expiresAt": "2025-12-29T13:45:10.123Z",
"createdAt": "2025-12-29T13:30:22.554Z",
"metadata": {},
"customer": {
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+2348192837465",
"countryCode": "NG"
}
}
}
```
This event is sent once, when you create the session, and it is the only checkout session event. `sessionId` is the `checkoutId` returned when you created the checkout. `expiresAt` is when the checkout stops accepting payment, 24 hours after you create it by default. Track the payment with [Transaction Events](/api-reference/endpoint/webhooks/transaction-events), and find the session's deposit with [List Transactions](/api-reference/endpoint/transactions/list) and `?reference=` set to the `merchantReference`.
# Customer Events
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/customer-events
webhook customer.events
Afriex delivers real-time notifications to your configured webhook URL whenever a customer is **created**, **updated**, or **deleted**.
The payload structure is **identical** across all events. Only the `event` field changes.
**Security**: Every request includes an `x-webhook-signature` header and an `x-afriex-business-id` header carrying your business id. You **must** verify this signature using Afriex's public key before processing. The key is an elliptic-curve (secp384r1 / P-384) key, not RSA. The signature is ECDSA with SHA-256, DER-encoded and then base64-encoded, computed over the exact raw request body, so verify it before parsing or re-serialising the JSON. See the [Webhooks Overview & Security](https://docs.afriex.com/api-reference/endpoint/webhooks/introduction) section for verification instructions.
## Event Types
* `CUSTOMER.CREATED` - A new customer has been created
* `CUSTOMER.UPDATED` - Customer information has been updated
* `CUSTOMER.DELETED` - A customer has been deleted
## Sample Payloads
### Customer Created
```json theme={null}
{
"id": "319331fa-1b25-57fe-be13-602e9f440181",
"event": "CUSTOMER.CREATED",
"createdAt": "2026-02-10T10:11:12.415Z",
"data": {
"name": "John Doe",
"email": "johndoe@gmail.com",
"phone": "+2348012345678",
"customerId": "698b0440cba7ec3daee9163d",
"countryCode": "NG",
"meta": {
"source": "whatsapp"
},
"createdAt": "2026-02-10T10:11:12.415Z",
"updatedAt": "2026-02-10T10:11:12.415Z"
}
}
```
### Customer Updated
```json theme={null}
{
"id": "009f1583-98b8-5c58-b0f4-8d369173107d",
"event": "CUSTOMER.UPDATED",
"createdAt": "2026-02-11T15:30:45.123Z",
"data": {
"name": "John Smith",
"email": "johnsmith@gmail.com",
"phone": "+2348012345678",
"customerId": "698b0440cba7ec3daee9163d",
"countryCode": "NG",
"meta": {
"source": "whatsapp",
"verified": true
},
"createdAt": "2026-02-10T10:11:12.415Z",
"updatedAt": "2026-02-11T15:30:45.123Z"
}
}
```
### Customer Deleted
```json theme={null}
{
"id": "ee13de17-9341-5940-af5c-1dfd1af576d4",
"event": "CUSTOMER.DELETED",
"createdAt": "2026-02-11T15:30:45.123Z",
"data": {
"name": "John Smith",
"email": "johnsmith@gmail.com",
"phone": "+2348012345678",
"customerId": "698b0440cba7ec3daee9163d",
"countryCode": "NG",
"meta": {
"source": "whatsapp",
"verified": true
},
"createdAt": "2026-02-10T10:11:12.415Z",
"updatedAt": "2026-02-11T15:30:45.123Z"
}
}
```
Delete events carry the **same full customer object** as create and update events. Afriex reads the record before removing it and sends it in full, so `CUSTOMER.DELETED` gives you the customer's last known state. There is no slimmed-down delete payload and no `deletedAt` field.
# Introduction
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/introduction
Webhook overview, security, and verification
Afriex sends real-time webhook notifications to your configured endpoint when important resource changes happen.
## How webhooks work
Afriex delivers event notifications for customer, payment method, transaction, and checkout session updates to the webhook URL you configure in your dashboard.
## Set up your webhook endpoint
1. Open the **Dashboard**.
2. Go to **Developer**.
3. Select the **Webhook** tab.
4. Save your webhook URL.
Example webhook URLs:
* `https://your-domain.com/webhook`
After you save your webhook URL, you can copy your webhook public key from the same screen.
## Afriex IP addresses
**Allowlist these IPs before going live.** To receive webhook deliveries, your server must allow inbound traffic from the following Afriex IP addresses:
| Environment | IP address |
| - | - |
| Sandbox | `34.234.189.210` |
| Production | `34.197.33.100` |
Add these to your firewall allowlist. Without this, Afriex webhook requests will be blocked.
## Security and signature validation
Every webhook request includes an `x-webhook-signature` header. You must validate this signature before processing the payload.
* Header: `x-webhook-signature`
* Algorithm: ECDSA with SHA-256
* Key: elliptic-curve, `secp384r1` (P-384). It is **not** an RSA key.
* Signature format: DER-encoded, then base64-encoded
* Signed data: the exact raw request body (`string`/`Buffer`)
* Public key source: **Developer → Webhook** in the Dashboard
* Environment note: Sandbox and production use different public keys
Always verify against the raw request body bytes. Do not verify parsed JSON, and do not re-serialize the body before you verify it.
The key is elliptic-curve, not RSA. A verifier built for RSA (for example `RSA-SHA256` with PKCS#1 v1.5) rejects every delivery. Use ECDSA with the P-384 curve and SHA-256.
Libraries that expect a raw `r||s` (IEEE P1363) signature, such as WebCrypto and JOSE libraries, need the DER signature converted first. Node's `crypto` module reads DER by default, so the sample below needs no conversion.
```typescript theme={null}
import crypto from "crypto";
function verifySignature(
signature: string,
rawBody: string | Buffer,
publicKey: string
): boolean {
try {
// The key is an EC (P-384) key, so this verifies ECDSA with SHA-256.
const verifier = crypto.createVerify("SHA256");
verifier.update(rawBody);
return verifier.verify(publicKey, signature, "base64");
} catch {
return false;
}
}
```
## Response and retries
Return a success response quickly after verification and processing.
* On success: return `200` (or another `2xx` status). Afriex waits up to **15 seconds** for your response before treating the attempt as failed, so aim well inside that budget and do slow work after acknowledging.
* On verification failure: return `400` or `401`
* Retry behavior: Afriex makes up to 12 delivery attempts, the first one and then up to 11 retries, with exponential backoff starting at 30 seconds.
The wait before each retry roughly doubles:
* 30s → 1m → 2m → 4m → 8m → 16m → 32m → about 1h → about 2h → about 4h → about 8h
## Duplicates, ordering, and replays
Every webhook body carries two fields besides `event` and `data`:
| Field | What it is |
| - | - |
| `id` | The event's unique ID. Every retry of the same event has the same `id`. |
| `createdAt` | When Afriex created the event, in ISO 8601. Retries keep the same value. |
The signature covers the whole body, so both fields are signed. You verify the signature exactly as before.
Build your handler for three cases:
* **The same event more than once.** Afriex retries any delivery that doesn't get a `2xx` within 15 seconds, even if you processed it. Store the `id` of each event you process. When a delivery's `id` is one you have, return `200` and skip it.
* **Events out of order.** A retried event can arrive after a newer one, and `TRANSACTION.UPDATED` can arrive before `TRANSACTION.CREATED`. Compare the payload's `data.updatedAt` with the version you have stored, and ignore anything older.
* **Replayed deliveries.** A captured delivery still has a valid signature if someone sends it again. Your stored `id`s stop a replay, because it carries an `id` you've already processed. Retries can arrive up to about 16 hours after `createdAt`, so keep each `id` for at least a day.
Acknowledge an event once you have stored it, not once you have finished acting on it. Saving it first means a crash can't lose it, and answering straight away keeps you well inside the 15 seconds:
```typescript theme={null}
// After verifying the signature against the raw body:
const event = JSON.parse(rawBody);
try {
// A unique key on event.id turns a retry or a replay into a no-op.
await webhookEvents.insertIfAbsent({ id: event.id, payload: event });
} catch {
return res.sendStatus(500); // not stored, so let Afriex retry
}
res.sendStatus(200);
// Elsewhere, a worker takes each stored event that isn't marked done, acts on it,
// then marks it done. Make that work safe to run twice, in case the worker
// crashes before it marks the event.
```
## Webhook event references
* [Customer Events](/api-reference/endpoint/webhooks/customer-events)
* [Payment Method Events](/api-reference/endpoint/webhooks/payment-method-events)
* [Transaction Events](/api-reference/endpoint/webhooks/transaction-events)
* [Checkout Session Events](/api-reference/endpoint/webhooks/checkout-session-events)
* [Pool Deposit Events](/api-reference/endpoint/webhooks/pool-deposit-events)
# Payment Method Events
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/payment-method-events
webhook payment_method.events
Afriex delivers real-time notifications to your configured webhook URL whenever a payment method is **created**, **updated**, or **deleted** for a customer.
A call to `GET /api/v1/payment-method/pool-account` also sends `PAYMENT_METHOD.UPDATED` for the pool account, on every call.
The payload structure is **identical** across all events. Only the `event` field changes.
**Security**: Every request includes an `x-webhook-signature` header and an `x-afriex-business-id` header carrying your business id. You **must** verify this signature using Afriex's public key before processing. The key is an elliptic-curve (secp384r1 / P-384) key, not RSA. The signature is ECDSA with SHA-256, DER-encoded and then base64-encoded, computed over the exact raw request body, so verify it before parsing or re-serialising the JSON. See the [Webhooks Overview & Security](https://docs.afriex.com/api-reference/endpoint/webhooks/introduction) section for verification instructions.
## Event Types
* `PAYMENT_METHOD.CREATED` - A new payment method has been created
* `PAYMENT_METHOD.UPDATED` - Payment method information has been updated
* `PAYMENT_METHOD.DELETED` - A payment method has been deleted
## Sample Payloads
### Payment Method Created
```json theme={null}
{
"id": "30965945-ffa8-53d6-aef7-24b14dbbe129",
"event": "PAYMENT_METHOD.CREATED",
"createdAt": "2026-04-24T09:13:33.883Z",
"data": {
"paymentMethodId": "6936b8774f987b2994865b73",
"channel": "BANK_ACCOUNT",
"customerId": "6922e4520a53e858ab42efa8",
"institution": {
"institutionCode": "000013",
"institutionName": "Guaranty Trust Bank (GTBank) Plc"
},
"accountName": "John Doe",
"accountNumber": "1234567890",
"countryCode": "NG"
}
}
```
### Payment Method Updated
```json theme={null}
{
"id": "b2a99d6b-9ce2-592f-9426-3e2df8f786ce",
"event": "PAYMENT_METHOD.UPDATED",
"createdAt": "2026-04-24T09:13:33.883Z",
"data": {
"paymentMethodId": "6936b8774f987b2994865b73",
"channel": "BANK_ACCOUNT",
"customerId": "6922e4520a53e858ab42efa8",
"institution": {
"institutionCode": "000013",
"institutionName": "Guaranty Trust Bank (GTBank) Plc"
},
"accountName": "John Smith",
"accountNumber": "1234567890",
"countryCode": "NG"
}
}
```
### Payment Method Deleted
```json theme={null}
{
"id": "39e222bf-f2f9-5d0b-81ef-713c9ebc2346",
"event": "PAYMENT_METHOD.DELETED",
"createdAt": "2026-04-24T09:13:33.883Z",
"data": {
"paymentMethodId": "69f87b0dcc0ee96511560796",
"channel": "BANK_ACCOUNT",
"customerId": "6922e4520a53e858ab42efa8",
"institution": {
"institutionCode": "000013",
"institutionName": "Guaranty Trust Bank (GTBank) Plc"
},
"accountName": "John Smith",
"accountNumber": "1234567890",
"countryCode": "NG"
}
}
```
# Pool Deposit Events
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/pool-deposit-events
webhook pool_deposit_request.events
Afriex delivers a webhook to your configured URL when a pool account deposit you submitted is rejected during review.
Submitting proof through `POST /api/v1/transaction/pool-account` creates a transaction in `IN_REVIEW`. An operator then confirms or rejects the inflow: approval surfaces as `TRANSACTION.CREATED`, rejection surfaces here. This event cannot be fired from `POST /api/v1/webhooks/trigger`.
**Security**: Every request includes an `x-webhook-signature` header and an `x-afriex-business-id` header carrying your business id. You **must** verify this signature using Afriex's public key before processing. The key is an elliptic-curve (secp384r1 / P-384) key, not RSA. The signature is ECDSA with SHA-256, DER-encoded and then base64-encoded, computed over the exact raw request body, so verify it before parsing or re-serialising the JSON. See the [Webhooks Overview & Security](https://docs.afriex.com/api-reference/endpoint/webhooks/introduction) section for verification instructions.
## Event Types
* `POOL_DEPOSIT_REQUEST.REJECTED` - A submitted pool account deposit was rejected during review
## Sample Payload
### Pool Deposit Rejected
```json theme={null}
{
"id": "3a9a4862-b1bc-59cd-ad8c-cac73f1b7f72",
"event": "POOL_DEPOSIT_REQUEST.REJECTED",
"createdAt": "2026-04-24T09:13:33.883Z",
"data": {
"transactionId": "69528240ba52c13b669fb239",
"reference": "pool-account-reference",
"amount": 5000,
"currency": "NGN",
"rejectionReason": "Sender name does not match the submitted proof",
"resubmissionRequired": true
}
}
```
`rejectionReason` is free text entered by the reviewer. Show it to your operator; do not branch on its contents. Branch on `resubmissionRequired` instead: `true` means you can correct the submission and send fresh proof, `false` means the rejection is final.
To test this event, [submit proof](/api-reference/endpoint/transactions/pool-account-proof) in sandbox and have it rejected. [Trigger Test Webhook](/api-reference/endpoint/webhooks/trigger) can't send it.
This payload is a flat, purpose-built shape rather than the full transaction object used by [Transaction Events](/api-reference/endpoint/webhooks/transaction-events). If you need the full transaction, fetch it with [Get Transaction](/api-reference/endpoint/transactions/get) using `transactionId`.
# Transaction Events
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/transaction-events
webhook transaction.events
Afriex sends real-time webhook notifications to your configured URL whenever a transaction is **created** or **updated**.
- `TRANSACTION.CREATED`: Fired when a new transaction is initiated.
- `TRANSACTION.UPDATED`: Fired when the transaction status or other fields change (most commonly `status` and `updatedAt`).
The payload structure is **identical** for both events. Only the `event` field and potentially `status` + `updatedAt` differ.
**Security**: Every request includes an `x-webhook-signature` header and an `x-afriex-business-id` header carrying your business id. You **must** verify this signature using Afriex's public key before processing. The key is an elliptic-curve (secp384r1 / P-384) key, not RSA. The signature is ECDSA with SHA-256, DER-encoded and then base64-encoded, computed over the exact raw request body, so verify it before parsing or re-serialising the JSON. See the [Webhooks Overview & Security](https://docs.afriex.com/api-reference/endpoint/webhooks/introduction) section for verification instructions.
## Sample Payloads
### Transaction Created
```json theme={null}
{
"id": "e9ec2c1e-902b-5d2d-8659-e1529afd23ab",
"event": "TRANSACTION.CREATED",
"createdAt": "2026-04-24T09:13:33.883Z",
"data": {
"status": "PENDING",
"type": "WITHDRAW",
"channel": "BANK_ACCOUNT",
"sourceAmount": "5000",
"sourceCurrency": "NGN",
"destinationAmount": "5000",
"destinationCurrency": "NGN",
"destinationId": "68e676bac48ad6beae3559a0",
"customerId": "68e6717848e1f632e9686460",
"transactionId": "69eb343d4973a18671813fb3",
"merchantReference": "myReference",
"meta": {
"reference": "myReference",
"idempotencyKey": "myReference",
"narration": "My first transaction"
},
"createdAt": "2026-04-24T09:13:33.883Z",
"updatedAt": "2026-04-24T09:13:33.883Z"
}
}
```
### Transaction Updated
```json theme={null}
{
"id": "2708d455-5789-5229-b606-f4fab67648cf",
"event": "TRANSACTION.UPDATED",
"createdAt": "2026-04-24T09:15:45.123Z",
"data": {
"status": "PENDING",
"type": "WITHDRAW",
"channel": "BANK_ACCOUNT",
"sourceAmount": "5000",
"sourceCurrency": "NGN",
"destinationAmount": "5000",
"destinationCurrency": "NGN",
"destinationId": "68e676bac48ad6beae3559a0",
"customerId": "68e6717848e1f632e9686460",
"transactionId": "69eb343d4973a18671813fb3",
"merchantReference": "myReference",
"meta": {
"reference": "myReference",
"idempotencyKey": "myReference",
"narration": "My first transaction"
},
"createdAt": "2026-04-24T09:13:33.883Z",
"updatedAt": "2026-04-24T09:15:45.123Z"
}
}
```
### Transaction Failed
When `status` transitions to `FAILED` or `REJECTED`, the payload's `meta.failureReason` describes why using a stable `AFX_*` code and a customer-safe message. See [Failure reasons](/api-reference/models#failure-reasons) for the full code list.
```json theme={null}
{
"id": "d374dc7f-9a8c-52f4-8e71-09ee46d0dddf",
"event": "TRANSACTION.UPDATED",
"createdAt": "2026-04-24T09:17:02.410Z",
"data": {
"status": "FAILED",
"type": "WITHDRAW",
"channel": "BANK_ACCOUNT",
"sourceAmount": "5000",
"sourceCurrency": "NGN",
"destinationAmount": "5000",
"destinationCurrency": "NGN",
"destinationId": "68e676bac48ad6beae3559a0",
"customerId": "68e6717848e1f632e9686460",
"transactionId": "69eb343d4973a18671813fb3",
"merchantReference": "myReference",
"meta": {
"reference": "myReference",
"idempotencyKey": "myReference",
"narration": "My first transaction",
"failureReason": {
"code": "AFX_VELOCITY_LIMIT_EXCEEDED",
"message": "This transaction exceeds the allowed velocity limit. Try again later.",
"retryable": true
}
},
"createdAt": "2026-04-24T09:13:33.883Z",
"updatedAt": "2026-04-24T09:17:02.410Z"
}
}
```
Cross-currency transactions also carry a `rate` field alongside the amounts, holding the realized source-to-destination exchange rate. See [Transaction](/api-reference/models#transaction) for the full field list.
`TRANSACTION.UPDATED` is **not** a terminal-status-only signal. It fires on every status change Afriex considers worth reporting, including intermediate ones such as `PROCESSING` and `CUSTOMER_ACTION_REQUIRED`, so a single transaction can produce several. Always branch on the payload's `status` field rather than treating any delivery as final.
# Trigger Test Webhook
Source: https://docs.afriex.com/api-reference/endpoint/webhooks/trigger
POST /api/v1/webhooks/trigger
Fires a real signed webhook to the business's configured callback URL using a real entity (customer, payment method, transaction, or checkout session) as the payload. Useful for testing webhook handlers in sandbox without manufacturing the underlying activity. **This endpoint is only available in the sandbox environment and will return a `403 Forbidden` response in production.** The business must have a webhook callback URL configured, or the request is rejected with `400`.
This endpoint is only available in the **sandbox** environment and returns a `403 Forbidden` response in production.
To test transaction webhooks end to end, let a real sandbox transaction settle instead of firing a synthetic event: it reaches a final status in about 5 minutes and sends `TRANSACTION.UPDATED`. Pick the outcome with the payment method's last four digits (`0001` fails, `0003` stays pending until you call [`/simulate`](/api-reference/endpoint/transactions/simulate)), or add `SIMULATE_INSTANT` anywhere in `meta.reference` to speed things up to about 30 seconds. Full reference: [Testing in Sandbox](/guides/testing-in-sandbox).
## How it works
1. Create or look up an existing entity in sandbox.
2. Call this endpoint with the matching `event` name and the entity's ID.
3. Afriex delivers a real, signed webhook to your configured callback URL using that entity as the payload.
Because the payload is a real entity, your handler verifies the signature and processes the event exactly the same way it would in production.
## What to pass as `entityId`
| Event family | `entityId` |
| - | - |
| `CUSTOMER.*` | The customer ID, 24 hexadecimal characters |
| `PAYMENT_METHOD.*` | The payment method ID, 24 hexadecimal characters |
| `TRANSACTION.*` | The transaction ID, 24 hexadecimal characters |
| `CHECKOUT_SESSION.*` | The `checkoutId` returned when you created the checkout. It must belong to your business. |
# Errors
Source: https://docs.afriex.com/api-reference/errors
The error format, what each HTTP status means, and which errors to retry.
Every error response has the same shape, so you can handle errors in one place.
## Error format
```json theme={null}
{
"code": "INVALID_BUSINESS_CUSTOMER_REQUEST",
"error": "Invalid business customer request",
"details": {
"errorMessage": "Invalid business customer request",
"friendlyMessage": "Invalid customer phone provided: PHONE_COUNTRY_MISMATCH"
}
}
```
| Field | Description |
| - | - |
| `code` | Machine-readable error code. Use it for logic in your integration. |
| `error` | A fixed message for the `code`, such as `Invalid business customer request`. |
| `details.errorMessage` | The same message as `error`. |
| `details.friendlyMessage` | The specific problem with this request, such as which field is wrong. It can be empty, and it can include internal codes such as `PHONE_COUNTRY_MISMATCH`. |
| `details.data` | Present on some errors only. For example, a `409` for an existing customer carries that customer's `customerId`. |
Branch on `code` and the HTTP status. Log `details.friendlyMessage` to debug a request, but don't parse it, because the wording can change. To show an error to your own users, map `code` to your own text.
See [Common error codes](/api-reference/models#common-error-codes) for the codes and their statuses.
## HTTP status codes
| Status | Meaning | What to do |
| - | - | - |
| `200` | The request succeeded. Deleting a resource also returns `200`. | Nothing. |
| `201` | The resource was created. | Nothing. |
| `202` | The request was accepted. The [sandbox helpers](/guides/testing-in-sandbox) return this. | Nothing. |
| `400` | The request is invalid, for example a business rule failed. | Fix the request. Don't retry it unchanged. |
| `401` | The API key is missing, invalid, revoked, or doesn't have the [permission](/api-reference/permissions) the endpoint needs. | Check the key and its permissions. Don't retry. |
| `402` | The wallet balance is too low to cover the request. | Fund the wallet, then send the request again. Don't retry before you have. |
| `403` | The request is understood but refused: a business limit is exceeded, a customer or business isn't verified, or the endpoint isn't available to you. Sandbox-only endpoints return this in production. | Don't retry until the cause is fixed. |
| `404` | The resource doesn't exist for your business. The `code` names the resource. | Check the ID. |
| `409` | The request conflicts with something that exists, such as a duplicate transaction or a customer with the same email. | Don't send it again. Look up the existing resource. See [Idempotency](/api-reference/idempotency). |
| `422` | The request failed schema validation, or it is valid but can't be done, for example an unsupported country, currency, channel, or virtual account type. `details.friendlyMessage` says which. | Fix the request and send it again. |
| `429` | You sent too many requests. | Wait `Retry-After` seconds, then retry. See [Rate limits](/api-reference/rate-limits). |
| `500` | An unexpected error on our side. | Retry with backoff. If it keeps happening, contact support. |
| `503` | An upstream processor failed or timed out, or the API is briefly offline. | Retry with backoff, and honour `Retry-After` when it's present. |
## Business limits
Afriex sets limits on your business. A transaction that would take you over a limit returns `403` with `BUSINESS_LIMIT_EXCEEDED`, and `details.friendlyMessage` names the limit and the period. A transaction below the smallest amount a limit allows returns `400` with `BUSINESS_TRANSACTION_MIN_AMOUNT_NOT_MET`.
Usage for daily, weekly, and monthly limits resets on a fixed schedule, in UTC:
| Limit period | Usage resets |
| - | - |
| Daily | Every day at 00:00 UTC |
| Weekly | Every Monday at 00:00 UTC |
| Monthly | On the 1st of each month at 00:00 UTC |
After usage resets, a transaction that went over a daily, weekly, or monthly limit can go through. To ask about your limits, contact [support@afriex.com](mailto:support@afriex.com).
## Retrying
Retry only the errors that can succeed on a second attempt:
* **Retry:** `429`, `500`, `503`, and requests that time out or lose their connection. Treat a `502` or `504` the same way.
* **Don't retry:** the other `4xx` errors. They fail the same way until you change the request.
When you retry:
1. Wait before each attempt, and wait longer each time. For example, start at one second and double the wait, with a little random jitter so many clients don't retry together.
2. Use `Retry-After` when the response has it, instead of your own wait.
3. Stop after a few attempts, and surface the error.
4. For `POST /transaction`, retry with the same `meta.idempotencyKey`. A retry can never create a second transaction, and a `409` tells you the first attempt went through. See [Idempotency](/api-reference/idempotency).
## Request IDs
Every API response, successful or not, carries an `x-request-id` header. It identifies that one request in Afriex's logs, including a `503` Afriex returns when the API is briefly unavailable. Log it next to each request you send, so you can find it again when something goes wrong.
```bash theme={null}
curl -i -H "x-api-key: YOUR_API_KEY" \
https://sandbox.api.afriex.com/api/v1/customer
# x-request-id: 5f3b1c0e2a7d94c5b8e6a1d2c3b4f5a6
```
Treat it as an opaque string. Each request gets a new one.
## Getting help
If a `500` or `503` persists, or a response doesn't make sense, contact [support@afriex.com](mailto:support@afriex.com) with the `x-request-id` of the request. Add the endpoint, the time, and the `code` you received.
For a webhook question, send the event's `id` from the webhook body instead. See [Webhooks](/api-reference/endpoint/webhooks/introduction#duplicates-ordering-and-replays).
# Idempotency
Source: https://docs.afriex.com/api-reference/idempotency
Retry a transaction create without creating a second transaction: what the idempotency key and reference do, what a duplicate returns, and how to recover after a timeout.
Creating a transaction moves money, so a request that times out leaves you unsure whether it happened. Two fields in `meta` let you retry safely.
| Field | What you send |
| - | - |
| `meta.idempotencyKey` | A unique value you generate for each transaction attempt, such as a UUID. |
| `meta.reference` | Your own order or payment ID. |
Both are required on `POST /transaction`. The API checks each one separately, and each must be unique within your business. A request that reuses either value is a duplicate.
## What a duplicate returns
A duplicate returns `409 DUPLICATE_REQUEST` and creates nothing:
```json theme={null}
{
"code": "DUPLICATE_REQUEST",
"error": "Duplicate request",
"details": {
"errorMessage": "Duplicate request",
"friendlyMessage": "A transaction with the same idempotency key or merchant reference already exists"
}
}
```
* **The API does not return the original transaction.** The `409` tells you the key or reference is taken. To get the transaction, look it up (see below).
* **The rest of the request is not compared.** The same key with a different amount, customer, or currency is still a duplicate. The API does not apply the new request.
* **Two cases return the same code.** The first transaction already exists, or it is still being processed and a second request arrived at the same time. The message says which.
* **Your business only.** Another business can use the same key or reference.
## Keys and references are used once
Any transaction that was created keeps its key and reference, including one that later fails. To try a failed payout again, create a new transaction with a new `idempotencyKey` and a new `reference`.
The one case where you reuse a key is a retry of a request whose outcome you don't know, described next.
## Retrying after an error or a timeout
Decide from what you received:
| What happened | What to do |
| - | - |
| `400` or `422` | The API rejected the request before moving any money. Fix it and send it again. The same key is still free. |
| `429` | The request was not processed. Wait `Retry-After` seconds, then retry with the same key. See [Rate limits](/api-reference/rate-limits). |
| `500` or `503` | Retry with the same key and backoff. If the first attempt went through, you get `409`, which means the transaction exists. |
| Timeout or dropped connection | You don't know whether it was created. Retry with the same key, or look the transaction up first. |
| `409 DUPLICATE_REQUEST` | The transaction exists or is being processed. Don't send it again. Look it up. |
A failure before any money moves frees the key, so retrying a request that the API refused is safe.
### Look up the transaction
Find a transaction by the `reference` you sent:
```bash theme={null}
curl -H "x-api-key: YOUR_API_KEY" \
"https://sandbox.api.afriex.com/api/v1/transaction?reference=YOUR_REFERENCE"
```
An empty list means no transaction was created with that reference. The result carries the transaction's `status`, and you also receive its [webhooks](/api-reference/endpoint/webhooks/transaction-events). Use whichever you prefer to confirm the outcome.
## Swaps keep their key
A `SWAP` keeps its idempotency key even when it fails, so you can't retry a failed swap with the same key. Check its status with the lookup above. To make a new attempt, send a new `idempotencyKey` and a new `reference`.
## Choosing keys and references
* Generate a new UUID for every transaction attempt, and reuse it only to retry that same attempt.
* Use your own order or payment ID as the `reference`, so you can find the transaction later.
* Store the key with the order before you send the request. After a crash, you can then retry with the same key.
* Don't build keys from values that repeat, such as a customer ID or an amount.
# Introduction
Source: https://docs.afriex.com/api-reference/introduction
Welcome to the Afriex Business API
## Welcome to the Afriex Business API
The Afriex Business API enables you to manage customers, process payments, handle payouts, and receive real-time notifications via webhooks. This documentation provides comprehensive guides and API reference for integrating with our platform.
Get the full OpenAPI spec at this endpoint for code generation, SDKs, and API tooling.
Add the Afriex Business API to your MCP client and search the docs from Cursor, Claude Code, and other MCP-enabled tools.
Import every endpoint into Postman, with sandbox example requests and API key auth already set up.
Fork the collection into your Postman workspace in one click:
## Base URLs
The API is available on two environments:
| Environment | Base URL |
| - | - |
| Sandbox | `https://sandbox.api.afriex.com` |
| Production | `https://api.afriex.com` |
## Authentication
All API endpoints require an API key. Include it in the `x-api-key` header:
```
x-api-key: your-api-key-here
```
See [Authentication](/api-reference/authentication) to get a key, set its permissions, restrict access by IP address, and sign requests.
## API versioning
The API supports versioning via the `x-api-version` header:
```
x-api-version: 2026-05-18
```
`2026-05-18` is currently the only supported version, and it is what you get if you omit the header. Any other value is rejected with `400 Bad Request` and an `Unsupported API version` message.
### Which version number is which
You will see four version numbers in these docs. Only the `x-api-version` header selects how the API behaves.
| Where you see it | Example | What it tells you |
| - | - | - |
| `x-api-version` header | `2026-05-18` | The API behaviour your request gets. |
| URL path | `/api/v1` | The path every endpoint lives under. It is part of the URL, not something you choose per request. |
| `info.version` in the [OpenAPI spec](https://api.afriex.com/api/docs.json) | `1.0.13` | A label on the spec file. It isn't bumped every time the spec changes, so don't use it to tell whether the API changed. |
| `@afriex/sdk` package | `5.x` | The SDK release. See [Afriex SDK](/sdk/introduction) for the version these docs describe. |
To see what changed in how the API behaves, read the [changelog](/changelog).
## Core Resources
The API provides the following core resources:
Create and manage customer profiles with optional KYC information.
Set up bank accounts, mobile money, and crypto wallets for payouts.
Process deposits and withdrawals with real-time status tracking.
Receive real-time notifications for customer, payment method, and transaction events.
**Webhook firewall requirement:** If you plan to receive webhooks, allowlist the following Afriex IP addresses on your server:
| Environment | IP address |
| - | - |
| Sandbox | `34.234.189.210` |
| Production | `34.197.33.100` |
See [Webhook IP addresses](/api-reference/endpoint/webhooks/introduction#afriex-ip-addresses) for details.
## Quick Links
* [Quickstart Guide](/quickstart) - Get up and running in minutes
* [Authentication](/api-reference/authentication) - Get an API key and keep it safe
* [Postman collection](/development#using-postman) - Try every endpoint from Postman
* [Customer API](/api-reference/endpoint/customers/create) - Manage customer profiles
* [Transaction API](/api-reference/endpoint/transactions/create) - Process payments
* [Webhook Events](/api-reference/endpoint/webhooks/introduction) - Handle notifications
## Support
Need help? Contact us at [support@afriex.com](mailto:support@afriex.com).
# IP allowlisting
Source: https://docs.afriex.com/api-reference/ip-allowlisting
Restrict API access to your servers. One list covers every API key in your business.
An IP allowlist limits which addresses can call the API for your business. Afriex compares the address each request comes from with your list and rejects requests from any other address.
**The list belongs to your business, not to a key.** Every API key you create uses the same list, and you manage it in one place.
## When the allowlist applies
**An empty list means no restriction.** In sandbox and in production, the API accepts requests from any address until you add one. Once you add an address, the API accepts requests only from the addresses on your list, and rejects the rest.
We recommend adding your servers' addresses before you go live, so a leaked key can't be used from anywhere else. In production, requests must then come from a public address, and private ranges such as `10.0.0.5` are rejected.
**The allowlist works over IPv4 only.** Once your list has an entry, a request that reaches Afriex over IPv6 is rejected, even from a server you allowlisted by its IPv4 address. `api.afriex.com` and `sandbox.api.afriex.com` both resolve to IPv6 addresses as well as IPv4, and a server with IPv6 usually prefers it. Make your API client connect over IPv4, for example `curl -4`, or `family: 4` in Node's `http`/`https` request options.
## What you can add
Each entry is an IPv4 address, such as `203.0.113.10`, or an IPv4 range in CIDR notation, such as `203.0.113.0/24`. You can add up to 100 entries. IPv6 addresses aren't supported yet.
## Add or remove addresses
In the [Dashboard](https://business.afriex.com/), go to **Developer** and select the **IP Whitelists** tab.
Click **Set IP whitelist** if you have none yet, or **Add IP whitelist** to add more. Enter an address or range, click **Add**, repeat for each one, then click **Save**.
Click the **×** next to an entry and confirm.
In test mode, **Allow all IPs** allowlists `0.0.0.0/0`, which matches every address. Use it if you want an allowlist in place while you call the sandbox from a laptop or a changing network. It isn't available in live mode.
## If a request is rejected
Once your list has an entry, a request can be rejected with `400` for three reasons. The message is in `details.friendlyMessage`:
| `code` | Message | Cause |
| - | - | - |
| `INVALID_BUSINESS_API_REQUEST` | `IP 203.0.113.99 is not allowed` | The address isn't on your list. The message names the address Afriex saw. |
| `INVALID_IP_ADDRESS` | `IPv6 not supported yet` | The request arrived over IPv6. Connect over IPv4. |
| `INVALID_IP_ADDRESS` | `IP address is not in public range` | In production, the request came from a private address. |
To fix it, add the address the request came from. That is the address of the server that sends the request, as the internet sees it, which can differ from the address inside your network.
* **Use a fixed outbound address.** If your servers sit behind a NAT gateway or a load balancer, allowlist its public address. Serverless platforms and autoscaled hosts often send from addresses that change, so route API calls through a static outbound address.
* **Check which address you sent from.** The error message shows the address Afriex saw. Add that address, or a range that contains it.
## Webhooks are separate
This allowlist controls requests **to** the Afriex API. It doesn't affect webhooks. To receive webhooks, allow the [Afriex webhook IP addresses](/api-reference/endpoint/webhooks/introduction#afriex-ip-addresses) on your own firewall.
# Models
Source: https://docs.afriex.com/api-reference/models
Data models, enumerations, and field references for the Afriex Business API
This page documents the key data models returned and accepted by the API, including all enumerated values and field-level details.
***
## Customer
| Field | Type | Description |
| - | - | - |
| `customerId` | string | Unique identifier for the customer |
| `reference` | string | Shortened customer reference. Returned on customer reads (list and by-ID) and used as the `reference` when submitting a [pool-account payment proof](/api-reference/endpoint/transactions/pool-account-proof). Falls back to `customerId` when no shortened value has been assigned. |
| `name` | string | Full name of the customer |
| `email` | string (email) | Email address |
| `phone` | string | Phone number in [E.164 format](https://en.wikipedia.org/wiki/E.164) (e.g. `+2348192837465`) |
| `countryCode` | string | ISO 3166-1 alpha-2 country code (e.g. `NG`) |
| `meta` | object | Optional metadata you attach to the customer |
| `createdAt` | string (ISO 8601) | Timestamp when the customer was created |
| `updatedAt` | string (ISO 8601) | Timestamp of the last update |
On create and update requests, supply the customer's full name as `fullName`; responses return it as `name`. KYC details are submitted separately through the [Update KYC](/api-reference/endpoint/customers/update-kyc) endpoint.
***
## Transaction
| Field | Type | Description |
| - | - | - |
| `transactionId` | string | Unique identifier for the transaction |
| `customerId` | string | ID of the customer who owns this transaction |
| `sourceId` | string | ID of the source payment method |
| `destinationId` | string | ID of the destination payment method |
| `sourceAmount` | string | Amount debited from the source wallet |
| `sourceCurrency` | string | ISO 4217 currency code of the source wallet (e.g. `USD`) |
| `destinationAmount` | string | Amount credited to the destination |
| `destinationCurrency` | string | ISO 4217 currency code of the destination (e.g. `NGN`) |
| `rate` | string | Realized source-to-destination exchange rate, as `1 sourceCurrency = rate destinationCurrency` (equal to `destinationAmount / sourceAmount`) |
| `fee` | string | Fee charged for this transaction, denominated in `sourceCurrency`. Reported separately from `sourceAmount`. Omitted when no fee applied. |
| `type` | string | Transaction type. See [Transaction Types](#transaction-types) |
| `channel` | string | Payment channel of the transaction. See [Transaction Channels](#transaction-channels). |
| `status` | string | Current status. See [Transaction Statuses](#transaction-statuses) |
| `merchantReference` | string | Merchant-supplied reference (mirrors `meta.reference` from the create request) |
| `meta` | object | Metadata attached to the transaction. See [TransactionMeta](#transactionmeta) |
| `createdAt` | string (ISO 8601) | Timestamp when the transaction was created |
| `updatedAt` | string (ISO 8601) | Timestamp of the last update |
### Transaction Types
| Value | Description |
| - | - |
| `WITHDRAW` | Send funds from the Afriex wallet to a recipient's payment method |
| `DEPOSIT` | Receive funds into the Afriex wallet |
| `SWAP` | Convert funds between currencies within the Afriex wallet |
| `REVERSAL`, `SCHEDULED`, `SEND` | Can appear on transactions you list or fetch. You can't create them through the API. |
You can create `DEPOSIT`, `WITHDRAW`, and `SWAP`. Handle the other values when you read transactions back.
### Transaction Channels
| Channel | Description |
| - | - |
| `BANK_ACCOUNT` | Local bank account transfer |
| `SWIFT` | International SWIFT wire transfer |
| `MOBILE_MONEY` | Mobile money wallet (e.g. M-Pesa, MTN MoMo) |
| `UPI` | Unified Payments Interface (India) |
| `INTERAC` | Interac e-Transfer (Canada) |
| `WE_CHAT` | WeChat Pay |
| `CARD` | Card payment |
| `CRYPTO` | Crypto rail |
| `VIRTUAL_BANK_ACCOUNT` | Hosted virtual bank account |
| `POOL_ACCOUNT` | Shared pool account for incoming deposits |
| `ACH_BANK_ACCOUNT` | US ACH bank transfer |
| `PAYBILL_TILL` | Paybill / Till (M-Pesa business numbers) |
| `RFP` | Request for Payment |
| `VIRTUAL_CARD` | Virtual card |
| `ALIPAY` | Alipay |
| `WALLET` | Afriex wallet. A `SWAP` uses this channel, because it moves money between your own wallets |
| `PAYMENT_LINQ` | A payment collected through an Afriex payment link |
| `ADMIN` | A manual credit or debit to your balance made by Afriex, for example a correction |
You don't send `WALLET`, `PAYMENT_LINQ`, or `ADMIN`. They can appear on transactions you read, so handle them when you list transactions.
### Transaction Statuses
| Status | Description |
| - | - |
| `PENDING` | Transaction has been received and is awaiting processing |
| `PROCESSING` | Transaction is actively being processed |
| `SUCCESS` | Transaction settled successfully |
| `FAILED` | Transaction failed. Check `meta.failureReason` for the reason code. See [Failure reasons](#failure-reasons). |
| `CANCELLED` | Transaction was cancelled before processing |
| `REFUNDED` | Funds were returned to the sender |
| `RETRY` | Transaction is being automatically retried |
| `UNKNOWN` | Status could not be determined. Contact support. |
| `SCHEDULED` | Transaction is scheduled to be processed later |
| `CUSTOMER_ACTION_REQUIRED` | Transaction is paused pending an action from the customer |
| `REJECTED` | Transaction was rejected after review |
| `IN_REVIEW` | Transaction is held pending a review |
| `RFI_REQUESTED` | Transaction is held pending a request for information. Someone may contact you for more detail about the transfer. |
| `DISPUTED` | A dispute has been raised against the transaction |
| `DISPUTE_RESOLVED` | The dispute has been resolved |
| `DISPUTE_WON` | The dispute was resolved in your favour |
| `DISPUTE_LOST` | The dispute was resolved against you |
| `DISPUTE_EVIDENCE_SUBMITTED` | Evidence has been submitted for the dispute |
`IN_REVIEW` and `RFI_REQUESTED` are **review states**: the transaction is still in flight and waiting on a review, not on you. Treat them as non-terminal and keep listening, rather than classifying them as failures.
More generally, handle unrecognised statuses as non-terminal rather than failed. New values can be added, and existing ones do not change meaning.
Subscribe to [Transaction Events](/api-reference/endpoint/webhooks/transaction-events) to receive real-time status updates instead of polling the API.
***
## TransactionMeta
The `meta` object passed when creating a transaction. `idempotencyKey` and `reference` are required.
| Field | Type | Required | Description |
| - | - | - | - |
| `idempotencyKey` | string | **Yes** | Unique key to prevent duplicate transactions. Use a UUID or your own unique identifier. |
| `reference` | string | **Yes** | Your internal reference for the transaction (e.g. order ID). Returned on the transaction as `merchantReference`. |
| `narration` | string | No | Human-readable reason or description for the transaction |
| `invoice` | string | Required for SWIFT withdrawals; otherwise No | S3 object key for the invoice document, as returned by [Generate Upload URL](/api-reference/endpoint/media/generate-upload-url). Upload the file to the presigned URL first, then pass the returned `key` here verbatim. The object is verified to exist in storage before the transaction is created; unknown keys are rejected. |
| `settlement` | string | No | Settlement handling. `spot` (default) processes immediately and debits your **Payout wallet**. `request` is WITHDRAW-only; it debits your **Collection wallet** instead and queues the payout for the next business day in the destination payment method's country. Read that wallet's balance with [Get Balance](/api-reference/endpoint/balance/get) and `walletType=DEPOSIT`. Omit `settlement` (or send `spot`) for an ordinary payout. |
```json theme={null}
{
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"reference": "ORDER-12345",
"narration": "Payment for invoice #12345",
"invoice": "64f0c2a1e4b0a1b2c3d4e5f6/invoice.pdf"
}
}
```
Always provide a unique `idempotencyKey` per transaction attempt. If a transaction with the same key (or the same `reference`) already exists or is still being processed, Afriex rejects the request with `409 DUPLICATE_REQUEST` instead of creating a duplicate. Look up the original with [List Transactions](/api-reference/endpoint/transactions/list). A request that was rejected with `429` never created a transaction, so you can retry it with the same key. See [Idempotency](/api-reference/idempotency) for how to recover after a timeout.
### Server-set meta fields
Afriex also sets fields on `meta` after the transaction is created. These echo alongside any fields you passed on create.
| Field | Type | Description |
| - | - | - |
| `otpRequired` | boolean | `true` when the customer must complete an OTP challenge before the transaction can settle. See [Simulating OTP-required deposits](/development#simulating-otp-required-deposits). |
| `failureReason` | object | Present only when `status` is `FAILED` or `REJECTED`. Carries a stable `code`, a customer-safe `message`, and a `retryable` flag. See [Failure reasons](#failure-reasons). |
### Failure reasons
When a transaction reaches `FAILED` or `REJECTED`, `meta.failureReason` explains why using a stable `AFX_*` code and a customer-safe message. Branch on `code` rather than the underlying rail so your integration stays stable across routing changes.
| Field | Type | Description |
| - | - | - |
| `code` | string | Stable `AFX_*` failure code. Safe to switch on; the set grows over time but existing values do not change meaning. |
| `message` | string | Customer-safe short description of the failure. Suitable for display; do not parse. Branch on `code` instead. |
| `retryable` | boolean | `true` when re-submitting the same request (or, for `AFX_PROPOSAL_EXPIRED`, starting a fresh proposal) may succeed. `false` when the caller must change the request before retrying. |
```json theme={null}
{
"meta": {
"failureReason": {
"code": "AFX_VELOCITY_LIMIT_EXCEEDED",
"message": "This transaction exceeds the allowed velocity limit. Try again later.",
"retryable": true
}
}
}
```
| Code | Meaning |
| - | - |
| `AFX_REQUEST_FAILED` | Generic failure while processing the request. Usually retryable. |
| `AFX_SYSTEM_ERROR` | Internal Afriex error. Retry with backoff. |
| `AFX_SERVICE_UNAVAILABLE` | A downstream service was unavailable. Retry with backoff. |
| `AFX_INVALID_CURRENCY` | The requested currency is not supported for this route. |
| `AFX_INVALID_AMOUNT` | The requested amount is invalid or outside allowed limits. |
| `AFX_INVALID_RECIPIENT` | The recipient payment method is invalid or incomplete. |
| `AFX_ACCOUNT_CLOSED` | The recipient account is closed. |
| `AFX_NAME_MISMATCH` | The beneficiary name does not match the account records. |
| `AFX_RECIPIENT_NOT_FOUND` | The recipient account could not be located at the destination institution. |
| `AFX_BENEFICIARY_RESTRICTED` | The beneficiary is restricted and cannot receive funds. |
| `AFX_INVALID_SENDER` | The sender's payment method is invalid or ineligible. |
| `AFX_INVALID_REQUEST` | The request payload is malformed or inconsistent. |
| `AFX_VELOCITY_LIMIT_EXCEEDED` | The customer has exceeded a velocity limit. Retry after the window resets. |
| `AFX_AMOUNT_LIMIT_EXCEEDED` | The request exceeds a per-transaction, daily, or account limit. |
| `AFX_RECIPIENT_WALLET_LIMIT_REACHED` | The recipient's wallet has reached a limit at the destination, so it cannot receive this payout. |
| `AFX_PAYMENT_FAILED` | The payment leg failed at the payment rail. |
| `AFX_COMPLIANCE_REJECTED` | The transaction was rejected during compliance review. |
| `AFX_PROPOSAL_EXPIRED` | The rate proposal expired before the transaction was committed. Start a fresh proposal. |
The `AFX_*` set may grow over time as new failure modes are surfaced. Existing values will not change meaning. Handle unknown codes by falling back to `message`.
***
## PaymentMethod
Empty fields are omitted from responses. Account-shaped channels (`BANK_ACCOUNT`, `MOBILE_MONEY`, `SWIFT`, etc.) populate `accountName`, `accountNumber`, and `institution`; the `CARD` channel instead populates `last4`, `brand`, `expiration`, and `cardName`.
| Field | Type | Description |
| - | - | - |
| `paymentMethodId` | string | Unique identifier for the payment method |
| `customerId` | string | ID of the customer who owns this payment method |
| `channel` | string | Payment channel. See [Payment Channels](#payment-channels) |
| `countryCode` | string | ISO 3166-1 alpha-2 country code |
| `currency` | string | ISO 4217 currency code. Use this instead of inferring from `countryCode`, which is ambiguous (e.g. a USD account in a non-US country) |
| `capabilities` | string\[] | Operations this method is enabled for (e.g. `DEPOSIT`, `WITHDRAW`). Filterable via the `capabilities` query parameter on [List Payment Methods](/api-reference/endpoint/payment-methods/list) |
| `reference` | string | Identifier used to reconcile incoming deposits against |
| `accountName` | string | Account holder name. Present for account-shaped channels |
| `accountNumber` | string | Account number. Present for account-shaped channels |
| `routingNumber` | string | Bank routing number. Present for account-shaped channels that carry one (e.g. ACH bank accounts) |
| `bankAddress` | object | Issuing bank's postal address. See [PaymentMethodBankAddress](#paymentmethodbankaddress) |
| `institution` | object | Bank or provider details. See [PaymentMethodInstitution](#paymentmethodinstitution) |
| `recipient` | object | Recipient contact details. See [PaymentMethodRecipient](#paymentmethodrecipient) |
| `transaction` | object | Per-transaction overrides. See [PaymentMethodTransaction](#paymentmethodtransaction) |
| `status` | string | Lifecycle status. See [Payment Method Statuses](#payment-method-statuses) |
| `last4` | string | Last 4 digits of the card. `CARD` channel only |
| `brand` | string | Card brand (e.g. `Visa`, `MasterCard`). `CARD` channel only |
| `expiration` | object | Card expiry with `month` and `year`. `CARD` channel only |
| `cardName` | string | Name on the card. `CARD` channel only |
| `expiresInMinutes` | integer | Minutes until a dynamic virtual account expires, when applicable |
| `amount` | number | Requested amount for a dynamic virtual account, when applicable |
| `extra` | object | Additional channel-specific properties |
| `meta` | object | Optional metadata attached to the payment method |
### Payment Channels
| Channel | Description |
| - | - |
| `BANK_ACCOUNT` | Local bank account transfer |
| `MOBILE_MONEY` | Mobile money wallet (e.g. M-Pesa, MTN MoMo) |
| `SWIFT` | International SWIFT wire transfer |
| `INTERAC` | Interac e-Transfer (Canada) |
| `UPI` | Unified Payments Interface (India) |
| `WE_CHAT` | WeChat Pay |
| `ALIPAY` | Alipay |
| `CARD` | Card payment |
| `CRYPTO` | Crypto rail |
| `VIRTUAL_BANK_ACCOUNT` | Hosted virtual bank account |
| `POOL_ACCOUNT` | Shared pool account for incoming deposits |
| `ACH_BANK_ACCOUNT` | US ACH bank transfer |
| `PAYBILL_TILL` | Paybill / Till (M-Pesa business numbers) |
| `RFP` | Request for Payment |
| `VIRTUAL_CARD` | Virtual card |
Not every channel is valid when **creating** a payment method. The [Create Payment Method](/api-reference/endpoint/payment-methods/create) endpoint accepts a narrower set of channels; the list above covers all channels that can appear on a returned payment method.
### Payment Method Statuses
| Status | Description |
| - | - |
| `active` | Payment method is active and usable |
| `pending` | Payment method is awaiting activation or verification |
| `deleted` | Payment method has been deleted |
| `expired` | Payment method has expired (e.g. an expired card) |
| `blocked` | Payment method has been blocked |
***
## PaymentMethodRecipient
Recipient contact details attached to a payment method, when supplied.
| Field | Type | Description |
| - | - | - |
| `recipientName` | string | Name of the recipient |
| `recipientEmail` | string (email) | Email address of the recipient |
| `recipientPhone` | string | Phone number of the recipient in E.164 format |
| `recipientAddress` | string | Address of the recipient |
A payment method returns only the four fields above. These are accepted when you create one but are not returned on it:
| Field | Type | Description |
| - | - | - |
| `recipientAddressLine1` | string | Optional. First line of `recipientAddress`. Used on SWIFT payment methods when `institutionAddressLine1` is not sent. |
| `recipientAddressCity` | string | Optional. City of `recipientAddress` |
| `recipientAddressState` | string | Optional. State of `recipientAddress` |
| `recipientAddressZip` | string | Optional. Postal or ZIP code of `recipientAddress` |
| `recipientAddressCountry` | string | Optional. Country of `recipientAddress` |
| `recipientIdType` | string | The type of the recipient's ID. Needed, with `recipientIdNumber`, for `BANK_ACCOUNT` payment methods in CNY, where it is recorded as the recipient's KYC. |
| `recipientIdNumber` | string | The number of the ID named by `recipientIdType`. Needed for `BANK_ACCOUNT` payment methods in CNY. |
| `recipientUpiId` | string | The recipient's UPI ID, used on UPI payment methods. When omitted, `accountNumber` is used. |
***
## PaymentMethodTransaction
Optional per-transaction details attached to a payment method.
| Field | Type | Description |
| - | - | - |
| `transactionNarration` | string | Optional reason or description for the transaction |
| `transactionInvoice` | string | S3 object key for the invoice document, as returned by [Generate Upload URL](/api-reference/endpoint/media/generate-upload-url) |
***
## PaymentMethodInstitution
| Field | Type | Description |
| - | - | - |
| `institutionId` | string | Unique ID of the bank or mobile money provider |
| `institutionName` | string | Display name of the institution |
| `institutionCode` | string | Institution code (e.g. bank code) |
| `institutionAddress` | string | Physical address of the institution, if required |
| `correspondentBankName` | string | Correspondent (intermediary) bank name. **Mandatory for USD (SWIFT) payout payment methods**; required to produce settlement advices/invoices. |
| `correspondentBankAccountNumber` | string | Correspondent (intermediary) bank account number. **Mandatory for USD (SWIFT) payout payment methods**; required to produce settlement advices/invoices. |
A payment method returns only the fields above. These are accepted when you create one but are not returned on it:
| Field | Type | Description |
| - | - | - |
| `institutionAddressLine1` | string | Optional. First line of `institutionAddress`, for SWIFT payment methods. Used ahead of `recipientAddressLine1`. |
| `institutionAddressCity` | string | Optional. City of `institutionAddress`, for SWIFT payment methods |
| `institutionAddressState` | string | Optional. State of `institutionAddress`, for SWIFT payment methods |
| `institutionAddressZip` | string | Optional. Postal or ZIP code of `institutionAddress`, for SWIFT payment methods |
| `institutionAddressCountry` | string | Optional. Country of `institutionAddress`, for SWIFT payment methods |
| `institutionBranch` | string | The branch of the bank. Required when you create a `BANK_ACCOUNT` payment method in CNY. The institution list returns it too, often as an empty string. |
Use the [List Institutions](/api-reference/endpoint/payment-methods/institution) endpoint to retrieve valid institutions for a given country and channel.
***
## PaymentMethodBankAddress
The postal address of the **issuing bank**, not the account holder. Populated per account by the provider that provisioned it, so it is present on virtual accounts and other account-shaped payment methods whose provider supplies it, and omitted otherwise.
| Field | Type | Description |
| - | - | - |
| `line1` | string | First line of the bank's street address |
| `line2` | string | Second line of the bank's street address, when present |
| `city` | string | City |
| `state` | string | State or region, when present |
| `zip` | string | Postal or ZIP code |
| `country` | string | Country, as supplied by the provider |
This is the address to quote as the beneficiary bank when originating an international wire into a virtual account.
On [Create Transaction](/api-reference/endpoint/transactions/create), `correspondentBankName` and `correspondentBankAccountNumber` can also be passed at the top level as a **fallback** when the destination payment method lacks them (USD `WITHDRAW` with `meta.settlement: "request"`). The values stored on the payment method take precedence; the two fields must be supplied together.
***
## PaymentBatch
A saved group of payout recipients funded from one wallet. Add recipients over time, then trigger the payouts in one call with [Withdraw Batch](/api-reference/endpoint/payment-batches/withdraw). Each execution is recorded as a [session](#paymentbatchsession).
| Field | Type | Description |
| - | - | - |
| `id` | string | Unique identifier for the batch |
| `name` | string | Label for the batch |
| `sourcePaymentMethod` | object | The wallet the batch is funded from, with its `channel` and `currencyCode`. Every payout in the batch is converted from this currency. |
The list endpoint returns a lighter shape (`id`, `name`, `meta`) without the full `sourcePaymentMethod`; call [Get Payment Batch](/api-reference/endpoint/payment-batches/get) for the full record.
### PaymentBatchRecipient
A recipient inside a batch: a payout target with a fixed amount for each run.
| Field | Type | Description |
| - | - | - |
| `recipientId` | string | The recipient's id within this batch. Use this to update or remove the recipient. |
| `paymentMethodId` | string | The saved payment method backing this recipient (list responses populate this; add/update return the same value in `id`). |
| `channel` | string | Payment channel. See [Payment Channels](#payment-channels). |
| `countryCode` | string | ISO 3166-1 alpha-2 country code |
| `accountName` | string | Account holder name |
| `accountNumber` | string | Account number |
| `institution` | object | Bank / provider details. See [PaymentMethodInstitution](#paymentmethodinstitution). |
| `amount` | object | Per-recipient payout amount for every run of the batch: `value`, a numeric string such as `"15000"`, and `currencyCode`, such as `"NGN"` |
### PaymentBatchSession
A single execution of [Withdraw Batch](/api-reference/endpoint/payment-batches/withdraw). Each session records the recipient outcomes for that run.
| Field | Type | Description |
| - | - | - |
| `id` | string | Session (run) id |
| `batchId` | string | The batch this run belongs to |
| `createdAt` | string (ISO 8601) | When the run was started |
| `meta` | object | Per-run metadata, including outcomes |
Bulk operations (bulk add recipients, withdraw) return a **`PaymentBatchOutcomes`** shape with `successes` and `errors` keyed by account number: each entry is either the created object or the reason it failed. Use this to reconcile which items landed.
***
## Error Response
All API errors return a consistent JSON body:
| Field | Type | Description |
| - | - | - |
| `code` | string | Machine-readable error code (e.g. `AUTHENTICATION_ERROR`) |
| `error` | string | Short error category (e.g. `Bad Request`) |
| `details.errorMessage` | string | Specific error reason (e.g. `PHONE_COUNTRY_MISMATCH`) |
| `details.friendlyMessage` | string | User-friendly message you can display to end users |
| `details.data` | object | Optional caller-safe context. On a customer-create uniqueness conflict (`EMAIL_ALREADY_EXISTS`, `PHONE_NUMBER_ALREADY_EXISTS`) contains `{ customerId }` for the existing customer so you can adopt it without a follow-up lookup |
```json theme={null}
{
"code": "INVALID_BUSINESS_CUSTOMER_REQUEST",
"error": "Bad Request",
"details": {
"errorMessage": "PHONE_COUNTRY_MISMATCH",
"friendlyMessage": "The phone number does not match the customer's country."
}
}
```
### Common error codes
| Code | HTTP Status | Description |
| - | - | - |
| `AUTHENTICATION_ERROR` | 401 | API key is missing or invalid |
| `INVALID_BUSINESS_CUSTOMER_REQUEST` | 400 | Customer request validation failed. `details.friendlyMessage` says which field, for example `Invalid customer phone provided: PHONE_COUNTRY_MISMATCH` when the phone number doesn't match `countryCode` |
| `EMAIL_ALREADY_EXISTS` | 409 | A customer with this email already exists. `details.data.customerId` returns the existing customer's id. |
| `PHONE_NUMBER_ALREADY_EXISTS` | 409 | A customer with this phone number already exists. `details.data.customerId` returns the existing customer's id. |
| `DUPLICATE_REQUEST` | 409 | A transaction with the same `meta.idempotencyKey` or `meta.reference` already exists or is being processed. See [Idempotency](/api-reference/idempotency) |
| `BUSINESS_CUSTOMER_NOT_FOUND` | 404 | No customer with that id belongs to your business |
| `BUSINESS_PAYMENT_METHOD_NOT_FOUND` | 404 | No payment method with that id belongs to your business. [Get Pool Account](/api-reference/endpoint/payment-methods/pool-account) also returns it when your business has no pool account in the country you asked for |
| `BALANCE_INSUFFICIENT` | 402 | Your wallet balance is too low to cover the transaction. Fund the wallet and send the request again |
| `BUSINESS_LIMIT_EXCEEDED` | 403 | The transaction would take your business over one of its limits. `details.friendlyMessage` names the limit and the period. See [Business limits](/api-reference/errors#business-limits) |
| `BUSINESS_TRANSACTION_MIN_AMOUNT_NOT_MET` | 400 | The transaction is below the smallest amount one of your business limits allows. See [Business limits](/api-reference/errors#business-limits) |
| `COUNTRY_NOT_SUPPORTED` | 422 | The destination country is not currently available |
| `PAYMENT_METHOD_CURRENCY_MISMATCH` | 422 | The payment method's currency does not match `destinationCurrency` |
| `VIRTUAL_ACCOUNT_LIMIT_REACHED` | 403 | The per-(business, customer, currency) virtual account limit is reached |
| `VIRTUAL_ACCOUNT_BVN_REQUIRED` | 400 | A permanent `NGN` virtual account needs a BVN and the holder has none. See [Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create#bvn-requirement-for-ngn-static-virtual-accounts) |
| `UNSUPPORTED_VIRTUAL_ACCOUNT_CURRENCY` | 422 | The currency cannot be held by the account holder you asked for, for example a customer asking for `KES` |
| `UNSUPPORTED_VIRTUAL_ACCOUNT_TYPE` | 422 | The currency does not offer the account type you asked for, permanent (`label`) or one-time (`amount`) |
| `BUSINESS_TRANSACTION_NOT_FOUND` | 404 | No transaction with that id belongs to your business |
| `VALIDATION_ERROR` | 422 | Request failed schema validation. `details.friendlyMessage` names the offending field |
| `RATE_LIMIT_ERROR` | 429 | Too many requests. Wait the number of seconds in the `Retry-After` header when it is present, then retry. See [Rate limits](/api-reference/rate-limits) |
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error. Safe to retry idempotent requests with backoff |
| `SERVICE_UNAVAILABLE` | 503 | An upstream processor failed or timed out, or the API is momentarily offline. Retry with backoff and honour `Retry-After` when present |
There is no generic `NOT_FOUND` code. Missing resources return a specific code naming the resource, as above.
# Pagination
Source: https://docs.afriex.com/api-reference/pagination
Page through list endpoints with page and limit: the response shape, the sort order of each list, and how to read a list that changes while you page.
List endpoints return results one page at a time. You choose the page with `page` and its size with `limit`.
## Parameters
| Parameter | Description |
| - | - |
| `page` | Zero-based page number. The first page is `0`. Defaults to `0`. |
| `limit` | Items per page, from `1` to `100`. Defaults to `10`, or `100` on the payment batch lists. |
A `limit` above `100` or below `1` is rejected with `422`. It is not reduced to the maximum for you.
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/transaction?page=0&limit=100" \
-H "x-api-key: YOUR_API_KEY"
```
## Response
Every paginated response has the same shape:
```json theme={null}
{
"data": [ ... ],
"total": 285,
"page": 0
}
```
| Field | Description |
| - | - |
| `data` | The items on this page. |
| `total` | The number of items that match your filters, across all pages. |
| `page` | The page you asked for. |
There is no next-page cursor. You have reached the last page when `(page + 1) * limit` is at least `total`, or when `data` is empty.
## Sort order
Each list has a fixed order. You can't change it.
| Endpoint | Order |
| - | - |
| [List Transactions](/api-reference/endpoint/transactions/list) | Newest first, by creation time |
| [List Customers](/api-reference/endpoint/customers/list) | Alphabetical, by full name |
| [List Payment Methods](/api-reference/endpoint/payment-methods/list) | Most recently updated first |
| [List Payment Batches](/api-reference/endpoint/payment-batches/list), [List Batch Recipients](/api-reference/endpoint/payment-batches/list-recipients), [List Batch Sessions](/api-reference/endpoint/payment-batches/list-sessions) | Newest first, by creation time |
## Reading every page
Request pages in order until you reach the last one:
```typescript theme={null}
async function listAllTransactions(apiKey: string) {
const limit = 100;
const seen = new Map();
for (let page = 0; ; page++) {
const res = await fetch(
`https://sandbox.api.afriex.com/api/v1/transaction?page=${page}&limit=${limit}`,
{ headers: { "x-api-key": apiKey } },
);
const { data, total } = await res.json();
// Key by ID so an item that shifts onto the next page is not counted twice.
for (const tx of data) seen.set(tx.transactionId, tx);
if (data.length === 0 || (page + 1) * limit >= total) break;
}
return [...seen.values()];
}
```
## Lists that change while you page
Each request counts its page from the start of the list at that moment. If items are added or reordered between your requests, the pages shift under you:
* **Transactions.** A transaction created while you page goes on page `0` and pushes every other item down by one. The last item on a page you already read then appears again at the top of the next page. Key the results by `transactionId`, as the example above does.
* **Payment methods.** The list is ordered by last update, so a payment method that changes while you page moves to page `0`. You can miss it on the page you were about to read. If you need a complete set, read the list a second time and merge the results by `paymentMethodId`.
To reconcile transactions, fix the window with `fromDate` and a `toDate` in the past. No new transaction can enter that window while you page, so the set stays stable. See [List Transactions](/api-reference/endpoint/transactions/list).
Don't poll a list to see whether a transaction finished. Status changes arrive as [webhooks](/api-reference/endpoint/webhooks/transaction-events).
# Permissions
Source: https://docs.afriex.com/api-reference/permissions
The permission each Afriex Business API endpoint requires, plus how keys and permissions relate.
Every API key is scoped to a configurable set of **permissions** chosen when the key is created in the [Dashboard](https://business.afriex.com/). Requests are rejected with `401 Unauthorized` when the key does not carry the permission the target endpoint requires, so pick the minimum set your integration needs.
Keys created **before** permissions existed carry no permissions and keep access to every endpoint, so no action is required for older keys. Provision a fresh key from the dashboard to opt into the scoped model.
## How the check works
* **Header:** requests carry the key in `x-api-key` (see [Authentication](/api-reference/authentication)).
* **One permission per endpoint:** each endpoint requires the permission listed below and accepts no broader substitute. Two rows work differently: `POST /transaction` requires the permission for the transaction `type` you send, and the sandbox-only simulate endpoint accepts either of two permissions.
* **Failure mode:** a key missing the required permission is rejected with `401 Unauthorized`, exactly the same shape as an unrecognised, malformed, revoked, or disabled key. The API does not distinguish those cases on the wire.
**`POST /transaction` checks the transaction type.** A key needs the permission for the type it creates: `TRANSACTION.DEPOSIT.CREATE` for a `DEPOSIT`, `TRANSACTION.SWAP.CREATE` for a `SWAP`, and `TRANSACTION.WITHDRAW.CREATE` for anything else (a request with no `type` is treated as a withdrawal). A key that holds only the deposit permission cannot create a withdrawal. If a request that used to work now returns `401`, check that the key holds the permission for the type you send.
Rates, transaction quotes, settlement advice, media uploads, payment method deletion, and payment batches each have their own permission. Select it when you create the key. See [Choosing permissions in the dashboard](#choosing-permissions-in-the-dashboard) for how the dashboard's access levels map to these permissions.
## Endpoint permission reference
| Endpoint | Permission |
| - | - |
| `GET /customer`, `GET /customer/{customerId}` | `CUSTOMER.READ` |
| `POST /customer` | `CUSTOMER.CREATE` |
| `PATCH /customer/{customerId}`, `PATCH /customer/{customerId}/kyc`, `POST /customer/{customerId}/verify` | `CUSTOMER.UPDATE` |
| `DELETE /customer/{customerId}` | `CUSTOMER.DELETE` |
| `GET /payment-method`, `GET /payment-method/{paymentMethodId}`, `GET /payment-method/institution`, `GET /payment-method/institution/codes`, `GET /payment-method/resolve`, `GET /payment-method/virtual-account`, `GET /payment-method/pool-account` | `PAYMENT_METHOD.READ` |
| `POST /payment-method`, `POST /payment-method/virtual-account`, `GET /payment-method/crypto-wallet`, `POST /payment-method/virtual-account/simulate-transfer` *(sandbox only)* | `PAYMENT_METHOD.CREATE` |
| `DELETE /payment-method/{paymentMethodId}` | `PAYMENT_METHOD.DELETE` |
| `POST /transaction` | `TRANSACTION.DEPOSIT.CREATE` for a deposit, `TRANSACTION.SWAP.CREATE` for a swap, otherwise `TRANSACTION.WITHDRAW.CREATE` |
| `GET /transaction/quote` | `TRANSACTION.QUOTE.READ` |
| `POST /transaction/{transactionId}/authorize`, `POST /transaction/pool-account` | `TRANSACTION.DEPOSIT.CREATE` |
| `POST /transaction/{transactionId}/simulate` *(sandbox only)* | `TRANSACTION.DEPOSIT.CREATE` or `TRANSACTION.WITHDRAW.CREATE` |
| `GET /transaction`, `GET /transaction/{transactionId}` | `TRANSACTION.HISTORY.READ` |
| `GET /transaction/{transactionId}/advice` | `TRANSACTION.RECEIPT.READ` |
| `GET /org/balance` | `WALLET.BALANCES.READ` |
| `GET /org/rates` | `RATES.READ` |
| `POST /media/url` | `MEDIA.UPLOAD` |
| `POST /sme-registration` | `COMPLIANCE.KYB.SUBMIT` |
| `GET /sme-registration/status` | `COMPLIANCE.KYB.READ` |
| `POST /checkout-session` | `CHECKOUT_LINK.CREATE` |
| `GET /payment-batch`, `GET /payment-batch/{batchId}`, `GET /payment-batch/{batchId}/recipients` | `PAYMENT_BATCH.READ` |
| `POST /payment-batch` | `PAYMENT_BATCH.CREATE` |
| `PATCH /payment-batch/{batchId}`, `POST /payment-batch/{batchId}/recipients`, `POST /payment-batch/{batchId}/recipients/bulk`, `PATCH /payment-batch/{batchId}/recipients/{recipientId}`, `DELETE /payment-batch/{batchId}/recipients/{recipientId}` | `PAYMENT_BATCH.UPDATE` |
| `DELETE /payment-batch/{batchId}` | `PAYMENT_BATCH.DELETE` |
| `POST /payment-batch/{batchId}/withdraw` | `TRANSACTION.WITHDRAW.CREATE` |
| `GET /payment-batch/{batchId}/sessions` | `TRANSACTION.HISTORY.READ` |
| `POST /org/balance/topup`, `POST /webhooks/trigger` | None; any valid key |
## Choosing permissions in the dashboard
When you create a key under **Developer → API keys**, you pick an access level for each resource: **None**, **Read**, or **Write**. **Write** includes **Read**. Each level grants the API permissions below.
| Resource | Read grants | Write adds |
| - | - | - |
| Balances | `WALLET.BALANCES.READ` | |
| Rates and quotes | `RATES.READ`, `TRANSACTION.QUOTE.READ` | |
| Transactions | `TRANSACTION.HISTORY.READ`, `TRANSACTION.RECEIPT.READ` | |
| Deposits | | `TRANSACTION.DEPOSIT.CREATE` |
| Withdrawals | | `TRANSACTION.WITHDRAW.CREATE` |
| Swaps | | `TRANSACTION.SWAP.CREATE` |
| Customers | `CUSTOMER.READ` | `CUSTOMER.CREATE`, `CUSTOMER.UPDATE`, `CUSTOMER.DELETE` |
| Payment methods | `PAYMENT_METHOD.READ` | `PAYMENT_METHOD.CREATE`, `PAYMENT_METHOD.DELETE` |
| Payment batches | `PAYMENT_BATCH.READ` | `PAYMENT_BATCH.CREATE`, `PAYMENT_BATCH.UPDATE`, `PAYMENT_BATCH.DELETE` |
| Checkout sessions | | `CHECKOUT_LINK.CREATE` |
| Business verification | `COMPLIANCE.KYB.READ` | `COMPLIANCE.KYB.SUBMIT` |
| File uploads | | `MEDIA.UPLOAD` |
* **Some resources have only a Write level.** Deposits, Withdrawals, Swaps, Checkout sessions, and File uploads grant no read permission.
* **Levels apply to a whole resource.** You cannot grant create without delete on the same resource.
* **You can only grant what you hold.** A level is offered only if you hold every permission in it, so a teammate without a permission cannot give it to a key.
### Start from a preset
The key form offers three starting points. You can adjust any level afterwards.
| Preset | What it sets |
| - | - |
| Read only | **Read** on every resource that has a Read level |
| Checkout only | **Write** on Checkout sessions |
| Payouts | **Read** on Balances, Rates and quotes, and Transactions. **Write** on Withdrawals, Customers, Payment methods, and Payment batches |
A payout needs a recipient, and a recipient is a payment method on a customer. That is why the Payouts preset also includes Customers and Payment methods.
Managing keys? Head to **Developer → API keys** in the [Dashboard](https://business.afriex.com/) to create, rotate, or revoke them.
# Rate limits
Source: https://docs.afriex.com/api-reference/rate-limits
How many requests your business can send, the headers that tell you where you stand, and what to do with a 429.
Some endpoints limit how many requests your business can send in a rolling window. Each business has its own limit, so other businesses' traffic never counts against yours. Going over the limit returns `429 Too Many Requests`.
## Limits
| Endpoint | Limit |
| - | - |
| `POST /transaction` | 60 requests per 60 seconds |
| `POST /customer/{customerId}/verify` | 100 requests per 24 hours |
Both limits apply in production and in sandbox.
The window is rolling: it counts the requests you sent in the last 60 seconds (or 24 hours), not since a fixed time. Requests rejected with `429` count too.
**Each limit is separate.** Being rate limited on one endpoint does not affect the other, and requests to one do not count against the other.
**Apart from these two, no endpoint has a per-business rate limit.** Reading data, such as listing or fetching transactions, customers, and payment methods, does not count toward these limits.
## Rate limit headers
Responses from these endpoints tell you where you stand against the limit:
| Header | Meaning |
| - | - |
| `X-RateLimit-Limit` | The maximum number of requests allowed in the window. |
| `X-RateLimit-Remaining` | The number of requests left in the current window. |
| `X-RateLimit-Reset` | Seconds until your next request fits in the window. |
| `Retry-After` | Sent with a `429`. Seconds to wait before you retry. |
## Handling a 429
A rate-limited request returns this body:
```json theme={null}
{
"code": "RATE_LIMIT_ERROR",
"error": "You can't make this request at the moment. Please try again later",
"details": {}
}
```
When you receive one:
1. Wait the number of seconds in `Retry-After`, then retry.
2. Retry a transaction with the same `meta.idempotencyKey`, so a retry can never create a second transaction.
3. Don't retry straight away. Rejected requests count toward the limit, so an immediate retry keeps you limited. If you keep getting `429`s, pause and look for a loop in your integration.
## Testing in sandbox
Sandbox enforces the same limits as production, so you can trigger a `429` there and test your handling before you go live. Check that your integration:
* Reads `Retry-After` and waits that long before it retries.
* Retries a transaction with the same `meta.idempotencyKey`.
* Slows down as `X-RateLimit-Remaining` approaches `0`.
To trigger the transaction limit, send more than 60 `POST /transaction` requests within 60 seconds. Rejected requests count toward the limit, so a customer verify test that goes over its limit stays limited until the 24-hour window clears.
A sandbox load test above 60 transaction creates a minute sees `429`s, just as it would in production. Size load tests to the volume you expect in production.
## Staying within the limits
Watch `X-RateLimit-Remaining` and slow down before it reaches `0`.
Reading data does not count toward either limit. These habits don't change your count, but they keep your integration efficient:
* **Let webhooks tell you.** Transaction status changes arrive as [webhooks](/api-reference/endpoint/webhooks/introduction), so you don't need to fetch a transaction again and again to see whether it finished.
* **Reconcile with the list endpoint.** [List transactions](/api-reference/endpoint/transactions/list) with `fromDate` and `toDate` instead of fetching each transaction on its own.
## Higher limits
Need a higher limit? Email [support@afriex.com](mailto:support@afriex.com) and tell us which endpoint it is for, how many requests you expect at your busiest and on an ordinary day, and what you are building.
# Changelog
Source: https://docs.afriex.com/changelog
Changes to how the Afriex Business API behaves, newest first.
This page lists changes that affect how you integrate. New pages and wording fixes in the documentation are not listed. The current API version is `2026-05-18`. See [API versioning](/api-reference/introduction#api-versioning).
* **No name lookup in a country.** [Resolve payment method](/api-reference/endpoint/payment-methods/resolve) returns `400` with a message saying account name lookup is not available, when the country has none. Ask your customer for the recipient's name instead. For mobile money it used to return `500`. For bank accounts the status is the same and the message is clearer.
* **Limit errors.** A transaction that would take your business over one of its limits returns `403` with `BUSINESS_LIMIT_EXCEEDED`, and `details.friendlyMessage` names the limit and the period. `BUSINESS_TRANSACTION_DAILY_LIMIT_EXCEEDED` is no longer returned. A transaction below the smallest amount a limit allows returns `400` with `BUSINESS_TRANSACTION_MIN_AMOUNT_NOT_MET`.
* **Reset times.** Usage for daily, weekly, and monthly limits resets at 00:00 UTC: every day, every Monday, and on the 1st of each month. See [Business limits](/api-reference/errors#business-limits).
* **Trigger the checkout webhook with the `checkoutId`.** [Trigger webhook](/api-reference/endpoint/webhooks/trigger) accepts the `checkoutId` that create checkout session returns as the `entityId` for `CHECKOUT_SESSION.CREATED`. The checkout must belong to your business, and the webhook is sent with the `checkoutId` as its `sessionId`, like the real one. Before, the trigger needed a UUID that only exists once a customer opens the checkout.
* **Error statuses that fit the error.** Several errors came back as `503`, so a client that retries `503` kept retrying a request that could not succeed. They now return the status that fits. A wallet balance that is too low returns `402` with `BALANCE_INSUFFICIENT`. An unsupported country, currency, channel, or virtual account type, a payment method with no route, and a payment method whose currency does not match return `422`. A swap your business has not enabled returns `403`. The codes and bodies are unchanged. See [Errors](/api-reference/errors).
* **Malformed IDs.** A malformed `customerId` on [create payment method](/api-reference/endpoint/payment-methods/create), or a malformed `batchId` on the payment batch endpoints, returns `400`. It used to return `500`.
* **Payment method for the business owner.** Create payment method without a `customerId` attaches the payment method to the business owner, as documented. It used to return `404` with `BUSINESS_CUSTOMER_NOT_FOUND`.
* **Status filter.** `?status=IN_REVIEW` on [list transactions](/api-reference/endpoint/transactions/list) now matches the transactions the API reports as `IN_REVIEW`. Values the API never returns are no longer accepted as status filters.
* **Checkout metadata is kept.** The `metadata` you send when you create a checkout is stored and returned in the `CHECKOUT_SESSION.CREATED` webhook. It used to be dropped.
* **Rejected up front.** These now return a clear client error instead of failing later or returning `500`: a payment batch recipient with no amount, a media upload `fileName` that is empty once cleaned, a date of birth with a month that does not exist, and an SME registration step with no `data`.
* **Checkout ID.** [Create checkout session](/api-reference/endpoint/checkout-sessions/create) returns a `checkoutId`. It is the `sessionId` in the `CHECKOUT_SESSION.CREATED` webhook, so you can match the webhook to the checkout you created.
* **References stay held while a checkout is open.** You can't create another checkout with the same `merchantReference` while an earlier one is still open. It returns `409`.
* **A paid checkout closes.** Once a checkout's payment is credited, it can't be opened again.
* **A wrong document value is rejected.** [Verify customer](/api-reference/endpoint/customers/verify) returns `400` with `INVALID_KYC_DOCUMENT_TYPE` when the customer already has that document type on file and your `docValue` differs. It used to return `200` as if the value had been verified, without comparing it.
* **Malformed transaction IDs.** `GET /transaction/{transactionId}/advice` returns `400` for a malformed ID, like `GET /transaction/{transactionId}`. It used to return `500`.
* **`toDate` on its own.** [List transactions](/api-reference/endpoint/transactions/list) accepts `toDate` without `fromDate`. It is compared with `fromDate` only when you send both. It used to be rejected.
* **Stricter limits that were already documented.** [Create virtual account](/api-reference/endpoint/payment-methods/virtual-account-create) caps a one-time `amount` at 5,000,000, and the [webhook trigger](/api-reference/endpoint/webhooks/trigger) needs a 24-character hexadecimal `entityId` (for `CHECKOUT_SESSION.CREATED`, see October 17). Requests outside those limits are rejected.
* **Checkout channels are decided when the customer opens the page.** [Create checkout session](/api-reference/endpoint/checkout-sessions/create) no longer narrows `channels` to what the currency supports, so it no longer returns `422` when none of them suit the currency. The response `channels` is the list you sent, or every channel when you sent none.
* **New failure reason.** `meta.failureReason.code` can now be `AFX_RECIPIENT_WALLET_LIMIT_REACHED`, when the recipient's wallet has reached a limit at the destination. See [Failure reasons](/api-reference/models#failure-reasons).
* **Pool account.** [Get pool account](/api-reference/endpoint/payment-methods/pool-account) returns `404` with `BUSINESS_PAYMENT_METHOD_NOT_FOUND` when your business has no pool account in the country you ask for. It used to return `503`, so a client that retried `503` kept retrying an answer that never changes.
* **Virtual account errors.** [Create virtual account](/api-reference/endpoint/payment-methods/virtual-account-create) returns `400` with `VIRTUAL_ACCOUNT_BVN_REQUIRED` when a permanent `NGN` account needs a BVN and the holder has none. `UNSUPPORTED_VIRTUAL_ACCOUNT_CURRENCY` and `UNSUPPORTED_VIRTUAL_ACCOUNT_TYPE` now come back as their own errors instead of `503`, and as `422` since October 16.
* **Webhook event IDs.** Every webhook body now carries `id`, unique to the event, and `createdAt`, when the event was created, alongside `event` and `data`. Retries keep both values, and the signature covers them. Use `id` to skip duplicate and replayed deliveries. If your handler rejects unknown fields in the body, allow these two. See [Duplicates, ordering, and replays](/api-reference/endpoint/webhooks/introduction#duplicates-ordering-and-replays).
* **Request IDs.** Every API response now carries an `x-request-id` header. Quote it when you contact support. See [Request IDs](/api-reference/errors#request-ids).
* **Transaction types.** `POST /transaction` accepts only `DEPOSIT`, `WITHDRAW`, and `SWAP`. It rejects any other `type`.
* **Rate limits.** `POST /transaction` is limited to 60 requests per 60 seconds for each business, and customer verification to 100 per 24 hours. Both limits apply in production and in sandbox. Responses from these endpoints carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`, and a `429` also carries `Retry-After`. See [Rate limits](/api-reference/rate-limits).
* **Webhook response time.** Each webhook delivery attempt now waits up to 15 seconds for your response, down from 30. Return a `2xx` quickly and do slow work afterwards. See [Webhooks](/api-reference/endpoint/webhooks/introduction).
* **API key permissions.** Every endpoint now checks for a permission on the API key, and `POST /transaction` checks the permission for the transaction `type`. Keys created before permissions existed keep access to every endpoint. See [Permissions](/api-reference/permissions).
* **Fee wallet.** The [balance](/api-reference/endpoint/balance/get) and [sandbox top-up](/api-reference/endpoint/balance/topup) endpoints accept `walletType=FEE`.
* **Nigerian mobile money.** [Resolve payment method](/api-reference/endpoint/payment-methods/resolve) accepts `channel=MOBILE_MONEY` with `countryCode=NG`.
* **Cards.** The API no longer accepts card payment methods or card deposits. Cards are available only in the hosted checkout.
* **Quote a withdrawal.** New endpoint: [`GET /transaction/quote`](/api-reference/endpoint/transactions/quote) prices a withdrawal with the same calculation that charges it, so you can show the amounts before you create the transaction.
* **Mobile money and virtual accounts.** The sandbox emulator now handles mobile money deposits and virtual accounts. Choose a transaction's outcome with [`/simulate`](/api-reference/endpoint/transactions/simulate), and send an incoming transfer to a virtual account with [simulate transfer](/api-reference/endpoint/payment-methods/virtual-account-simulate-transfer). See [Testing in sandbox](/guides/testing-in-sandbox).
* **Checkout sessions.** [Checkout sessions](/api-reference/endpoint/checkout-sessions/create) are available in production. Create a session with a production key to take real payments, and with a sandbox key to try the flow in test mode. They were sandbox only before.
# Integration Guide
Source: https://docs.afriex.com/development
Best practices for integrating the Afriex Business API
## Integration Guide
This guide covers best practices for integrating the Afriex Business API into your application.
Get the full OpenAPI spec at this endpoint for code generation, SDKs, and API tooling.
Add the Afriex Business API to your MCP client and search the docs from Cursor, Claude Code, and other MCP-enabled tools.
Import every endpoint into Postman, with sandbox example requests and API key auth already set up.
## Environments
The Afriex API provides two environments:
| Environment | Base URL | Purpose |
| - | - | - |
| Sandbox | `https://sandbox.api.afriex.com` | Testing and development |
| Production | `https://api.afriex.com` | Live transactions |
**Never use production API keys in your testing environment.**
## Authentication
All API requests require authentication using your API key in the `x-api-key` header:
```bash theme={null}
curl -H "x-api-key: YOUR_API_KEY" \
https://sandbox.api.afriex.com/api/v1/customer
```
See [Authentication](/api-reference/authentication) for permissions, IP allowlisting, and request signing.
## Using Postman
The Postman collection is generated from the same OpenAPI spec as this API reference, so it always lists the current endpoints, parameters, and example requests.
Click **Run in Postman** to fork the collection into your own Postman workspace. Open the collection's **Variables** tab, paste your sandbox API key into `apiKey`, and send any request. Every request sends the key in the `x-api-key` header, and requests go to the sandbox by default. To keep your key in an environment instead, or to switch to production, fork the **Afriex Sandbox** and **Afriex Production** environments from the [Afriex Postman workspace](https://www.postman.com/afriex-testing/afriex-business-api/overview).
Each request comes with an example body. Optional query parameters are included but switched off, so tick the ones you want to use.
Using the Postman desktop app? Your fork appears there automatically once you're signed in with the same account. Or skip the browser: click **Import** in the app and paste the link below.
### Importing by link
To import the collection instead of forking it, click **Import** in Postman and paste this link:
```
https://docs.afriex.com/api-reference/postman-collection.json
```
Then set `apiKey` to your sandbox API key in a Postman environment. Requests go to the sandbox by default; for production, also set `baseUrl` to `https://api.afriex.com` in that environment.
To pick up API changes, use **Pull changes** on your fork, or import the link again and choose **Replace**. If your API key is in an environment, it is not affected.
## Amounts
Transaction amounts are decimal values in the currency's main unit. A `sourceAmount` of `"10"` in `USD` is ten US dollars, not ten cents.
* Send amounts as numeric strings, such as `"10"` or `"100.50"`. The API also accepts numbers, but strings keep floating-point rounding out of your code.
* Responses and webhooks return amounts as strings. Parse them with a decimal type, not a float.
* Checkout sessions are the exception. Their `amount` is in minor units, such as kobo or cents. See [Create checkout session](/api-reference/endpoint/checkout-sessions/create#amounts-are-in-minor-units).
## Idempotency
Creating a transaction takes a `meta.idempotencyKey` and a `meta.reference`. Both must be unique in your business, so a retry can never create a second transaction:
```json theme={null}
{
"customerId": "CUSTOMER_ID",
"type": "WITHDRAW",
"destinationAmount": "5000",
"sourceCurrency": "USD",
"destinationCurrency": "NGN",
"destinationId": "PAYMENT_METHOD_ID",
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"reference": "YOUR_REFERENCE"
}
}
```
A repeat returns `409 DUPLICATE_REQUEST` and creates nothing. See [Idempotency](/api-reference/idempotency) for what a duplicate returns, how to recover after a timeout, and why a failed swap needs a new key.
## Error Handling
Every error has the same shape: a machine-readable `code` to branch on, and `details.friendlyMessage`, which says what was wrong with this request.
```json theme={null}
{
"code": "INVALID_BUSINESS_CUSTOMER_REQUEST",
"error": "Invalid business customer request",
"details": {
"errorMessage": "Invalid business customer request",
"friendlyMessage": "Invalid customer phone provided: PHONE_COUNTRY_MISMATCH"
}
}
```
Retry `429`, `500`, `503`, and requests that time out, with backoff. Don't retry the other `4xx` errors until you change the request. See [Errors](/api-reference/errors) for every status and what to do about it.
## Pagination
List endpoints take a zero-based `page` and a `limit` of up to `100`, and return `{data, total, page}`:
```bash theme={null}
curl -H "x-api-key: YOUR_API_KEY" \
"https://sandbox.api.afriex.com/api/v1/customer?page=0&limit=10"
```
See [Pagination](/api-reference/pagination) for the sort order of each list, how to find the last page, and how to page through a list that changes while you read it.
## Webhook Integration
Set up webhooks to receive real-time event notifications:
1. Configure your webhook URL in the [Dashboard](https://business.afriex.com/)
2. Verify the `x-webhook-signature` header (ECDSA P-384 with SHA-256, DER signature, base64) using the **raw** request body. The key is not RSA. See [Security and signature validation](/api-reference/endpoint/webhooks/introduction#security-and-signature-validation)
3. Use the webhook public key from **Developer → Webhook** in the Dashboard (sandbox and production keys are different)
4. Return `200` quickly after successful verification and processing
If signature verification fails, return `400` or `401`. Afriex makes up to 12 delivery attempts, the first one and then up to 11 retries, with exponential backoff starting at 30 seconds.
Learn more about webhook setup, security, and event types.
## Rate Limiting
Be mindful of rate limits when making API calls:
* Wait for `Retry-After` before you retry a `429`, and back off on `500` and `503`
* Cache exchange rates and other static data
* Use webhooks to learn about status changes
See [Rate limits](/api-reference/rate-limits) for the per-endpoint limits, the headers that show where you stand, and how to handle a `429`.
## Testing Your Integration
Use the sandbox environment to test all API calls without affecting live data.
Test webhook handling with the sandbox environment events.
Work through the [go-live checklist](/guides/testing-in-sandbox#checklist-before-going-live). Besides the base URL and API key, the webhook public key and the webhook IP address change in production.
### Building and testing in sandbox
For the full sandbox reference, including the test-value taxonomy, how to simulate deterministic outcomes, OTP flow, virtual-account transfers, balance topups, and webhook drills, see the [Testing in Sandbox guide](/guides/testing-in-sandbox).
## Support
Need help with your integration? Contact us at [support@afriex.com](mailto:support@afriex.com).
# Connecting the Pieces
Source: https://docs.afriex.com/guides/connecting-the-pieces
How Customers, Payment Methods, and Transactions link together
## What this guide covers
If you've read the quickstart but aren't sure exactly *which ID goes where*, or why you need to create three different things before money can move, this guide is for you.
The Afriex Business API has three core building blocks. They must be created in order, and each one produces an ID that the next step needs. This guide explains what each building block is, what ID it gives you, and exactly where that ID is used.
***
## The big picture
Think of it like this: a **Customer** is the end user your business is acting on behalf of: the sender of a payout, or the payer of a deposit. A **Payment Method** holds the account on the other side: the recipient's account for a payout, or the account you collect from for a deposit. A **Transaction** is the instruction to move money, and it needs to know *on whose behalf* you are acting and *which account* to use.
Here is how the IDs connect:
```mermaid theme={null}
flowchart TD
A["Step 1: Create Customer
POST /api/v1/customer
─────────────────────────────
✦ Returns: customerId"]
B["Step 2: Create Payment Method
POST /api/v1/payment-method
─────────────────────────────
↳ Input: customerId
✦ Returns: paymentMethodId"]
C["Step 3: Create Transaction
POST /api/v1/transaction
─────────────────────────────
↳ Input: customerId
↳ Input: destinationId = paymentMethodId (WITHDRAW)
↳ Input: sourceId = paymentMethodId (DEPOSIT)
✦ Returns: transactionId"]
A -->|"customerId"| B
B -->|"paymentMethodId"| C
A -->|"customerId"| C
```
### ID quick-reference
| ID field | Belongs to | Used in |
| - | - | - |
| `customerId` | Customer | Payment Method creation, Transaction creation |
| `paymentMethodId` | Payment Method | Transaction as `destinationId` or `sourceId` |
| `destinationId` | Transaction field | Equals a `paymentMethodId` (used in WITHDRAW) |
| `sourceId` | Transaction field | Equals a `paymentMethodId` (used in DEPOSIT) |
| `transactionId` | Transaction | Status lookups, webhook events |
`destinationId` and `sourceId` in a Transaction are both **Payment Method IDs**. They are not special IDs. They are exactly the `paymentMethodId` value returned when you created the Payment Method. This is the single most common point of confusion.
***
## Each building block explained
### Customer
**What it is:** A Customer is the person or business your business is acting on behalf of: the sender of a payout, or the payer of a deposit. On a payout, Afriex passes the Customer's details to the payout rail as the sender, and the Payment Method's details as the recipient. For example, if you run a remittance app, each user who sends money home is a Customer, and the bank account they send to is a Payment Method linked to that Customer. The Customer record stores their identity information (name, email, phone, country) and is used to attribute transactions to the right person in your system.
**What ID it gives you:** `customerId`
**When you use that ID:**
* Pass it as `customerId` when creating a Payment Method (to link the account to this person)
* Pass it as `customerId` when creating a Transaction (to identify whose account is involved)
**Fields to create a Customer:**
| Field | Required | Example |
| - | - | - |
| `fullName` | Yes | `"Amara Osei"` |
| `email` | Yes | `"amara@example.com"` |
| `phone` | Yes | `"+233201234567"` (E.164 format) |
| `countryCode` | Yes | `"GH"` (ISO 3166-1 alpha-2) |
***
### Payment Method
**What it is:** A Payment Method holds the account on the other side of the transaction: the recipient's bank account, mobile money wallet, or other financial account for a payout, or the account you collect from for a deposit. It is linked to a Customer, the person on whose behalf you are acting. On a payout, the account belongs to the recipient, who is usually not the Customer. You need a Payment Method before you can send or receive money, because the Transaction needs to know exactly *which account* to credit or debit.
**What ID it gives you:** `paymentMethodId`
**When you use that ID:**
* As `destinationId` in a WITHDRAW transaction (the account the money is sent to)
* As `sourceId` in a DEPOSIT transaction (the account the money is pulled from)
A Payment Method has a `type` field, either `WITHDRAW` (used to send funds to a recipient) or `DEPOSIT` (used to collect funds from a customer). Make sure you create the right type for the transaction you plan to run.
**Supported channels:**
| Channel | Description | Supports | Common use case |
| - | - | - | - |
| `BANK_ACCOUNT` | Local bank transfer | Withdraw | Payout to Nigerian GTBank, Kenyan KCB |
| `MOBILE_MONEY` | Mobile wallet | Deposit & Withdraw | MTN MoMo, M-Pesa, Airtel Money |
| `SWIFT` | International wire transfer | Withdraw | Cross-border payouts to Europe, US |
| `UPI` | Unified Payments Interface | Withdraw | Payouts to India |
| `INTERAC` | Interac e-Transfer | Deposit & Withdraw | Collections and payouts in Canada |
| `WE_CHAT` | WeChat Pay and Alipay | Withdraw | Payouts to China |
| `VIRTUAL_BANK_ACCOUNT` | Afriex-issued virtual account | Deposit | Receive collections in NGN, GHS, KES, and USD |
| `POOL_ACCOUNT` | Shared pool account | Deposit | Shared collection account (production only) |
| `CRYPTO_WALLET` | Crypto wallet | Deposit | USDC / USDT deposits |
#### Virtual bank accounts
`VIRTUAL_BANK_ACCOUNT` is not created through the standard `POST /api/v1/payment-method` endpoint. It has two dedicated endpoints, and they do different things:
* **[Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create)** (`POST /api/v1/payment-method/virtual-account`) creates one. Pass `currency`, optionally `customerId`, and exactly one of `label` or `amount`.
* **[List Virtual Accounts](/api-reference/endpoint/payment-methods/virtual-account)** (`GET /api/v1/payment-method/virtual-account`) is **read-only**. It returns every active account for that customer and currency, and responds `200` with `data: []` when none exist. It does **not** create one.
The `GET` endpoint used to create an account on the fly when none existed. It no longer does. If you are relying on that behavior, calling `GET` now returns an empty list rather than a new account, with no error. Call `POST` to create.
The two account shapes are chosen by which field you pass to `POST`:
* **Static (`label`):** A permanent virtual bank account. The same account number is returned on every subsequent list for that customer and currency, and the customer can fund it at any time for any amount. `label` is one of `SALES`, `OPERATIONS`, `PAYROLL`, `COLLECTIONS`, `VENDOR_PAYMENTS`, `TAX`, `REFUNDS`, `MARKETING`, `TREASURY`, `GENERAL`.
* **Dynamic (`amount`):** An account tied to a specific amount that expires after a set window (the response includes `expiresInMinutes`). Use this when collecting a specific payment within a defined time.
| | Static | Dynamic |
| - | - | - |
| Triggered by | Passing `label` | Passing `amount` |
| Expires | Never | Yes (see `expiresInMinutes` in response) |
| Amount-specific | No | Yes |
When you pass a `customerId`, virtual accounts are available in **NGN** and **GHS**. Other currencies for a specific customer are rejected with `UNSUPPORTED_VIRTUAL_ACCOUNT_CURRENCY`. There is also a cap on how many accounts a single business-customer-currency combination can hold; exceeding it returns `VIRTUAL_ACCOUNT_LIMIT_REACHED`.
**POOL\_ACCOUNT** returns the business's shared pool account for a country. Requires `country`; pass `customerId` to scope the response to a specific end-user. Use the `reference` on the response to reconcile incoming deposits.
Virtual accounts are for your **end customers**. Link each one to a `customerId` so payments are attributed to the right person.
***
### Transaction
**What it is:** A Transaction is the actual instruction to move money. It references a Customer and (depending on the type) one of their Payment Methods.
**What ID it gives you:** `transactionId`
**When you use that ID:**
* To check the status: `GET /api/v1/transaction/{transactionId}`
* It appears in webhook events so you can match notifications to records in your system
**Three transaction types:**
| Type | What it does | Payment method needed |
| - | - | - |
| `WITHDRAW` | Send money *from* your Afriex wallet *to* a recipient's account | `destinationId` (= paymentMethodId) |
| `DEPOSIT` | Pull money *from* a customer's account *into* your Afriex wallet | `sourceId` (= paymentMethodId) |
| `SWAP` | Convert currency *within* your Afriex wallet | None |
**WITHDRAW:** Your business wallet is the implicit source. Afriex debits your wallet automatically. You do not specify a source payment method.
**DEPOSIT:** Your business wallet is the implicit destination. Afriex credits your wallet automatically. You do not specify a destination payment method.
**SWAP:** Both source and destination are your business wallet. No customer or payment method IDs are needed at all.
***
## The 3-step integration sequence
Here is the exact order of operations, with the IDs flowing from one step to the next.
Register the person on whose behalf the transaction is being made (the originator or sender).
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/customer \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"fullName": "Amara Osei",
"email": "amara@example.com",
"phone": "+233201234567",
"countryCode": "GH"
}'
```
Save the `customerId` from the response. You need it in the next step.
```json theme={null}
{
"data": {
"customerId": "69516dd0464b2213bd74cfad",
"name": "Amara Osei",
"email": "amara@example.com",
"phone": "+233201234567",
"countryCode": "GH"
}
}
```
Register the recipient's bank account or mobile wallet (the account the money will be sent to or collected from). Link it to the Customer from Step 1 by passing their `customerId`.
First, look up the correct institution code for the customer's bank or mobile provider:
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/payment-method/institution?channel=MOBILE_MONEY&countryCode=GH" \
-H "x-api-key: YOUR_API_KEY"
```
Then create the payment method using the `institutionCode` and `institutionName` from that response:
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/payment-method \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "MOBILE_MONEY",
"accountName": "Amara Osei",
"accountNumber": "0201234567",
"countryCode": "GH",
"institution": {
"institutionCode": "MTN",
"institutionName": "MTN"
}
}'
```
Save the `paymentMethodId` from the response. This is what goes into `destinationId` (or `sourceId`) in your transaction.
```json theme={null}
{
"data": {
"paymentMethodId": "690df3281c11eea59108fcaf",
"customerId": "69516dd0464b2213bd74cfad",
"channel": "MOBILE_MONEY",
"accountName": "Amara Osei",
"accountNumber": "0201234567",
"countryCode": "GH"
}
}
```
Now create the transaction using both IDs from the previous steps.
* `customerId` = the ID from Step 1
* `destinationId` = the `paymentMethodId` from Step 2 (for a WITHDRAW)
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/transaction \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"destinationAmount": "91.50",
"sourceCurrency": "USD",
"destinationCurrency": "GHS",
"destinationId": "690df3281c11eea59108fcaf",
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440001",
"reference": "ORDER-98765"
}
}'
```
Here the recipient gets exactly 91.50 GHS and the API works out the USD cost. To show that cost before you send, call [Quote a Withdrawal](/api-reference/endpoint/transactions/quote) first.
The response includes a `transactionId`. Use it to track status or match incoming webhook events.
***
## Transaction type payloads side by side
```json WITHDRAW: Pay out to a recipient's account theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"destinationAmount": "16500",
"sourceCurrency": "USD",
"destinationCurrency": "NGN",
"destinationId": "690df3281c11eea59108fcaf",
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440001",
"reference": "ORDER-001"
}
}
```
```json DEPOSIT: Collect from a customer's account theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "DEPOSIT",
"sourceAmount": "500",
"sourceCurrency": "KES",
"destinationCurrency": "USD",
"sourceId": "690df3281c11eea59108fcc1",
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440002",
"reference": "COLLECTION-001"
}
}
```
```json SWAP: Convert currency in your wallet theme={null}
{
"type": "SWAP",
"sourceAmount": "500",
"sourceCurrency": "USD",
"destinationCurrency": "NGN",
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440003",
"reference": "SWAP-001"
}
}
```
### What changes between each type
| Field | WITHDRAW | DEPOSIT | SWAP |
| - | - | - | - |
| `customerId` | Required | Required | Not required |
| `destinationId` | Required (= paymentMethodId) | Not used | Not used |
| `sourceId` | Not used | Required (= paymentMethodId) | Not used |
| `sourceAmount` / `destinationAmount` | Send the one you want exact; the API derives the other. If you send both, `destinationAmount` wins unless `shouldPreferSourceAmount` is `true` | Same as WITHDRAW | Exactly one. Sending both is rejected |
| `meta.idempotencyKey` | Required | Required | Required |
| `meta.reference` | Required | Required | Required |
***
## Payment method fields by channel
Different payment channels require different fields. Use the sections below to find the exact payload shape for your channel.
`customerId` is listed below for every channel because that is the usual case, but it is **optional**. Omit it and the payment method is attached to the business owner instead of an end customer. Pass it whenever the method belongs to one of your customers, which is almost always what you want.
Required fields: `customerId`, `channel`, `accountName`, `accountNumber`, `countryCode`, `institution.institutionCode`, `institution.institutionName`
Two currencies differ:
* **EUR (IBAN):** only `institution.institutionName` is required.
* **CNY (China):** `institution.institutionBranch` is required as well.
Look up valid institution codes first: [List Institutions](/api-reference/endpoint/payment-methods/institution)
You can also verify the account holder name before creating: [Resolve Payment Method](/api-reference/endpoint/payment-methods/resolve)
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "BANK_ACCOUNT",
"accountName": "John Doe",
"accountNumber": "0123456789",
"countryCode": "NG",
"institution": {
"institutionCode": "000013",
"institutionName": "Guaranty Trust Bank (GTBank) Plc"
}
}
```
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (the mobile number), `countryCode`, `institution.institutionName`. Send the provider's `institution.institutionCode` from List Institutions as well.
Look up valid institution codes for the country: [List Institutions](/api-reference/endpoint/payment-methods/institution)
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "MOBILE_MONEY",
"accountName": "Amara Osei",
"accountNumber": "0201234567",
"countryCode": "GH",
"institution": {
"institutionCode": "MTN",
"institutionName": "MTN"
}
}
```
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (IBAN or account number), `countryCode`, `institution.institutionCode` (SWIFT/BIC), `institution.institutionName`, `institution.institutionAddress`, `recipient.recipientAddress`
SWIFT payouts are in USD, and need the correspondent (intermediary) bank too: `institution.correspondentBankName` and `institution.correspondentBankAccountNumber`.
Use [Institution Codes](/api-reference/endpoint/payment-methods/institution-codes) to resolve a SWIFT code to a bank name.
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "SWIFT",
"accountName": "John Doe",
"accountNumber": "DE89370400440532013000",
"countryCode": "DE",
"institution": {
"institutionCode": "DEUTDEDB",
"institutionName": "Deutsche Bank",
"institutionAddress": "Taunusanlage 12, 60262 Frankfurt am Main, Germany",
"correspondentBankName": "Citibank N.A. New York",
"correspondentBankAccountNumber": "10991234"
},
"recipient": {
"recipientAddress": "Musterstrasse 1, 10115 Berlin, Germany"
}
}
```
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (UPI ID / VPA), `countryCode`, `recipient.recipientPhone`
UPI takes no `institution` object.
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "UPI",
"accountName": "Raj Kumar",
"accountNumber": "rajkumar@upi",
"countryCode": "IN",
"recipient": {
"recipientPhone": "+919876543210"
}
}
```
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (the email address registered for Interac), `countryCode`, `recipient.recipientEmail`
Interac takes no `institution` object.
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "INTERAC",
"accountName": "John Doe",
"accountNumber": "john.doe@email.com",
"countryCode": "CA",
"recipient": {
"recipientEmail": "john.doe@email.com"
}
}
```
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (phone number), `countryCode`, `institution.institutionCode` (`"WECHAT"`), `institution.institutionName` (`"WECHAT"`), `recipient.recipientPhone`
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "WE_CHAT",
"accountName": "Zhang Wei",
"accountNumber": "+8613812345678",
"countryCode": "CN",
"institution": {
"institutionCode": "WECHAT",
"institutionName": "WECHAT"
},
"recipient": {
"recipientPhone": "+8613812345678"
}
}
```
Alipay does not have a channel of its own. Create it on the `WE_CHAT` channel and set `institution.institutionCode` and `institution.institutionName` to `ALIPAY`; sending `"channel": "ALIPAY"` is rejected.
Required fields: `customerId`, `channel`, `accountName`, `accountNumber` (Alipay-linked phone number or account ID), `countryCode`, `institution.institutionCode`, `institution.institutionName`
```json theme={null}
{
"customerId": "69516dd0464b2213bd74cfad",
"type": "WITHDRAW",
"channel": "WE_CHAT",
"accountName": "Zhang Wei",
"accountNumber": "+8613812345678",
"countryCode": "CN",
"institution": {
"institutionCode": "ALIPAY",
"institutionName": "ALIPAY"
}
}
```
Virtual bank accounts are not created through the standard `POST /api/v1/payment-method` endpoint. Use the dedicated endpoint instead:
```bash theme={null}
POST /api/v1/payment-method/virtual-account # create one
GET /api/v1/payment-method/virtual-account # list existing ones (read-only)
```
`POST` creates the account; `GET` only lists what already exists and returns `data: []` when there is none.
**Two shapes**, determined by which field you pass to `POST`:
| | Static | Dynamic |
| - | - | - |
| Pass | `label` | `amount` |
| Expires | Never | Yes, see `expiresInMinutes` in response |
| Amount-specific | No | Yes |
With a `customerId`, `NGN` and `GHS` are supported. `KES` and `USD` accounts belong to your business, so omit `customerId` to create them.
See [Create Virtual Account](/api-reference/endpoint/payment-methods/virtual-account-create) and [List Virtual Accounts](/api-reference/endpoint/payment-methods/virtual-account) for the full request and response reference.
***
## Institution fields: which one to use and when
The `institution` object has several fields that look similar. Here is exactly what each one is and when you need it.
| Field | What it is | When you need it |
| - | - | - |
| `institutionCode` | Short identifier for the bank or provider (e.g. `"000013"` for GTBank Nigeria, `"MTN"` for MTN Ghana) | **Required for bank accounts, SWIFT, and WeChat Pay and Alipay.** Optional for mobile money and EUR bank accounts. Not used for UPI or Interac. |
| `institutionName` | Human-readable display name (e.g. `"Guaranty Trust Bank (GTBank) Plc"`, `"MTN"`) | **Required for every channel except UPI and Interac.** |
| `institutionId` | Afriex's internal database ID for the institution | **Output only. Never pass this as input.** It appears in responses but is not needed for creation. |
| `institutionAddress` | Physical address of the institution | **Only for SWIFT transfers.** Skip it for all other channels. |
| `institutionBranch` | The bank branch | **Only for CNY bank accounts.** |
| `correspondentBankName`, `correspondentBankAccountNumber` | The correspondent (intermediary) bank for a USD payout | **Only for SWIFT transfers.** |
### How to find the right institutionCode
1. Call `GET /api/v1/payment-method/institution?channel=BANK_ACCOUNT&countryCode=NG`
2. The response returns a list of institutions, each with `institutionCode` and `institutionName`
3. Copy those values directly into your Payment Method creation request
Nigerian banks are listed with their NIP code, such as `000013` for GTBank. The API also accepts the older three-digit CBN sort code, such as `058`.
For SWIFT and US routing numbers, use `GET /api/v1/payment-method/institution/codes` to resolve a known SWIFT/BIC code to a bank name.
Cache the institution list per country and channel. It changes infrequently and there is no need to fetch it on every Payment Method creation.
***
## The meta object
Every transaction must include a `meta` object with two required fields. Without them, the API returns a `400` error.
```json theme={null}
"meta": {
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"reference": "ORDER-12345"
}
```
| Field | Required | What it does |
| - | - | - |
| `idempotencyKey` | **Yes** | A unique string you generate per transaction attempt. If a transaction already exists with this key, Afriex rejects the request with `409 DUPLICATE_REQUEST` rather than creating a duplicate. It does **not** replay the original transaction. Use a UUID v4. |
| `reference` | **Yes** | Your own internal ID for this transaction (e.g. order number, payment ID, or invoice reference). Used for reconciliation and returned on the transaction as `merchantReference`. |
| `narration` | No | A human-readable description (e.g. `"Salary payment for March 2025"`). |
| `invoice` | Required for SWIFT withdrawals; otherwise No | An S3 object key for the invoice document. Upload the file via [Generate Upload URL](/api-reference/endpoint/media/generate-upload-url) first, then pass the returned `key` here. The object must already exist in storage when the transaction is created. |
Never reuse an `idempotencyKey` across different transaction attempts. Use a fresh UUID for every new transaction. Reusing a key, or reusing a `reference`, is rejected with `409 DUPLICATE_REQUEST` and the new transaction is not created.
Because a duplicate is an error rather than a replay, **a `409` is not a failure to retry**. It means a transaction for that key already exists. Look it up with [List Transactions](/api-reference/endpoint/transactions/list) filtered by your `merchantReference` to find out what happened to it, instead of retrying with a fresh key and risking a genuine double payment.
***
## Common mistakes
### 1. Using a customerId as destinationId or sourceId
`destinationId` and `sourceId` must be a `paymentMethodId`, not a `customerId`. Both look like hex strings, which makes it easy to mix them up.
```json theme={null}
// Wrong: this is a customerId
"destinationId": "69516dd0464b2213bd74cfad"
// Correct: this is the paymentMethodId from Step 2
"destinationId": "690df3281c11eea59108fcaf"
```
***
### 2. Skipping the 3-step sequence
You cannot create a Transaction without first creating a Customer and a Payment Method. The API returns a `404` or `400` if either does not exist. Always follow: **Customer → Payment Method → Transaction**.
***
### 3. Mixing up DEPOSIT and WITHDRAW fields
* **WITHDRAW** uses `destinationId` (where the money goes)
* **DEPOSIT** uses `sourceId` (where the money comes from)
Passing `destinationId` on a DEPOSIT (or `sourceId` on a WITHDRAW) will cause the API to reject the request.
***
### 4. Assuming SWAP needs a payment method
SWAP does not touch any external account. It converts currency inside your Afriex business wallet. No `customerId`, `destinationId`, or `sourceId` is needed. Only the currency pair and the `meta` object are required.
```json theme={null}
{
"type": "SWAP",
"sourceAmount": "500",
"sourceCurrency": "USD",
"destinationCurrency": "NGN",
"meta": {
"idempotencyKey": "unique-uuid-here",
"reference": "SWAP-001"
}
}
```
***
### 5. Omitting meta.idempotencyKey or meta.reference
Both are required on every transaction. The API returns a `400` if either is missing. Always generate a fresh UUID for `idempotencyKey` and use your own internal order or payment ID as `reference`.
***
### 6. Hardcoding institution codes without looking them up
Do not guess institution codes. Use `GET /api/v1/payment-method/institution?channel=BANK_ACCOUNT&countryCode=NG` to get the correct code for each country and channel. The same bank can have a different code in different countries.
***
### 7. Passing institutionId as input
`institutionId` is an output field. It appears in API responses but you never need to supply it when creating a Payment Method. The fields you pass are `institutionCode` and `institutionName`.
***
## Tracking what happens after you create a transaction
Transactions do not complete instantly. After creation the status will be `PENDING` or `PROCESSING`. Here is how to know when it finishes.
**Testing in sandbox?** Sandbox transactions settle automatically in about 5 minutes. Pick the outcome with the payment method's last four digits (e.g. `0001` for fail, `0003` to hold in `PENDING` until you call [`/simulate`](/api-reference/endpoint/transactions/simulate)); add `SIMULATE_INSTANT` anywhere in `meta.reference` to speed things up to about 30 seconds. Full reference: [Testing in Sandbox](/guides/testing-in-sandbox).
**Option A (recommended): Webhooks**
Subscribe to `TRANSACTION.UPDATED` events. Afriex sends an HTTP POST to your webhook URL with the full transaction payload whenever the transaction's status changes.
This fires on intermediate statuses too, not just terminal ones, so expect several deliveries for a single transaction. Branch on the payload's `status` field to decide whether the transaction is actually finished.
See [Webhook setup](/api-reference/endpoint/webhooks/introduction) for configuration steps and payload examples.
**Option B: Poll the status endpoint**
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/transaction/TRANSACTION_ID" \
-H "x-api-key: YOUR_API_KEY"
```
Prefer webhooks over polling. Polling adds latency and uses API quota. Webhooks notify you the moment the status changes.
### Terminal statuses
| Status | Meaning |
| - | - |
| `SUCCESS` | Transaction finished successfully |
| `FAILED` | Transaction could not be processed. Check `meta.failureReason.code` for a stable `AFX_*` reason (see [Failure reasons](/api-reference/models#failure-reasons)). |
| `CANCELLED` | Cancelled before processing started |
| `REFUNDED` | Funds returned to the sender |
| `REJECTED` | Rejected after manual review |
***
## Next steps
Make your first API call in under 5 minutes.
Full parameter reference for customer creation.
All channels and required fields in the API reference.
Full transaction creation reference with all fields.
Complete field-by-field documentation for all models.
Receive real-time transaction status updates.
# Supported Currencies & Payment Rails
Source: https://docs.afriex.com/guides/supported-currencies
Complete list of countries, currencies, and payment channels available through the Afriex Business API
Afriex routes payments across **30+ currencies** and **100+ countries**. Use this page to confirm which payment rails are available for a given country before building your integration.
Local and international currencies
Coverage across 5 continents
Bank, mobile money, SWIFT, crypto, and more
Collections and payouts both supported
## Rail Reference
| Rail | API `channel` value | Description |
| - | - | - |
| Bank Account | `BANK_ACCOUNT` | Local bank account transfer |
| Virtual Account | `VIRTUAL_BANK_ACCOUNT` | System-generated account number for inbound deposits |
| Pool Account | `POOL_ACCOUNT` | Shared pool account for multiple users |
| Mobile Money | `MOBILE_MONEY` | Mobile money wallet (M-Pesa, MTN MoMo, Airtel, etc.) |
| SWIFT | `SWIFT` | International SWIFT wire transfer |
| Interac | `INTERAC` | Interac e-Transfer (Canada) |
| UPI | `UPI` | Unified Payments Interface (India) |
| WeChat Pay | `WE_CHAT` | WeChat Pay (China) |
| Alipay | `ALIPAY` | Alipay (China) |
| Crypto | `CRYPTO` | Crypto wallet (USDC / USDT on supported networks) |
| Paybill / Till | `PAYBILL_TILL` | M-Pesa Paybill or Till number (Kenya). Not currently creatable through the API |
***
## Supported Countries
| Country | Currency | Deposit Rails | Payout Rails |
| - | - | - | - |
| 🇧🇯 Benin | XOF | `Mobile Money` | `Mobile Money` |
| 🇧🇼 Botswana | BWP | — | `Mobile Money` |
| 🇧🇫 Burkina Faso | XOF | `Mobile Money` *(coming soon)* | `Mobile Money` *(coming soon)* |
| 🇨🇲 Cameroon | XAF | `Mobile Money` | `Bank Account` `Mobile Money` |
| 🇨🇫 Central African Republic | XAF | — | `Mobile Money` *(coming soon)* |
| 🇨🇬 Republic of Congo | XAF | `Mobile Money` *(coming soon)* | `Mobile Money` |
| 🇨🇮 Côte d'Ivoire | XOF | `Mobile Money` | `Bank Account` `Mobile Money` |
| 🇨🇩 DR Congo | CDF | — | `Mobile Money` *(coming soon)* |
| 🇪🇬 Egypt | EGP | — | `Bank Account` `Mobile Money` |
| 🇪🇹 Ethiopia | ETB | `Mobile Money` | `Bank Account` `Mobile Money` |
| 🇬🇦 Gabon | XAF | `Mobile Money` *(coming soon)* | `Mobile Money` |
| 🇬🇲 Gambia | GMD | — | `Mobile Money` |
| 🇬🇭 Ghana | GHS | `Mobile Money` *(coming soon)* `Virtual Account` | `Bank Account` `Mobile Money` |
| 🇬🇳 Guinea | GNF | — | `Mobile Money` |
| 🇬🇼 Guinea-Bissau | XOF | — | `Mobile Money` |
| 🇰🇪 Kenya | KES | `Mobile Money` `Virtual Account` | `Bank Account` `Mobile Money` `Paybill / Till` *(not through the API)* |
| 🇲🇬 Madagascar | MGA | — | `Mobile Money` |
| 🇲🇼 Malawi | MWK | `Mobile Money` *(coming soon)* | `Mobile Money` |
| 🇲🇱 Mali | XOF | — | `Mobile Money` *(coming soon)* |
| 🇲🇦 Morocco | MAD | — | `Mobile Money` *(coming soon)* |
| 🇲🇿 Mozambique | MZN | `Mobile Money` *(coming soon)* | `Mobile Money` |
| 🇳🇬 Nigeria | NGN | `Virtual Account` | `Bank Account` `Mobile Money` |
| 🇷🇼 Rwanda | RWF | `Mobile Money` *(coming soon)* | `Bank Account` `Mobile Money` |
| 🇸🇳 Senegal | XOF | — | `Bank Account` `Mobile Money` |
| 🇸🇱 Sierra Leone | SLE | `Mobile Money` *(coming soon)* | `Mobile Money` |
| 🇿🇦 South Africa | ZAR | — | `Bank Account` |
| 🇸🇸 South Sudan | SSP | — | `Mobile Money` *(coming soon)* |
| 🇹🇿 Tanzania | TZS | `Mobile Money` | `Mobile Money` |
| 🇹🇬 Togo | XOF | — | `Mobile Money` *(coming soon)* |
| 🇺🇬 Uganda | UGX | `Mobile Money` | `Bank Account` `Mobile Money` |
| 🇿🇲 Zambia | ZMW | `Mobile Money` *(coming soon)* | `Mobile Money` |
| Country | Currency | Deposit Rails | Payout Rails |
| - | - | - | - |
| 🇨🇦 Canada | CAD | `Interac` *(coming soon)* | `Interac` *(coming soon)* |
| 🇺🇸 United States | USD | `Virtual Account` | `Bank Account` `SWIFT` |
USD `SWIFT` payouts route to **100 countries** globally — not just the US. See [SWIFT Coverage](#swift-coverage) below for the full country list.
| Country | Currency | Deposit Rails | Payout Rails |
| - | - | - | - |
| 🇦🇹 Austria | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇧🇪 Belgium | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇧🇬 Bulgaria | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇭🇷 Croatia | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇨🇾 Cyprus | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇨🇿 Czech Republic | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇩🇰 Denmark | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇪🇪 Estonia | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇫🇮 Finland | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇫🇷 France | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇩🇪 Germany | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇬🇷 Greece | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇭🇺 Hungary | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇮🇪 Ireland | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇮🇹 Italy | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇱🇻 Latvia | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇱🇹 Lithuania | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇱🇺 Luxembourg | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇲🇹 Malta | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇳🇱 Netherlands | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇳🇴 Norway | EUR | `Virtual Account` *(coming soon)* | `Bank Account` *(coming soon)* |
| 🇵🇱 Poland | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇵🇹 Portugal | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇷🇴 Romania | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇸🇰 Slovakia | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇸🇮 Slovenia | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇪🇸 Spain | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇸🇪 Sweden | EUR | `Virtual Account` *(coming soon)* | `Bank Account` |
| 🇺🇦 Ukraine | EUR | `Virtual Account` *(coming soon)* | `Bank Account` *(coming soon)* |
| 🇬🇧 United Kingdom | GBP | `Virtual Account` *(coming soon)* | `Bank Account` |
EUR bank payouts use the **SEPA** network and require a valid IBAN. GBP payouts use **Faster Payments**.
| Country | Currency | Deposit Rails | Payout Rails |
| - | - | - | - |
| 🇨🇳 China | CNY | — | `Bank Account` `WeChat Pay` `Alipay` |
| 🇮🇳 India | INR | — | `Bank Account` `UPI` |
| 🇵🇰 Pakistan | PKR | — | `Bank Account` `Mobile Money` |
CNY `Bank Account` payouts require the recipient's **phone number**, **ID type**, and **ID number** in addition to standard bank details.
| Asset | Supported Countries | Deposit Rails | Payout Rails |
| - | - | - | - |
| USDC | 🌍 100+ countries globally | `Crypto` | — |
| USDT | 🌍 100+ countries globally | `Crypto` | — |
USDC and USDT deposits are accepted from 100+ countries via crypto wallet.
***
## Coming Soon
**Pool Accounts** are live. Fetch your business's shared pool account for a country with [Get Pool Account](/api-reference/endpoint/payment-methods/pool-account), then submit proof of an inbound payment with [Submit Pool Account Payment Proof](/api-reference/endpoint/transactions/pool-account-proof). Country-level coverage is still expanding.
***
## SWIFT Coverage
USD `SWIFT` payouts (`channel: SWIFT`) are available to **100 countries** — far beyond the US alone. This makes SWIFT the default choice for international USD disbursements when no local rail is available.
SWIFT transfers require: recipient email, recipient address, SWIFT/BIC code, and a transaction invoice.
| Region | Countries |
| - | - |
| **Africa** | 🇩🇿 Algeria · 🇧🇯 Benin · 🇧🇼 Botswana · 🇧🇫 Burkina Faso · 🇨🇲 Cameroon · 🇨🇫 Central African Republic · 🇨🇬 Republic of Congo · 🇨🇮 Côte d'Ivoire · 🇨🇩 DR Congo · 🇪🇬 Egypt · 🇪🇹 Ethiopia · 🇬🇦 Gabon · 🇬🇲 Gambia · 🇬🇭 Ghana · 🇬🇳 Guinea · 🇬🇼 Guinea-Bissau · 🇰🇪 Kenya · 🇲🇬 Madagascar · 🇲🇼 Malawi · 🇲🇱 Mali · 🇲🇦 Morocco · 🇲🇿 Mozambique · 🇳🇦 Namibia · 🇳🇪 Niger · 🇳🇬 Nigeria · 🇷🇼 Rwanda · 🇸🇳 Senegal · 🇸🇱 Sierra Leone · 🇿🇦 South Africa · 🇸🇸 South Sudan · 🇹🇿 Tanzania · 🇹🇬 Togo · 🇹🇳 Tunisia · 🇺🇬 Uganda · 🇿🇲 Zambia · 🇿🇼 Zimbabwe |
| **Americas** | 🇦🇷 Argentina · 🇧🇷 Brazil · 🇨🇦 Canada · 🇨🇱 Chile · 🇨🇴 Colombia · 🇭🇹 Haiti · 🇲🇽 Mexico · 🇵🇪 Peru · 🇺🇸 United States · 🇺🇾 Uruguay |
| **Europe** | 🇦🇹 Austria · 🇧🇪 Belgium · 🇧🇬 Bulgaria · 🇭🇷 Croatia · 🇨🇾 Cyprus · 🇨🇿 Czech Republic · 🇩🇰 Denmark · 🇪🇪 Estonia · 🇫🇮 Finland · 🇫🇷 France · 🇩🇪 Germany · 🇬🇮 Gibraltar · 🇬🇷 Greece · 🇭🇺 Hungary · 🇮🇪 Ireland · 🇮🇹 Italy · 🇱🇻 Latvia · 🇱🇮 Liechtenstein · 🇱🇹 Lithuania · 🇱🇺 Luxembourg · 🇲🇹 Malta · 🇳🇱 Netherlands · 🇳🇴 Norway · 🇵🇱 Poland · 🇵🇹 Portugal · 🇷🇴 Romania · 🇷🇺 Russia · 🇸🇰 Slovakia · 🇸🇮 Slovenia · 🇪🇸 Spain · 🇸🇪 Sweden · 🇨🇭 Switzerland · 🇺🇦 Ukraine · 🇬🇧 United Kingdom |
| **Asia & Pacific** | 🇦🇺 Australia · 🇧🇩 Bangladesh · 🇨🇳 China · 🇭🇰 Hong Kong · 🇮🇩 Indonesia · 🇮🇳 India · 🇯🇵 Japan · 🇰🇷 South Korea · 🇰🇼 Kuwait · 🇲🇾 Malaysia · 🇳🇿 New Zealand · 🇵🇭 Philippines · 🇵🇰 Pakistan · 🇶🇦 Qatar · 🇸🇦 Saudi Arabia · 🇸🇬 Singapore · 🇹🇭 Thailand · 🇹🇷 Turkey · 🇦🇪 UAE · 🇻🇳 Vietnam |
Use the [List Institutions](/api-reference/endpoint/payment-methods/institution) endpoint with `channel=SWIFT` to look up valid SWIFT/BIC codes for a given recipient country.
# Testing in Sandbox
Source: https://docs.afriex.com/guides/testing-in-sandbox
The complete reference for building and testing your integration against the Afriex sandbox: test values, deterministic outcomes, virtual account transfers, OTP flow, balance topups, and webhook drills.
Every Afriex environment behaves the same way on the wire: your requests, responses, webhooks and error codes are identical between sandbox and production. Sandbox just refuses to move real money.
**Base URL:** `https://sandbox.api.afriex.com`, with a sandbox API key.
Deposits, payouts, and virtual accounts can all be tested on **every payment method and currency** your business supports.
Try the sandbox demo, no API key or code required. Pick a currency and amount, click **Proceed to payment**, and walk through mobile money or bank transfer as your customers would. The demo is sandbox-only and uses the real hosted checkout page under the hood.
## When a transaction settles
A sandbox deposit or payout completes on its own about **5 minutes** after it is created, and your webhook receives the final status exactly as in production. When you need it sooner:
* Include `SIMULATE_INSTANT` anywhere in `meta.reference` when you create the transaction, and it completes in about **30 seconds**.
* Or call [Simulate Transaction Outcome](/api-reference/endpoint/transactions/simulate) with `{"outcome": "success"}` or `{"outcome": "failed"}` to finalize it immediately.
Either way the `TRANSACTION.UPDATED` webhook is sent when the transaction settles.
## Choosing the outcome with the last 4 digits
The last four digits of the **account number**, the **phone number**, or the **card number** decide what happens. Everything else about the account is up to you, so you can build one payment method per outcome and reuse it across tests. A payment method with none of these to key off (an email or a wallet address, for example) simply succeeds unless you complete it with [`/simulate`](#simulating-a-pending-transaction).
| Ends in | Payout (`WITHDRAW`) | Mobile money deposit (`DEPOSIT`) | Card deposit (`DEPOSIT`, via [checkout](/api-reference/endpoint/checkout-sessions/create)) |
| - | - | - | - |
| anything else | Succeeds | Succeeds | Succeeds |
| `0001` | Fails; your balance is refunded | Fails | Fails |
| `0002` | Succeeds | Waits for a one-time password (see [OTP](#otp-required-mobile-money-deposits)) | Succeeds |
| `0003` | Stays pending until you call [`/simulate`](#simulating-a-pending-transaction) | Stays pending until you call [`/simulate`](#simulating-a-pending-transaction) | Stays pending until you call [`/simulate`](#simulating-a-pending-transaction) |
| `0004` | Account name lookup ([`/resolve`](/api-reference/endpoint/payment-methods/resolve)) fails | Succeeds | Succeeds |
| `0005` | Declined immediately | Declined immediately | Declined immediately |
**Cards are hosted-checkout only.** The "Card deposit" column above applies to card payments made through a [checkout session](/api-reference/endpoint/checkout-sessions/create); [Create Payment Method](/api-reference/endpoint/payment-methods/create) does not accept `CARD` as a channel.
A phone number must still be a valid mobile number for its country, so keep the country prefix and change only the last digits. For example `+254712340002` in Kenya. Account name lookups against a sandbox account return **`Sandbox Test Account`**.
## Simulating a pending transaction
Any payment method ending in `0003` puts the transaction in `PENDING` and holds it there until you tell the sandbox what to do. Call [Simulate Transaction Outcome](/api-reference/endpoint/transactions/simulate):
```bash theme={null}
curl -X POST \
-H "x-api-key: YOUR_SANDBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"outcome": "success"}' \
https://sandbox.api.afriex.com/api/v1/transaction/TRANSACTION_ID/simulate
```
Pass `{"outcome": "failed"}` to test your failure path. The result arrives by the `TRANSACTION.UPDATED` webhook shortly after this call returns.
You can use the same endpoint to complete **any** pending sandbox transaction early, not just ones ending in `0003`. The transaction must still be `PENDING`, `PROCESSING`, or `UNKNOWN`; a deposit waiting on OTP (`CUSTOMER_ACTION_REQUIRED`) has to be authorized first.
## OTP-required mobile money deposits
A mobile-money deposit from a wallet ending in `0002` returns `CUSTOMER_ACTION_REQUIRED`. Complete it with [Authorize Transaction](/api-reference/endpoint/transactions/authorize) using the sandbox OTP **`123456`**:
```bash theme={null}
curl -X POST \
-H "x-api-key: YOUR_SANDBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"OTP","otp":"123456"}' \
https://sandbox.api.afriex.com/api/v1/transaction/TRANSACTION_ID/authorize
```
Any other OTP returns `OTP_INCORRECT`, so you can test the wrong-OTP path too. After authorization the deposit completes on the normal sandbox timeline (or you can accelerate it via [`/simulate`](#simulating-a-pending-transaction)).
## Simulating an incoming bank transfer
Sandbox virtual accounts receive no money on their own. Pay into one with [Simulate Virtual Account Transfer](/api-reference/endpoint/payment-methods/virtual-account-simulate-transfer):
```bash theme={null}
# Reusable virtual account (created without an amount)
curl -X POST \
-H "x-api-key: YOUR_SANDBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"accountNumber": "4821930275", "amount": 5000, "currency": "NGN"}' \
https://sandbox.api.afriex.com/api/v1/payment-method/virtual-account/simulate-transfer
```
For a **one-time** virtual account (created with an `amount`), also pass the `reference` the account was issued with. Pass `"outcome": "failed"` to simulate a failed inflow. The credit lands as a transaction and is reported by webhook.
## Testing cards
Cards are only collected through a hosted [checkout session](/api-reference/endpoint/checkout-sessions/create), never through [Create Payment Method](/api-reference/endpoint/payment-methods/create). To test a card deposit:
Call [Create Checkout Session](/api-reference/endpoint/checkout-sessions/create) with `channels` including `"CARD"`. Redirect the payer to the returned `checkoutUrl`.
On the hosted page, use a test card whose **last four digits** pick the outcome from the [table above](#choosing-the-outcome-with-the-last-4-digits) (`0001` fails, `0003` stays pending, and so on). The hosted page pre-fills a valid test card in read-only form on sandbox, so you can click **Pay** without typing anything.
The card deposit lands as a `DEPOSIT` transaction and settles on the sandbox timeline described above. Use [`/simulate`](#simulating-a-pending-transaction) to finalize a `0003` pending card deposit on demand.
## Hosted checkout in sandbox
A checkout session started with a sandbox API key opens the hosted page in **test mode**, with helpers built in so you don't have to memorize the test values above:
* A **Test mode** badge in the page header.
* The mobile-money **test numbers** (`0001` fails, `0002` asks for an OTP, `0005` is declined) are listed on the mobile-money screen.
* The **OTP hint** (`123456`) is shown when an OTP is required.
* **Simulate transfer** / **Simulate failed transfer** buttons on the bank-transfer screen finalize the deposit without you needing to leave the page.
* Card fields are pre-filled with a valid **test card** in read-only form.
Want to try the hosted checkout end to end without touching the API first? Open the [sandbox demo](https://sandbox.pay.afriex.com/demo), pick a currency and amount, and click **Proceed to payment**. All three channels are covered, allocated per currency: **mobile money** (KES, GHS and others), **bank transfer** (NGN), and **card** (USD). Use the same test values documented above to drive different outcomes.
## Funding your sandbox balance
Payouts and swaps need funds in your sandbox wallets. Call [Top Up Sandbox Balance](/api-reference/endpoint/balance/topup) whenever you need more:
```bash theme={null}
curl -X POST \
-H "x-api-key: YOUR_SANDBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"currency": "USD", "amount": 10000}' \
https://sandbox.api.afriex.com/api/v1/org/balance/topup
```
The top-up credits your Payout wallet by default. To fund another wallet, add `"walletType": "FEE"` (the wallet that settles transaction fees) or `"walletType": "DEPOSIT"` (the Collection wallet). Read the result back with [Get Balance](/api-reference/endpoint/balance/get) and the same `walletType`.
The topup endpoint only works in sandbox; production returns `403`.
## Drilling your webhook handler
Verify signatures, retries, and downstream handling with a synthetic event from [Trigger Test Webhook](/api-reference/endpoint/webhooks/trigger). Configure your webhook URL in the [Dashboard](https://business.afriex.com/) first.
## Checklist before going live
Once you're comfortable in sandbox:
Run a `success`, a `failed`, an OTP flow (`0002`), and a `0003` pending flow. Confirm your webhook handler branches correctly on each.
Trigger a test webhook and confirm you reject a tampered body and accept a valid one.
Look up transactions with [List Transactions](/api-reference/endpoint/transactions/list) and `?reference=`, the `meta.reference` you sent. Confirm that a retry with the same idempotency key returns `409` and creates nothing. See [Idempotency](/api-reference/idempotency).
Your code stays the same, but these settings differ between sandbox and production:
* **Base URL.** Use `https://api.afriex.com` instead of `https://sandbox.api.afriex.com`.
* **API key.** Create a key in **Live** mode, with only the [permissions](/api-reference/permissions) your integration needs.
* **Webhook public key.** Production signs webhooks with a different key. Copy it from **Developer → Webhook** in live mode, or every signature check fails.
* **Webhook IP address.** Production webhooks come from `34.197.33.100`, not the sandbox address. Allow it on your firewall. See [Afriex IP addresses](/api-reference/endpoint/webhooks/introduction#afriex-ip-addresses).
* **API allowlist.** If you use an [IP allowlist](/api-reference/ip-allowlisting), add your production servers' public addresses. **Allow all IPs** isn't available in live mode.
Sandbox and production run the same code paths. If you see divergence, email `support@afriex.com` with the request's `x-request-id` response header and we'll dig in. See [Request IDs](/api-reference/errors#request-ids).
# Afriex Business API
Source: https://docs.afriex.com/index
Build seamless payment experiences with the Afriex Business API
## Welcome
The Afriex Business API enables businesses to manage customers, process cross-border payments, and handle payouts across Africa and beyond. Integrate our API to send and receive payments in multiple currencies.
## Start here
Send your first sandbox payout, from API key to settled transaction.
How customers, payment methods, and transactions link together.
## What do you want to build?
Send money to a bank account or mobile money wallet with a `WITHDRAW` transaction, and quote it first to show the exact amounts.
Build a payment batch, add recipients, and start every payout in one call.
Redirect your customer to an Afriex-hosted page that takes card, bank transfer, and mobile money.
Give each customer a virtual bank account, and reconcile what arrives through webhooks.
Collect from a customer's payment method with a `DEPOSIT` transaction, including OTP-approved mobile money.
Read live exchange rates, and convert your balance between currencies with a `SWAP` transaction.
## Supported markets
Afriex pays out and collects across most of Africa, with routes to Europe, North America, and Asia. Coverage depends on the payment channel: bank transfer, mobile money, SWIFT, and others.
Every country, currency, and channel Afriex supports, kept in one list.
To check the providers available for a specific country and channel, call [List Institutions](/api-reference/endpoint/payment-methods/institution).
## Tools
Drive successes, failures, OTPs, and webhooks before you go live.
Typed methods for the API, with webhook signature helpers.
Every endpoint with sandbox example requests, generated from the OpenAPI spec.
Use the API and search these docs from Claude, Cursor, and other MCP clients.
## Get help
Email [support@afriex.com](mailto:support@afriex.com), and check [the status page](https://afriex.statuspage.io) for incidents.
# Connecting MCP Clients
Source: https://docs.afriex.com/mcp/connecting
Connect Claude Desktop, Claude Code, and Cursor to the Afriex MCP server
Connect your MCP client to the Afriex MCP server at `https://mcp.afriex.com/mcp`.
There are two ways to provide your API key:
* **HTTP headers**: pass your API key directly in the client config. The server stores these values for the session and uses them for all Business API calls. Recommended when your client supports custom headers.
* **`authenticate` tool**: call the `authenticate` tool as your first action after connecting. Works with all clients.
The examples below connect to the sandbox, which the MCP server calls `development`. Change it to `production` only when you want the assistant to act on live data and move real money.
Your API key is a secret. Don't put it in a config file that you commit to a repository. Each client below shows where to keep it instead.
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
**With HTTP headers (recommended):**
```json theme={null}
{
"mcpServers": {
"afriex": {
"url": "https://mcp.afriex.com/mcp",
"headers": {
"x-afriex-api-key": "your-api-key",
"x-afriex-environment": "development"
}
}
}
}
```
**With `authenticate` tool:**
```json theme={null}
{
"mcpServers": {
"afriex": {
"url": "https://mcp.afriex.com/mcp"
}
}
}
```
Restart Claude Desktop. The Afriex tools will appear in the tools menu. Then call the `authenticate` tool:
```json theme={null}
{
"apiKey": "your-api-key",
"environment": "development"
}
```
**With HTTP headers (recommended):**
Add the server from your terminal. The key is stored in your local Claude Code config, not in the project:
```bash theme={null}
claude mcp add --transport http afriex https://mcp.afriex.com/mcp \
--header "x-afriex-api-key: your-api-key" \
--header "x-afriex-environment: development"
```
To share the server with your team, add it to `.mcp.json` in your project root instead, and read the key from an environment variable so it never lands in the repository:
```json theme={null}
{
"mcpServers": {
"afriex": {
"type": "http",
"url": "https://mcp.afriex.com/mcp",
"headers": {
"x-afriex-api-key": "${AFRIEX_API_KEY}",
"x-afriex-environment": "development"
}
}
}
}
```
**With `authenticate` tool:**
```bash theme={null}
claude mcp add --transport http afriex https://mcp.afriex.com/mcp
```
Then call the `authenticate` tool:
```json theme={null}
{
"apiKey": "your-api-key",
"environment": "development"
}
```
**With HTTP headers (recommended):**
Add to `~/.cursor/mcp.json`, your global Cursor config. Avoid `.cursor/mcp.json` in your project root for this, because that file is usually committed with the project.
```json theme={null}
{
"mcpServers": {
"afriex": {
"url": "https://mcp.afriex.com/mcp",
"headers": {
"x-afriex-api-key": "your-api-key",
"x-afriex-environment": "development"
}
}
}
}
```
**With `authenticate` tool:**
Go to **Cursor Settings → MCP → Add new MCP Server**, select **Type: URL**, and enter `https://mcp.afriex.com/mcp`.
Or add it to `.cursor/mcp.json` in your project root. With no key in it, this file is safe to commit:
```json theme={null}
{
"mcpServers": {
"afriex": {
"url": "https://mcp.afriex.com/mcp"
}
}
}
```
Then call the `authenticate` tool:
```json theme={null}
{
"apiKey": "your-api-key",
"environment": "development"
}
```
# MCP Server
Source: https://docs.afriex.com/mcp/introduction
Use the Afriex Business API with AI assistants via the Model Context Protocol
The Afriex MCP server exposes the Afriex Business API as tools for AI assistants. It implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), letting clients like Claude Desktop, Claude Code, and Cursor interact with the Afriex platform programmatically.
## How it works
The server exposes the core Business API endpoints as MCP tools, listed below. An AI assistant can create customers, send transactions, look up balances, and more, all via natural language.
Some endpoints have no tool yet, including quotes, payment batches, customer updates and verification, transaction authorization, and the sandbox simulators. Call those over the REST API.
## Authentication
You will need an Afriex Business API key from the [dashboard](https://business.afriex.com/). Connect your MCP client to `https://mcp.afriex.com/mcp`, then authenticate using one of two methods:
**Method 1: HTTP headers (recommended)**
Pass your API key directly in your MCP client config. The server captures these values and uses them for all subsequent Business API calls in the session:
| Header | Required | Description |
| - | - | - |
| `x-afriex-api-key` | Yes | Your Afriex Business API key |
| `x-afriex-environment` | No | `development` (the sandbox, `https://sandbox.api.afriex.com`) or `production`. Defaults to `development`. |
**Method 2: `authenticate` tool**
After connecting, call the `authenticate` tool as your first action:
```json theme={null}
{
"apiKey": "your-api-key",
"environment": "development"
}
```
With `production`, the assistant acts on live data, and tools such as `create_transaction` move real money. Start in `development`, which is the MCP server's name for the sandbox. Give the API key only the [permissions](/api-reference/permissions) you want the assistant to have.
See [Connecting MCP Clients](/mcp/connecting) for client-specific setup instructions.
## Available Tools (26)
### Authentication
| Tool | Description |
| - | - |
| `authenticate` | Set your API key and environment (`production`/`development`) for this session |
| `session_info` | View current session config: masked API key, environment, and base URL |
### Customers
| Tool | Description |
| - | - |
| `create_customer` | Create a new customer with name, email, phone, and country code |
| `list_customers` | Retrieve a paginated list of customers |
| `get_customer` | Get a customer by ID |
| `delete_customer` | Delete a customer by ID |
| `update_customer_kyc` | Update KYC information for a customer |
### Transactions
| Tool | Description |
| - | - |
| `create_transaction` | Create a `DEPOSIT`, `WITHDRAW`, or `SWAP` transaction |
| `list_transactions` | Retrieve a paginated list of transactions |
| `get_transaction` | Get a transaction by ID |
### Payment Methods
| Tool | Description |
| - | - |
| `create_payment_method` | Create a payment method (`BANK_ACCOUNT`, `SWIFT`, `MOBILE_MONEY`, `UPI`, `INTERAC`, `WE_CHAT`) |
| `list_payment_methods` | Retrieve a paginated list of payment methods |
| `get_payment_method` | Get a payment method by ID |
| `delete_payment_method` | Delete a payment method by ID |
| `list_institutions` | List banks/providers by country and channel |
| `resolve_institution_codes` | Resolve a SWIFT code or routing number to a bank name |
| `resolve_payment_method` | Resolve recipient info by account number or phone |
| `get_crypto_wallet` | Get or create a crypto wallet (USDT/USDC, production only) |
| `list_virtual_accounts` | List existing virtual accounts for a customer and currency (`NGN`, `GHS`, `KES`, or `USD`) |
| `create_virtual_account` | Create a virtual account in `NGN`, `GHS`, `KES`, or `USD`, static (`label`) or dynamic (`amount`). A customer can hold `NGN` or `GHS`; omit `customerId` for `KES` and `USD` |
| `get_pool_account` | Get the business's shared pool account for a country (production only) |
### Checkout Sessions
| Tool | Description |
| - | - |
| `create_checkout_session` | Create a hosted checkout session and return its `checkoutUrl` |
### Webhooks
| Tool | Description |
| - | - |
| `trigger_webhook` | Fire a real signed test webhook to your configured callback URL (sandbox only) |
### Organization
| Tool | Description |
| - | - |
| `get_balance` | Fetch wallet balances for specified currencies |
| `get_rates` | Get real-time exchange rates |
| `topup_balance` | Top up sandbox balance with a specified amount and currency (sandbox only) |
# Quickstart
Source: https://docs.afriex.com/quickstart
Send your first sandbox payout, from API key to settled transaction
This guide takes you from a new API key to a settled payout in the sandbox. You create a customer, save the bank account you are paying, fund your sandbox balance, price the payout, and send it. No real money moves.
Want to see what the hosted checkout looks like before writing any code? Try the [sandbox demo](https://sandbox.pay.afriex.com/demo). No API key required.
## Prerequisites
* An Afriex Business account ([sign up here](https://business.afriex.com/))
* A tool for making HTTP requests (cURL, [Postman](/development#using-postman), or your preferred HTTP client)
Every request below goes to the sandbox at `https://sandbox.api.afriex.com`. Replace `YOUR_API_KEY` with your sandbox key, and replace the IDs in capitals with the values your earlier calls returned.
1. Log in to the [Afriex Dashboard](https://business.afriex.com/) and switch the **Test / Live** toggle to **Test**.
2. Go to **Developer** and open the **API Keys** tab.
3. Generate a key. For this guide, give it **Write** on **Customers**, **Payment methods**, and **Withdrawals**, and **Read** on **Rates and quotes** and **Transactions**. See [Permissions](/api-reference/permissions) for what each level grants.
4. Copy the key and store it securely.
Keep your API key on your server. Never put it in client-side code or a public repository. Test mode keys only work against the sandbox, and live mode keys only work against production.
A customer is the end user you are acting for. On a payout, the customer is the sender; you save the recipient's bank account in the next step.
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/customer \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"fullName": "John Doe",
"email": "john.doe@example.com",
"phone": "+2348192837465",
"countryCode": "NG"
}'
```
The response returns the new customer. Keep its `customerId` for the next steps.
```json theme={null}
{
"data": {
"customerId": "69516dd0464b2213bd74cfad",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+2348192837465",
"countryCode": "NG"
}
}
```
You send the customer's name as `fullName`, and responses return it as `name`.
A payout goes to a payment method, so save the recipient's bank account first, linked to the customer. Look up the bank's `institutionCode` with [List Institutions](/api-reference/endpoint/payment-methods/institution) rather than hardcoding it:
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/payment-method/institution?countryCode=NG&channel=BANK_ACCOUNT" \
-H "x-api-key: YOUR_API_KEY"
```
Then create the payment method with an `institutionCode` and `institutionName` from that list:
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/payment-method \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"customerId": "CUSTOMER_ID",
"type": "WITHDRAW",
"channel": "BANK_ACCOUNT",
"accountName": "Amaka Okafor",
"accountNumber": "0123456789",
"countryCode": "NG",
"institution": {
"institutionCode": "000013",
"institutionName": "Guaranty Trust Bank (GTBank) Plc"
}
}'
```
Keep the `paymentMethodId` from the response.
In the sandbox, the last four digits of the account number choose what happens to payouts to it. `6789` succeeds. `0001` fails, and `0003` stays pending until you call [`/simulate`](/api-reference/endpoint/transactions/simulate). See [Testing in Sandbox](/guides/testing-in-sandbox) for every test value.
A payout debits your Payout wallet, so give it some test USD with [Top Up Sandbox Balance](/api-reference/endpoint/balance/topup):
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/org/balance/topup \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{ "amount": 500, "currency": "USD" }'
```
This endpoint is sandbox only and returns `403` in production.
Ask for a [quote](/api-reference/endpoint/transactions/quote) before you send. Choose the side you want to be exact. Here the recipient gets exactly 5,000 NGN, and the API works out how many USD that costs:
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/transaction/quote?customerId=CUSTOMER_ID&destinationId=PAYMENT_METHOD_ID&sourceCurrency=USD&destinationCurrency=NGN&destinationAmount=5000" \
-H "x-api-key: YOUR_API_KEY"
```
The response holds the `rate`, any `fee`, and both amounts. A quote creates nothing, and creating the transaction with the same inputs charges the quoted amounts.
Create a `WITHDRAW` with the same inputs as the quote. Send only the amount you fixed, here `destinationAmount`, and let the API derive the other side.
```bash theme={null}
curl -X POST https://sandbox.api.afriex.com/api/v1/transaction \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"customerId": "CUSTOMER_ID",
"type": "WITHDRAW",
"destinationAmount": "5000",
"sourceCurrency": "USD",
"destinationCurrency": "NGN",
"destinationId": "PAYMENT_METHOD_ID",
"meta": {
"reference": "order-12345",
"idempotencyKey": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}'
```
`meta.reference` is your own order ID, and `meta.idempotencyKey` is a fresh UUID for this attempt. Both must be unique in your business. If the request times out, retry with the same idempotency key so it can never create a second payout. See [Idempotency](/api-reference/idempotency).
The transaction starts as `PENDING`. In the sandbox it settles about 5 minutes later. Add `SIMULATE_INSTANT` anywhere in `meta.reference` to settle it in about 30 seconds.
Look it up by your reference:
```bash theme={null}
curl "https://sandbox.api.afriex.com/api/v1/transaction?reference=order-12345" \
-H "x-api-key: YOUR_API_KEY"
```
In a real integration, don't poll. Listen for the `TRANSACTION.UPDATED` [webhook](/api-reference/endpoint/webhooks/transaction-events) and act on its `status`.
## Next steps
**Planning to use webhooks?** Allowlist these Afriex IP addresses on your server so webhook deliveries are not blocked:
| Environment | IP address |
| - | - |
| Sandbox | `34.234.189.210` |
| Production | `34.197.33.100` |
See [Webhook setup](/api-reference/endpoint/webhooks/introduction) for full details.
How customers, payment methods, and transactions fit together, with payloads for every transaction type.
Drive failures, OTPs, pending payouts, and webhooks before you go live.
Receive real-time notifications and verify their signatures.
Explore all available endpoints and their parameters.
Prefer a tutorial? Follow the blog walkthrough from signup to your first request.
## Support
Need help? Contact us at [support@afriex.com](mailto:support@afriex.com).
# Afriex SDK
Source: https://docs.afriex.com/sdk/introduction
TypeScript SDK for the Afriex Business API: type-safe, modular, and production-ready
## Overview
The Afriex SDK is a TypeScript library that wraps the Afriex Business API with full type definitions, built-in retry logic, and modular imports. Every REST endpoint in the [API reference](/api-reference/introduction) has a matching SDK method except [Quote a Withdrawal](/api-reference/endpoint/transactions/quote), which you call over REST. The TypeScript snippet for each method appears as a tab in the right-hand code panel on that endpoint's page. These docs describe `@afriex/sdk` 5.x.
This page covers what is SDK-specific: install, init, configuration, retries, environments, and webhook signature handling.
## Installation
```bash theme={null}
npm install @afriex/sdk
# or
pnpm add @afriex/sdk
# or
yarn add @afriex/sdk
```
## Quick Start
### 1. Initialize the SDK
```typescript theme={null}
import { AfriexSDK } from "@afriex/sdk";
const afriex = new AfriexSDK({
apiKey: process.env.AFRIEX_API_KEY,
environment: "staging", // the sandbox; or 'production'
});
```
`environment` defaults to `'production'`. A client created without it sends every request to production, so set it explicitly. The SDK's name for the sandbox is `'staging'`, which points at `https://sandbox.api.afriex.com`, the same sandbox the rest of these docs describe.
### 2. Make your first calls
```typescript theme={null}
const customer = await afriex.customers.create({
fullName: "John Doe",
email: "john@example.com",
phone: "+1234567890",
countryCode: "US",
});
const rates = await afriex.rates.getRates({
fromSymbols: "USD",
toSymbols: "NGN,GBP",
});
const balances = await afriex.balance.getBalance({
currencies: ["USD", "NGN"],
});
```
For the full set of methods, browse the [API reference](/api-reference/introduction); each endpoint page shows the equivalent SDK call.
## Configuration
| Option | Type | Default | Description |
| :- | :- | :- | :- |
| `apiKey` | `string` | Required | Your Afriex API key |
| `environment` | `string` | `'production'` | `'staging'` (the sandbox) or `'production'` |
| `webhookPublicKey` | `string` | (none) | Public key for webhook verification |
| `retryConfig` | `RetryConfig` | `{ maxRetries: 0 }` | Request retry configuration. See [Enable retries](#enable-retries) |
| `signRequest` | `(request) => string` | (none) | Returns the `x-api-signature` for a request, if your business has [request signing](/api-reference/authentication#request-signing) enabled. It receives the `method`, `url`, and `body` |
| `apiVersion` | `string` | `'2026-05-18'` | Sent as `x-api-version` |
| `customConfig` | `object` | (none) | Override `baseUrl` or `timeout` (default 30 seconds) |
| `logLevel`, `enableLogging` | | `'error'`, `true` | SDK logging |
### Enable Retries
Retries are disabled by default. Enable them with `retryConfig`. The SDK retries a request when it fails with one of `retryableStatusCodes`:
```typescript theme={null}
const afriex = new AfriexSDK({
apiKey: process.env.AFRIEX_API_KEY,
environment: "staging",
retryConfig: {
maxRetries: 3,
retryDelay: 1000,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
},
});
```
**`POST` and `PATCH` are not retried by default,** because sending one twice can repeat its effect. So `transactions.create` is not retried even with `retryConfig` set. A transaction create is safe to send again with the same `meta.idempotencyKey` (see [Idempotency](/api-reference/idempotency)), so retry it yourself, or add `"POST"` to `retryConfig.retryableMethods`. That list applies to every endpoint, and other creates, such as payment methods, have no idempotency key. A retry resends the same body, so it carries the same idempotency key. If a retried create returns `409 DUPLICATE_REQUEST`, the first attempt went through: look the transaction up by its reference instead of sending it again.
## Environments
| Environment | Base URL |
| :- | :- |
| Sandbox (`'staging'`) | `https://sandbox.api.afriex.com` |
| Production | `https://api.afriex.com` |
Get your API key from the [Afriex Dashboard](https://business.afriex.com) under the **Developer** tab.
## Webhooks
The SDK ships helpers for verifying webhook signatures and parsing event payloads. See [Webhooks](/sdk/webhooks).
## Credits
The SDK started as a hackathon project by the Afriex DevRel team and is now maintained as the official SDK.
# Webhooks
Source: https://docs.afriex.com/sdk/webhooks
Verify webhook signatures and handle event notifications with the SDK
The SDK ships helpers for verifying webhook signatures and parsing event payloads. For the full list of webhook events and payload shapes, see the [Webhooks reference](/api-reference/endpoint/webhooks/introduction).
## Setup
Pass your webhook public key when initializing the SDK:
```typescript theme={null}
const afriex = new AfriexSDK({
apiKey: process.env.AFRIEX_API_KEY,
environment: "staging", // or 'production'
webhookPublicKey: process.env.AFRIEX_WEBHOOK_PUBLIC_KEY,
});
```
Copy the public key from **Developer → Webhook** in the dashboard. Sandbox and production have different keys, so use the one that matches `environment`.
## Verify and Parse
The signature covers the exact bytes Afriex sent, so verify the raw request body. If a JSON body parser such as `express.json()` runs first, you get a parsed object instead of the bytes Afriex signed, and every signature check fails. In Express, read the webhook route with `express.raw()` and pass the body to the SDK as it is. `verify` and `verifyAndParse` take a `Buffer` or a string:
Store each event before you acknowledge it, and act on it afterwards, outside the request. Afriex waits up to 15 seconds for a `2xx`, retries anything slower, and sends every event at least once, so key what you store on the event's `id`. See [Duplicates, ordering, and replays](/api-reference/endpoint/webhooks/introduction#duplicates-ordering-and-replays).
```typescript theme={null}
import express from "express";
import type { WebhookPayload } from "@afriex/sdk";
const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["x-webhook-signature"] as string;
const payload: Buffer = req.body; // raw bytes from express.raw(), not parsed JSON
let event: WebhookPayload;
try {
event = afriex.webhooks.verifyAndParse(payload, signature);
} catch {
return res.status(400).send("Invalid signature");
}
try {
// A unique key on event.id turns a retry or a replay into a no-op.
await webhookEvents.insertIfAbsent({ id: event.id, payload: event });
} catch {
return res.status(500).send("Try again"); // not stored, so let Afriex retry
}
res.status(200).send("OK");
});
// Run by a worker for each stored event that isn't marked done. Make it safe to
// run twice, and compare data.updatedAt with what you hold, because a retried
// event can arrive after a newer one.
async function processWebhookEvent(event: WebhookPayload) {
switch (event.event) {
case "CUSTOMER.CREATED":
case "CUSTOMER.UPDATED":
case "CUSTOMER.DELETED":
console.log("Customer event:", event.data);
break;
case "TRANSACTION.CREATED":
case "TRANSACTION.UPDATED":
console.log("Transaction event:", event.data);
break;
case "PAYMENT_METHOD.CREATED":
case "PAYMENT_METHOD.UPDATED":
case "PAYMENT_METHOD.DELETED":
console.log("Payment method event:", event.data);
break;
case "CHECKOUT_SESSION.CREATED":
console.log("Checkout session event:", event.data);
break;
case "POOL_DEPOSIT_REQUEST.REJECTED":
console.log("Pool deposit rejected:", event.data);
break;
}
}
```
`id` and `createdAt` are part of every webhook body. The SDK's `WebhookPayload` type includes them from the first release after `@afriex/sdk` 5.0.0. On 5.0.0, upgrade, or read them from the parsed JSON.
## Verify Only
Validate the signature without parsing. `payload` is the raw body, as above:
```typescript theme={null}
const isValid = afriex.webhooks.verify(payload, signature);
if (isValid) {
const event = JSON.parse(payload.toString("utf8"));
// Handle event
}
```
## Trigger Test Webhook (Sandbox)
Send a test webhook to your endpoint in sandbox:
```typescript theme={null}
const result = await afriex.webhooks.triggerTestWebhook({
event: "TRANSACTION.UPDATED",
resourceId: "transaction-id",
});
console.log(result); // { success: true, message: "..." }
```
For `CHECKOUT_SESSION.CREATED`, use the `checkoutId` returned when you created the checkout as `resourceId`.
Test webhook triggers only work in the sandbox environment.