Sending money across borders involves more moving parts than most developers expect. You need to know the live exchange rate before the user confirms. You need to register the recipient correctly. You need to attach their bank account or mobile wallet. You need to trigger the transfer and then track it through to settlement. And you need to handle failures gracefully when something goes wrong in the corridor.
Most of that complexity is what the Afriex SDK is built to handle. This article walks through the full remittance flow — from registering a sender to confirming delivery — using the Afriex SDK from start to finish.
Get your API key
Everything starts at business.afriex.com. Create a Business account if you do not have one. Once you are in:
- Go to Settings then API Keys
- Click Generate new API key
- Give it a name (e.g. "Remittance App")
- Set the permissions your use case needs — for a remittance app, you need at minimum: View wallet balances, Initiate withdrawals, View payment methods, and Add or manage payment methods
- Copy the key immediately. You cannot view it again after leaving the page.
Store it as an environment variable:
AFRIEX_API_KEY=your-api-key-here
AFRIEX_ENVIRONMENT=staging # switch to production when you go live
Start in the staging environment so nothing real moves while you build.
Install and initialize the SDK
npm install @afriex/sdk
Initialize once and reuse the instance across your application:
// src/lib/afriex.ts
import { Afriex } from "@afriex/sdk";
export const afriex = new Afriex({
apiKey: process.env.AFRIEX_API_KEY!,
environment:
(process.env.AFRIEX_ENVIRONMENT as "staging" | "production") ?? "staging",
retryConfig: {
maxRetries: 3,
retryDelay: 1000,
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
},
webhookPublicKey: process.env.AFRIEX_WEBHOOK_PUBLIC_KEY,
});
The retryConfig handles transient failures automatically. If the Afriex API returns a 429 or a 503, the SDK retries up to three times with a delay between attempts, so you do not have to write that logic yourself. The webhookPublicKey is used later for signature verification — get it from the Developers > Webhooks section of your dashboard.
Step 1: Show the live exchange rate
Before anything else, show the sender what rate they are getting. A remittance user deciding between providers makes that decision based on the rate they see. Never show a hardcoded or cached rate — always fetch live.
import { afriex } from "@/lib/afriex";
async function getRate(from: string, to: string) {
const result = await afriex.rates.getRates({
fromSymbols: from,
toSymbols: to,
});
const rate = result?.rates?.[from]?.[to];
if (!rate) {
throw new Error(`Rate not available for ${from}/${to}`);
}
return parseFloat(rate);
}
// Usage: sender sends USD, recipient gets NGN
const rate = await getRate("USD", "NGN");
console.log(`1 USD = ${rate} NGN`);
This call returns the live mid-market rate for the currency pair. Show it to the user with a clear note about when it was fetched, since rates can shift between the moment they see it and the moment they confirm. The rate the user sees is informational — the actual conversion happens on Afriex's end when the transaction is created.
Step 2: Create the recipient as a customer
The recipient needs to exist in the Afriex system before you can attach a payment method or send them money. This is a one-time step per recipient.
import { afriex } from "@/lib/afriex";
async function registerRecipient(details: {
name: string;
email: string;
phone: string;
countryCode: string;
}) {
const customer = await afriex.customers.create({
name: details.name,
email: details.email,
phone: details.phone, // E.164 format, e.g. +2348012345678
countryCode: details.countryCode, // ISO 3166-1 alpha-2, e.g. NG, KE, GH
});
return customer;
}
const recipient = await registerRecipient({
name: "Ada Okafor",
email: "ada@example.com",
phone: "+2348012345678",
countryCode: "NG",
});
console.log(recipient.customerId); // store this — you need it in every subsequent call
Save the customerId that comes back. Every subsequent API call references this ID rather than the recipient's personal details.
If you are building a product where senders send to the same recipient repeatedly (a diaspora parent sending to family, for example), store the customerId against the recipient record in your own database on first creation and skip this step for future transfers.
Step 3: Attach the recipient's payment method
Now tell Afriex where to send the money — the recipient's bank account or mobile wallet.
Bank account
async function attachBankAccount(params: {
customerId: string;
accountName: string;
accountNumber: string;
institutionCode: string; // bank code, e.g. "058" for GTBank Nigeria
institutionName: string;
countryCode: string;
}) {
const paymentMethod = await afriex.paymentMethods.create({
customerId: params.customerId,
channel: "BANK_ACCOUNT",
accountName: params.accountName,
accountNumber: params.accountNumber,
countryCode: params.countryCode,
institution: {
institutionCode: params.institutionCode,
institutionName: params.institutionName,
},
});
return paymentMethod;
}
const bankMethod = await attachBankAccount({
customerId: recipient.customerId,
accountName: "Ada Okafor",
accountNumber: "0123456789",
institutionCode: "058",
institutionName: "GTBank",
countryCode: "NG",
});
console.log(bankMethod.paymentMethodId); // store this too
Mobile money
For recipients receiving on MTN, M-Pesa, Airtel, or similar networks, the channel changes but the pattern is the same:
const mobileMethod = await afriex.paymentMethods.create({
customerId: recipient.customerId,
channel: "MOBILE_MONEY",
accountName: "Ada Okafor",
accountNumber: "+2348012345678", // recipient's mobile number
countryCode: "NG",
institution: {
institutionCode: "MTN",
institutionName: "MTN Mobile Money",
},
});
Not sure which institution code to use? Call afriex.paymentMethods.listInstitutions({ countryCode: "NG", channel: "BANK_ACCOUNT" }) to get the full list for any country and channel combination.
Like the customerId, save the paymentMethodId that comes back. This is what you pass when you create the transaction.
Step 4: Send the transfer
This is the call that actually moves money. Everything before this was setup — this is the execution.
import { afriex } from "@/lib/afriex";
async function sendRemittance(params: {
customerId: string;
paymentMethodId: string;
amount: number;
sourceCurrency: string; // currency the sender is sending
destinationCurrency: string; // currency the recipient receives
reference: string; // your internal reference for this transfer
}) {
const idempotencyKey = `idem-${params.reference}`;
const transaction = await afriex.transactions.create({
type: "WITHDRAW",
customerId: params.customerId,
destinationId: params.paymentMethodId,
sourceAmount: `${params.amount}`,
destinationAmount: `${params.amount}`,
sourceCurrency: params.sourceCurrency,
destinationCurrency: params.destinationCurrency,
meta: {
reference: params.reference,
idempotencyKey,
narration: `Remittance — ${params.reference}`,
merchantId: params.reference,
},
});
return transaction;
}
const transfer = await sendRemittance({
customerId: recipient.customerId,
paymentMethodId: bankMethod.paymentMethodId,
amount: 100,
sourceCurrency: "USD",
destinationCurrency: "NGN",
reference: "txn-ada-001",
});
console.log(transfer.transactionId); // the Afriex transaction ID — store this
console.log(transfer.status); // starts as PENDING
Two things worth understanding about this call.
The idempotencyKey is derived from your own reference. If this call fails due to a network error and your code retries it, the same key means Afriex will not create a duplicate transaction. It recognizes the key and returns the existing transaction instead of creating a new one.
The transaction comes back with a status of PENDING. That is not a failure — it is the expected starting state. The actual settlement happens asynchronously, and Afriex tells you what happened next through webhooks.
Step 5: Track the transfer through webhooks
You will not know the outcome of a transfer by polling the response from the create call. Afriex sends a signed HTTP POST to your webhook URL every time the transaction status changes. This is how you know when money has actually landed.
First, register your webhook URL in the Afriex dashboard under Developers > Webhooks. Copy the webhook public key from that page and add it to your environment:
AFRIEX_WEBHOOK_PUBLIC_KEY=your-webhook-public-key
Then build the handler:
import crypto from "crypto";
// Signature verification
function verifySignature(
signature: string,
rawBody: string,
publicKey: string
): boolean {
try {
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(rawBody);
return verifier.verify(publicKey, signature, "base64");
} catch {
return false;
}
}
// Webhook handler (Next.js route example)
export async function POST(req: Request) {
const signature = req.headers.get("x-webhook-signature");
if (!signature) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}
// Read raw body before any parsing — the signature was computed against these exact bytes
const rawBody = await req.text();
const isValid = verifySignature(
signature,
rawBody,
process.env.AFRIEX_WEBHOOK_PUBLIC_KEY!
);
if (!isValid) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
if (
event.event === "TRANSACTION.UPDATED" ||
event.event === "TRANSACTION.CREATED"
) {
const { transactionId, status } = event.data;
// Update your database with the new status
await updateTransferStatus(transactionId, status);
switch (status) {
case "COMPLETED":
case "SUCCESS":
// Money has landed — notify the sender
await notifySender(transactionId, "Your transfer was delivered.");
break;
case "IN_REVIEW":
// Compliance hold — not a failure, just needs time
await notifySender(transactionId, "Your transfer is under review.");
break;
case "RETRY":
// The network is retrying automatically — no action needed
break;
case "FAILED":
case "REJECTED":
// Terminal failure — let the sender know and offer a retry
await notifySender(transactionId, "Your transfer could not be completed.");
break;
}
}
return Response.json({ received: true });
}
A few important things about how this handler is written.
It reads the raw body with req.text() before parsing anything. The RSA-SHA256 signature was computed against those exact bytes. If you parse to JSON first and re-serialize, the verification will fail even if the payload is genuine.
It returns 200 immediately. Afriex expects a response within about five seconds. If your handler does slow work (database writes, email sending) before returning, put that work in a queue and return immediately. Afriex will retry delivery up to twelve times with exponential backoff if it does not get a success response.
IN_REVIEW and RETRY are not failures. IN_REVIEW means a compliance hold that will resolve into COMPLETED or REJECTED. RETRY means the payment network is handling it automatically. Treating either as a failure will cause you to alert users unnecessarily.
The full picture
Here is the complete flow in sequence:
1. Fetch live rate → afriex.rates.getRates()
2. Register recipient → afriex.customers.create()
3. Attach payment method → afriex.paymentMethods.create()
4. Send the transfer → afriex.transactions.create()
5. Receive status updates → webhook handler
Steps 2 and 3 are one-time per recipient. For a sender who sends to the same person repeatedly, you store the customerId and paymentMethodId and jump straight to step 4 on subsequent transfers.
Afriex handles the currency conversion, the corridor routing, and the settlement. Your application handles the user flow, the data storage, and the notification layer. The two concerns stay clean and separate.
The full API reference is at docs.afriex.com and the Business dashboard is at business.afriex.com. If you have questions, drop them in the comments or reach out on X @codewithveek.
Top comments (0)