Welcome to AfriGate
AfriGate is a payment gateway that enables merchants to accept Mobile Money payments and perform transfers across Africa. Integrate once, reach 16 countries.
Collect Payments
Accept Mobile Money from your customers
Send Transfers
Disburse funds to recipients
Refunds
Refund full or partial payments
Webhooks
Real-time event notifications
Integration Journey
GET /v1/payment-methods Step 1 — firstFetch the countries/operators available for your account (with real-time availability) and display them to your customer. Never hardcode this list. See Payment Methods.
POST /v1/payments Step 2Create the payment with the operator + country chosen by the customer and the channel matching the operator's flow. See Payment Flows.
Webhook Step 3Receive the final result (payment.completed) on your callbackUrl, then confirm via GET /v1/payments/{token}.
Payment Flow
Merchant AfriGate Mobile Money Operator
| | |
|-- POST /v1/payments ----->| |
|<-- { token, status } ----| |
| |-- Routing + USSD Push ------>|
| | |
| |<-- Callback result ----------|
|<-- Webhook (callbackUrl) -| |
| | |
|-- GET /v1/payments/{token} ->| |
|<-- { status: "success" } ---| | Transfer Flow
Merchant AfriGate Mobile Money Operator
| | |
|-- POST /v1/transfers ---->| |
|<-- { token, status } ----| |
| |-- Routing + Send ----------->|
| | |
| |<-- Callback result ----------|
|<-- Webhook (callbackUrl) -| | Authentication
API Keys
Afrigate authentication is based on a key pair: a public key (pk_) and a private key (sk_, sometimes called the "secret"). Both are generated together from your merchant dashboard and must always be sent together in every request.
It's the same principle as the pairs used by other platforms:
| Platform | Public key | Private key |
|---|---|---|
| Afrigate | pk_live_... | sk_live_... |
| AWS | Access Key ID | Secret Access Key |
| OAuth 2.0 | client_id | client_secret |
| Stripe | pk_live_... | sk_live_... |
Public key (pk_) | Private key (sk_) | |
|---|---|---|
| Role | Identifies your merchant account | Proves you are the account owner |
| Analogy | Username | Password |
| Sensitive? | No, can be known by third parties | Yes, strictly confidential |
| Storage | Back-end env variable (acceptable) | Secret manager only |
| Shown in dashboard? | At any time | Once, at generation |
The public key (pk_)
- What it's for: it's your identifier. When Afrigate receives a request, this value tells it "which merchant are we talking about?".
- Format:
pk_{env}_{24 random characters}, e.g.pk_live_mZWbtIV-ll-_0tNSSAxXV4fW. - Sensitivity: not sensitive on its own. Knowing only a merchant's public key allows no action — like knowing a username without the password.
- Storage: can be stored in plaintext in your back-end code (env variable, config file). Still, don't put it in front-end / mobile code: not because of a compromise risk, but to avoid exposing it needlessly in logs or browser debug tools.
The private key (sk_)
- What it's for: it's your password. It proves you own the corresponding public key.
- Format:
sk_{env}_{32 random characters}, e.g.sk_live_bleW2QUnMZ4Z9RPMuNLyAwGJ2egoP7JN. - Sensitivity: strictly confidential. Anyone who obtains the
pk_+sk_pair can create payments and transfers on your behalf, move your funds, or view your transaction history. - Storage: server-side only, in a secret manager (AWS Secrets Manager, HashiCorp Vault, an uncommitted
.envfile, CI env variable). Never in front-end, mobile, or a public Git repo. - Retrieval: the private key is shown only once, at creation/rotation. Afrigate does not store it in plaintext (argon2 hash). If you lose it, you must generate a new one via the dashboard.
Combine both in a request
Concatenate the public key and the private key with a : in between, and place the result after Bearer in the Authorization header:
Authorization: Bearer {publicKey}:{privateKey} Full example:
Authorization: Bearer pk_live_mZWbtIV-ll-_0tNSSAxXV4fW:sk_live_bleW2QUnMZ4Z9RPMuNLyAwGJ2egoP7JN Pairs per environment
Each environment has its own pair — a sandbox key does not work in production and vice versa.
| Environment | Public key | Private key | Usage |
|---|---|---|---|
| Production | pk_live_... | sk_live_... | Real transactions, real fund movement |
| Sandbox | pk_test_... | sk_test_... | Testing, no real fund movement, 50,000-unit sandbox balance included (in your country's currency) |
If your private key leaks
- Log into the dashboard.
- Rotate the compromised key — the old private key is revoked immediately.
- Update the new pair in your back-ends.
- Review your transaction history over the suspicious window (illegitimate payments or transfers).
Permissions
| Permission | Description |
|---|---|
payment:read | View payments |
payment:write | Create/cancel payments |
transfer:read | View transfers |
transfer:write | Create transfers |
Per-key permissions are not yet enforced
These scopes describe the intended model, but the gateway does not currently enforce them. Today any valid API key pair has full access — read and write, payments and transfers. Fine-grained per-key restriction is planned but not yet active, so treat every key as fully privileged and protect the sk_ accordingly.
Injected Headers
After API key validation, the following headers are automatically added:
| Header | Description |
|---|---|
X-Merchant-ID | Your merchant identifier (UUID) |
X-Merchant-Code | Your merchant code |
X-Key-Type | live or test |
X-Request-ID | Unique request identifier |
Environments
| Environment | Base URL |
|---|---|
| Production | https://prod.afrigate.dev |
| Sandbox | https://sandbox.afrigate.dev |
Sandbox mode
Use the sandbox environment for testing. No real money is moved. Switch to production when you're ready to go live.
Payment Methods
Step 1 — required before any payment
Always call GET /v1/payment-methods to build the payment screen shown to your customer. Never hardcode the operator/country list: it depends on your enabled countries and on real-time maintenance. Showing an unavailable (or non-enabled) operator means a payment doomed to fail.
This endpoint returns a response specific to your merchant: only your enabled countries and their operators are returned, and each operator carries an isAvailable flag that accounts for ongoing maintenance (operator or gateway). It is the source of truth for showing your customer only what they can actually use. The countries table below is only an indicative overview — GET /v1/payment-methods is authoritative.
Authentication: public key only
Unlike payments/transfers (which require the pk:sk pair), this read-only endpoint authenticates with your public key alone — safe to call from a lightweight back-end.
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Required | Bearer {publicKey} — your pk_… alone (no :sk_) |
Content-Type | Optional | application/json |
cURL curl https://prod.afrigate.dev/v1/payment-methods \
-H "Authorization: Bearer pk_live_mZWbtIV-ll-_0tNSSAxXV4fW"
Response 200 OK
JSON {
"data": {
"countries": [
{
"countryCode": "CI",
"countryName": "Cote d'Ivoire",
"flag": "https://d37zkt40qskmxk.cloudfront.net/flags/ci.svg",
"currency": "XOF",
"paymentMethods": [
{
"name": "Wave",
"category": "mobile_money",
"logo": "https://d37zkt40qskmxk.cloudfront.net/operators/wave.png",
"isAvailable": true
},
{
"name": "Orange Money",
"category": "mobile_money",
"logo": "https://d37zkt40qskmxk.cloudfront.net/operators/orange.png",
"isAvailable": true
},
{
"name": "MTN Mobile Money",
"category": "mobile_money",
"logo": "https://d37zkt40qskmxk.cloudfront.net/operators/mtn.png",
"isAvailable": false
}
]
}
]
}
}
Fields
Field Description countries[]One object per country enabled on your account countries[].countryCodeISO 3166-1 alpha-2 country code (uppercase) countries[].countryNameCountry name (may be null) countries[].flagFlag URL (may be null) countries[].currencyISO 4217 currency of the country (XOF, XAF, …) paymentMethods[].nameDisplay name of the operator (e.g. Wave, Orange Money) paymentMethods[].categorymobile_money, card or fintech_wallet paymentMethods[].logoOperator logo URL (may be null) paymentMethods[].isAvailablefalse if the operator (or the gateway serving it) is in active maintenance — hide/grey it out
isAvailable: false is temporary (maintenance). Don't remove the operator from your UI, just grey it out. The display name is not the code to send in operator — see the operators table for the unified code (wave, orange, momo, …).
Countries & Operators
AfriGate is connected in the countries below. For each payment/transfer you send country (ISO 3166-1 alpha-2) + operator (unified code) + currency (the country's currency); AfriGate routes automatically to the right PSP. The up-to-date list specific to your account (enabled countries + real-time availability) is always given by GET /v1/payment-methods.
| Country | Code | Currency | Operators |
|---|---|---|---|
| Benin | BJ | XOF | Moov, MTN |
| Botswana | BW | BWP | Voucher |
| Cameroon | CM | XAF | MTN, Orange Money |
| Ivory Coast | CI | XOF | Orange Money, Wave, Moov, MTN |
| Gambia | GM | GMD | Wave, Afrimoney, Qmoney |
| Ghana | GH | GHS | MTN, Vodafone, AirtelTigo |
| Kenya | KE | KES | M-Pesa, Airtel |
| Liberia | LR | LRD | MTN, Orange Money, Lonestar |
| Nigeria | NG | NGN | Opay, Palmpay |
| Uganda | UG | UGX | MTN, Airtel |
| Senegal | SN | XOF | Orange Money, Wave, Free Money |
| Sierra Leone | SL | SLE | Afrimoney, Orange Money |
| Tanzania | TZ | TZS | Halopesa, M-Pesa, Tigo, Airtel |
The currency sent must be the country's currency — e.g. BWP for Botswana, XOF for Senegal/Ivory Coast/Benin, XAF for Cameroon. The operator name above is the display name; the lowercase code for operator (wave, orange, momo, moov) is in the operators table. Zero-decimal currencies (XOF, XAF, GNF, BIF, RWF, KMF, DJF) take integer amounts only — fees and net amounts are also rounded to the whole unit; other currencies keep 2 decimals.
Per-operator specifics
How the customer authorizes the payment changes per operator. The channel field (and sometimes an extra field) drives this flow — detailed in Payment Flows. Main cases:
| Operator / country | Flow | What YOU must do |
|---|---|---|
| Wave — SN, GM, CI, SL | Redirect | channel: "REDIRECT" → redirect the customer to redirectUrl (https://pay.afrigate.dev/{token}) |
| Orange Money — CI | OTP direct | Customer dials #144*82#, gives you the code → send it in otp with channel: "OTP" |
| Orange Money — SN | Redirect | channel: "REDIRECT" (enforced) |
| Botswana (Voucher) | Voucher | Customer buys a voucher and gives you its PIN → send it in metadata.voucherPin |
| Opay / Palmpay — NG | Redirect | channel: "REDIRECT" → redirect to redirectUrl |
| MTN (MoMo), Moov, M-Pesa, Tigo Pesa, Halopesa, Airtel | Push / STK | channel: "PUSH" (default) → customer approves the prompt with their PIN |
Payments Payments (Collect)
Initiate a fund collection from a customer's mobile money account to your merchant account.
Create a Payment
POST /v1/payments Required Headers
Header Required Description AuthorizationRequired Bearer {keyId}:{secret} — see Authentication X-Idempotency-KeyRequired Unique UUID to prevent duplicates Content-TypeRequired application/json
Request Body
JSON {
"amount": 5000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "orange",
"country": "CI",
"customer": {
"phone": "+2250700000000",
"name": "Jean Kouassi",
"email": "jean@example.com"
},
"successUrl": "https://mysite.com/payment/success",
"failedUrl": "https://mysite.com/payment/failed",
"callbackUrl": "https://mysite.com/webhooks/afrigate",
"merchantTransactionId": "ORDER-12345",
"feeBearer": "merchant",
"channel": "PUSH",
"designation": "Online purchase",
"description": "Order #12345",
"metadata": {
"orderId": "12345",
"customField": "value"
}
}
Fields
amount number RequiredAmount in the currency's minor unit (minimum 1). For zero-decimal currencies (XOF, XAF, GNF, BIF, RWF, KMF, DJF) the amount must be an integer (no decimals) — send 5000, not 5000.00. Other currencies (GHS, KES, NGN, BWP, TZS, UGX…) use 2 decimals.
currency string RequiredISO 4217 currency code (e.g. XOF, XAF, GHS)
paymentMethod string RequiredPayment method (e.g. MOBILE_MONEY)
operator string RequiredUnified operator code. See operators table
country string RequiredISO 3166-1 alpha-2 country code (e.g. CI, SN, GH)
customer.phone string RequiredCustomer phone number. Validated per country (dialing code + exact length); international E.164 (+225…, recommended) or national format accepted. Must match the country. See Phone Number Format
customer.name string OptionalCustomer name
customer.email string OptionalCustomer email
successUrl string RequiredRedirect URL after successful payment
failedUrl string RequiredRedirect URL after failure
callbackUrl string RequiredWebhook receiving URL
merchantTransactionId string OptionalYour internal reference
feeBearer string OptionalWho pays fees: merchant (default) or customer
channel string OptionalPUSH (default), OTP, USSD, QRCODE, REDIRECT, DIRECT. See Payment Flows
otp string OptionalAuthorization / OTP code the customer generates with their operator (e.g. Orange Money via USSD). Send it with channel: "OTP" to authorize the payment directly, without a redirect page (max 20 chars). See Payment Flows
metadata object OptionalAdditional data returned in webhooks. Also carries the voucher PIN for Botswana prepaid-voucher payments: metadata.voucherPin — see Payment Flows
Response 201 Created
JSON {
"success": true,
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"token": "pay_xK9mN2pQ",
"merchantId": "m_abc123",
"amount": 5000,
"currency": "XOF",
"feeAmount": 150,
"netAmount": 4850,
"status": "initiated",
"paymentMethod": "MOBILE_MONEY",
"operator": "orange",
"country": "CI",
"redirectUrl": "https://pay.afrigate.dev/a1b2c3...",
"expiresAt": "2024-01-15T15:30:00.000Z",
"createdAt": "2024-01-15T15:00:00.000Z"
}
}
Redirect URL
The redirectUrl field is only present with channel: REDIRECT (redirect operators — Wave, Orange Money SN, Opay, Palmpay). It always equals https://pay.afrigate.dev/{token} (AfriGate-hosted page). Always redirect the customer to this URL: the AfriGate page then forwards them to the operator's page automatically. For other channels (PUSH, OTP, …) this field is absent. See Payment Flows.
Get a Payment
GET/v1/payments/{token} Returns the same structure as the creation response.
List Payments
GET/v1/payments Parameter Type Description statusstring Filter by status fromstring Start date (ISO 8601) tostring End date (ISO 8601) limitnumber Number of results (default: 20) offsetnumber Offset for pagination
Cancel a Payment
POST/v1/payments/{token}/cancel JSON { "reason": "Customer changed their mind" }
Only payments with initiated or pending status can be cancelled.
Payment Lifecycle
State Machine initiated --> pending --> processing --> success
--> failed
--> expired
--> cancelled
success --> refunded (full refund)
--> partially_refunded (partial refund)
Status Description Terminal initiatedPayment created, awaiting processing No pendingBeing routed to the operator No processingOperator is processing the transaction No successPayment successful Yes failedPayment failed Yes cancelledCancelled by the merchant Yes expiredTimeout exceeded (15 min for REDIRECT flows, 30 min for push/momo flows) Yes refundedFully refunded Yes partially_refundedPartially refunded Yes
Payments Operators & Payment Flows
How the customer authorizes a payment depends on the operator (operator) and country (country). The channel field drives this flow. There are four flows, and sending the wrong channel for an operator can silently fail the transaction at some PSPs.
Golden rule: you never have to choose the PSP (Flutterwave, Wave, Payaza, 54pay…). You only send operator + country; AfriGate routes to the right connector. You only choose the channel per the flows below.
Flow 1 — Redirect (Wave, Orange Money SN, Opay, Palmpay)
The customer is redirected to a payment page where they authorize the debit with their operator.
- Send
channel: "REDIRECT". - The
201 response contains redirectUrl = https://pay.afrigate.dev/{token} (AfriGate-hosted page). Always redirect the customer to this URL; the AfriGate page then forwards them to the operator. You never handle the raw operator URL. - Nothing to collect (no OTP), nothing pushed to the phone.
- The final result arrives via the webhook (
payment.completed); you can also poll GET /v1/payments/{token}.
Operator operatorcountryNote Wave waveSN, CI, GM, SLREDIRECT enforced Orange Money Senegal orangeSNREDIRECT enforced Opay opayNGREDIRECT enforced Palmpay palmpayNGREDIRECT enforced
For all redirect operators, REDIRECT is the only supported and enforced channel. Any other channel is rejected with 400 (Operator '<x>' only supports channel REDIRECT).
JSON — Wave Senegal POST /v1/payments
{
"amount": 5000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "wave",
"country": "SN",
"channel": "REDIRECT",
"customer": { "phone": "+221770000000", "name": "Awa Diop" },
"successUrl": "https://mysite.com/ok",
"failedUrl": "https://mysite.com/ko",
"callbackUrl": "https://mysite.com/webhooks/afrigate",
"merchantTransactionId": "ORDER-12345"
}
Flow 2 — OTP direct (Orange Money CI)
The customer generates a code with their operator, you collect it on your interface and send it in the otp field with channel: "OTP". The payment is authorized directly, with no redirect.
Customer steps — Orange Money Ivory Coast:
- The customer dials on their phone:
#144*82#. - They receive a payment code (OTP) by SMS.
- They give you this code on your interface (payment page, app, POS…).
- You call
POST /v1/payments with channel: "OTP" and otp: "<code>".
otp field: string, 20 chars max. - No
redirectUrl is returned. The final result arrives via the webhook.
JSON — Orange Money CI POST /v1/payments
{
"amount": 5000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "orange",
"country": "CI",
"channel": "OTP",
"otp": "123456",
"customer": { "phone": "+2250700000000", "name": "Jean Kouassi" },
"successUrl": "https://mysite.com/ok",
"failedUrl": "https://mysite.com/ko",
"callbackUrl": "https://mysite.com/webhooks/afrigate"
}
If you can't collect the OTP (e.g. a no-interaction flow), Orange CI also accepts channel: "REDIRECT" as a fallback.
Flow 3 — Push / STK (MTN MoMo, Moov, …)
A validation prompt is pushed to the customer's phone; they approve it with their PIN. Nothing to redirect, nothing to collect.
- Send
channel: "PUSH" (the default if channel is omitted). - The final result arrives via the webhook.
JSON — MTN Mobile Money CI POST /v1/payments
{
"amount": 5000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "momo",
"country": "CI",
"channel": "PUSH",
"customer": { "phone": "+2250500000000", "name": "Ama Kone" },
"successUrl": "https://mysite.com/ok",
"failedUrl": "https://mysite.com/ko",
"callbackUrl": "https://mysite.com/webhooks/afrigate"
}
Flow 4 — Voucher / prepaid (Botswana)
The customer buys a voucher (prepaid token) from a point of sale or an app, gets a PIN, and gives it to you. You send this PIN in the metadata object (metadata.voucherPin). There is no redirect and no push: the PIN alone authorizes the debit.
- Botswana (
country: "BW", currency BWP): PIN in metadata.voucherPin. - The final result arrives via the webhook (
payment.completed); you can also poll GET /v1/payments/{token}. No redirectUrl is returned.
JSON — Botswana voucher POST /v1/payments
{
"amount": 100,
"currency": "BWP",
"paymentMethod": "MOBILE_MONEY",
"operator": "voucher",
"country": "BW",
"customer": { "phone": "+26771000000", "name": "Kgomotso M." },
"successUrl": "https://mysite.com/ok",
"failedUrl": "https://mysite.com/ko",
"callbackUrl": "https://mysite.com/webhooks/afrigate",
"metadata": { "voucherPin": "12345678" }
}
Summary
Operator operatorcountryFlow channelThe customer… Wave waveSN, CI, GM, SLRedirect REDIRECT (enforced in all countries)is redirected to redirectUrl Orange Money orangeSNRedirect REDIRECT (enforced)is redirected to redirectUrl Opay opayNGRedirect REDIRECT (enforced)is redirected to redirectUrl Palmpay palmpayNGRedirect REDIRECTis redirected to redirectUrl Orange Money orangeCIOTP direct OTP + otp fielddials #144*82#, gives you the code MTN MoMo momoCI, …Push PUSH (default)approves the prompt with their PIN Moov moovCI, BJPush PUSH (default)approves the prompt with their PIN Voucher voucherBWVoucher — (PIN via metadata.voucherPin) buys a voucher, gives you the PIN
When in doubt, first query GET /v1/payment-methods to learn which operators are active/available, then apply the channel from the table above. The wrong channel can fail the payment without a clear message (except REDIRECT-only operators, which return an explicit 400).
Disbursement Transfers
Send funds from your merchant account to a recipient's mobile money account.
Create a Transfer
POST/v1/transfers JSON {
"amount": 10000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "momo",
"country": "CI",
"recipient": {
"phone": "+2250700000000",
"name": "Awa Traore",
"email": "awa@example.com"
},
"callbackUrl": "https://mysite.com/webhooks/afrigate",
"merchantTransactionId": "TRANSFER-789",
"designation": "Supplier payment",
"metadata": { "invoiceId": "789" }
}
Fields
amount number RequiredAmount (minimum 1). Zero-decimal currencies (XOF, XAF, GNF, BIF, RWF, KMF, DJF) must be an integer; other currencies use 2 decimals.
currency string RequiredISO 4217 currency code
operator string RequiredUnified operator code. See operators table
country string RequiredISO 3166-1 alpha-2 country code
recipient.phone string RequiredRecipient phone number. Validated per country (dialing code + exact length); E.164 (+225…) or national format accepted. Must match the country. See Phone Number Format
recipient.name string OptionalRecipient name
callbackUrl string OptionalWebhook URL
metadata object OptionalAdditional data
Transfer Lifecycle
Status Description Terminal initiatedTransfer created No pendingBeing routed No processingOperator is processing No successFunds sent to recipient Yes failedTransfer failed Yes expiredTimeout exceeded (10 min default) Yes
Refunds
Refund all or part of a successful payment.
POST/v1/payments/{paymentToken}/refund JSON {
"amount": 2500,
"currency": "XOF",
"refundType": "partial",
"reason": "Product returned"
}
amount number RequiredAmount to refund
currency string RequiredCurrency (must match the payment)
refundType string Optionalfull or partial (auto-detected if omitted)
reason string OptionalRefund reason (max 500 characters)
Refund rules
- Only
success payments can be refunded - Amount cannot exceed remaining refundable amount
- Full refund sets status to
refunded - Partial refund sets status to
partially_refunded
List Refunds
GET/v1/payments/{paymentToken}/refunds Checkout Session
The checkout is a payment page hosted by AfriGate. Use it to offer a turnkey payment experience.
Get Session
GET/v1/checkout/{token} Check Status
GET/v1/checkout/{token}/status Status Description pendingWaiting for customer action processingPayment in progress redirectCustomer must be redirected (operatorRedirectUrl present) successPayment successful (successUrl present) failedPayment failed (failedUrl present) expiredSession expired
Integration Webhooks
Webhooks notify you in real-time of status changes on your transactions.
Events
Event Trigger payment.completedPayment reaches a terminal status (success, failed, cancelled, expired) or is refunded (refunded, partially_refunded) transfer.completedTransfer reaches a terminal status
Refunds don't trigger a separate event: they reuse payment.completed with the payment's new status (refunded / partially_refunded).
Payload Format
JSON {
"event": "payment.completed",
"data": {
"token": "pay_xK9mN2pQ",
"merchantId": "m_abc123",
"amount": "5000",
"currency": "XOF",
"status": "success",
"completedAt": "2024-01-15T15:05:00.000Z"
},
"timestamp": "2024-01-15T15:05:01.000Z"
}
Refund Payload
A refund does not emit its own event: it reuses payment.completed with the payment's new status — refunded (full) or partially_refunded (partial). The token is the payment's token (not the refund's), and the data object carries extra refund fields:
JSON {
"event": "payment.completed",
"data": {
"token": "pay_xK9mN2pQ",
"merchantId": "m_abc123",
"status": "partially_refunded",
"refundId": "ref_a1b2c3d4",
"paymentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"amount": "2500",
"currency": "XOF",
"refundType": "partial"
},
"timestamp": "2024-01-15T16:00:00.000Z"
}
Webhook Headers
Header Description X-Webhook-Request-IdUnique delivery identifier (UUID) X-Webhook-TimestampTimestamp in milliseconds (epoch) X-Webhook-SignatureHMAC-SHA256 signature
Signature Verification
Node.js const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, timestamp, secret) {
const content = `${timestamp}.${JSON.stringify(payload)}`;
const expected = crypto
.createHmac('sha256', secret)
.update(content)
.digest('hex');
const receivedSig = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSig)
);
}
app.post('/webhooks/afrigate', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Verify timestamp is recent (< 5 minutes)
const age = Date.now() - parseInt(timestamp);
if (age > 5 * 60 * 1000) {
return res.status(400).json({ error: 'Timestamp too old' });
}
if (!verifyWebhookSignature(req.body, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, data } = req.body;
console.log(`Event: ${event}, Status: ${data.status}`);
res.status(200).json({ received: true });
});
Python import hmac, hashlib, time, json
def verify_webhook(payload, signature, timestamp, secret):
age = int(time.time() * 1000) - int(timestamp)
if age > 5 * 60 * 1000:
return False
content = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
expected = hmac.new(
secret.encode(), content.encode(), hashlib.sha256
).hexdigest()
received = signature.replace("sha256=", "")
return hmac.compare_digest(expected, received)
Retry Policy
Each webhook is delivered with one initial POST plus up to 5 retries (6 attempts max). The request timeout per attempt is 10 seconds. Retries use exponential backoff (2, 4, 8, 16, 32 seconds):
Attempt Delay before attempt Cumulative 1 (initial) Immediate 0s 2 (retry 1) 2s 2s 3 (retry 2) 4s 6s 4 (retry 3) 8s 14s 5 (retry 4) 16s 30s 6 (retry 5) 32s 62s
Not every failure is retried
Only transient failures are retried: 5xx responses, connection timeouts (status 0), and 408 / 425 / 429. Permanent 4xx responses (400, 401, 403, 404, 422) are not retried — the delivery is marked FAILED immediately. Return 2xx to acknowledge; return a 5xx only if you want AfriGate to retry.
Best Practices
- Respond
200 OK immediately, process asynchronously - Use
X-Webhook-Request-Id to deduplicate - Always verify the signature
- Reject webhooks older than 5 minutes
- Confirm status via
GET /v1/payments/{token}
Idempotency
To prevent duplicate transactions, include a unique X-Idempotency-Key header.
Header X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Scenario Behavior New key Transaction created normally Existing key Original response returned (no duplicate) Key TTL 24 hours
Required for: POST /v1/payments and POST /v1/transfers
Rate Limiting
Requests are rate-limited per merchant over a fixed 60-second window. The limit differs per environment:
Environment Limit Window Production 2,500 requests 60 seconds Sandbox 500 requests 60 seconds
Response Headers
Header Description X-RateLimit-LimitMax requests in the window (2500 in production, 500 in sandbox) X-RateLimit-RemainingRemaining requests in the current window X-RateLimit-ResetUnix timestamp at which the window resets — use this to back off
There is no HTTP Retry-After header — use X-RateLimit-Reset to decide when to retry. In the 429 body, retry_after is the window length (60 seconds), not a live countdown.
Exceeded 429
JSON {
"success": false,
"data": null,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded",
"details": { "limit": 2500, "window": 60, "retry_after": 60 }
},
"meta": {
"request_id": "req_...",
"timestamp": "2024-01-15T15:00:00.000Z"
}
}
Integration Sandbox Testing
The sandbox environment (https://sandbox.afrigate.dev) accepts test phone numbers that simulate a successful or failed payment without touching the real mobile money operator. No real funds move, and no user action is required.
Sandbox Starting Balance
When your merchant account is created, your sandbox wallet is credited with 50,000 units in your country's currency (e.g. 50,000 XOF for an Ivory Coast merchant, 50,000 NGN for a Nigeria merchant) so you can test transfers (disbursements) immediately, without making a prior deposit. This simulated balance is decremented on each successful simulated transfer and has no impact in production.
Test Numbers by Country
To trigger a simulated result, use these numbers in customer.phone (payment) or recipient.phone (transfer), with the matching country.
Country Code Success number Failed number Senegal SN+221700000001+221700000002 Ivory Coast CI+225700000001+225700000002 Benin BJ+229700000001+229700000002 Nigeria NG+234700000001+234700000002 Ghana GH+233700000001+233700000002 Botswana BW+267700000001+267700000002 Cameroon CM+237700000001+237700000002 Gambia GM+220700000001+220700000002 Kenya KE+254700000001+254700000002 Liberia LR+231700000001+231700000002 Sierra Leone SL+232700000001+232700000002 Tanzania TZ+255700000001+255700000002 Uganda UG+256700000001+256700000002
Ready-to-use Request (per country)
For each country: a representative operator, the matching channel, the currency, and the success number for customer.phone. Swap in the failed number (…002) to simulate a failure. The operator can be any available for the country (see GET /v1/payment-methods).
Country countrycurrencyoperator (ex.)channelcustomer.phone (success) Senegal SNXOFwaveREDIRECT+221700000001 Ivory Coast CIXOFmomoPUSH+225700000001 Benin BJXOFmoovPUSH+229700000001 Nigeria NGNGNopayREDIRECT+234700000001 Ghana GHGHSmomoPUSH+233700000001 Botswana BWBWPvoucherPUSH *+267700000001 Cameroon CMXAFmomoPUSH+237700000001 Gambia GMGMDwaveREDIRECT+220700000001 Kenya KEKESmpesaPUSH+254700000001 Liberia LRLRDmomoPUSH+231700000001 Sierra Leone SLSLEorangePUSH+232700000001 Tanzania TZTZSmpesaPUSH+255700000001 Uganda UGUGXmomoPUSH+256700000001
In sandbox the simulator only looks at country + customer.phone: it returns success/failed after ~5s regardless of the operator. The channel must still be consistent (REDIRECT operators like wave SN / opay reject another channel with 400).
* Botswana (voucher): in sandbox no PIN is required (the simulator ignores it). In production, send the PIN in metadata.voucherPin — see Payment Flows.
The number and the country must match exactly. Any other number is treated as a real call to the sandbox operator.
Example: Successful Payment
cURL curl -X POST https://sandbox.afrigate.dev/v1/payments \
-H "Authorization: Bearer pk_test_xxxxxxxxxxxxxxxxxxxxxxxx:sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XOF",
"paymentMethod": "MOBILE_MONEY",
"operator": "wave",
"country": "SN",
"customer": {
"phone": "+221700000001",
"name": "Test Success"
},
"successUrl": "https://mysite.com/payment/success",
"failedUrl": "https://mysite.com/payment/failed",
"callbackUrl": "https://mysite.com/webhooks/afrigate",
"merchantTransactionId": "TEST-SUCCESS-001"
}'
Returns 201 Created, identical to a real payment (status: "pending"). After about 5 seconds, your callbackUrl receives the webhook:
JSON {
"event": "payment.completed",
"data": {
"token": "pay_xK9mN2pQ",
"merchantId": "m_abc123",
"amount": "5000",
"currency": "XOF",
"status": "success",
"completedAt": "2026-05-03T15:00:05.000Z"
},
"timestamp": "2026-05-03T15:00:06.000Z"
}
GET /v1/payments/{token} then returns status: "success".
Example: Failed Payment
Same parameters with the country's failed number (+221700000002 for SN). The webhook received after ~5s:
JSON {
"event": "payment.completed",
"data": {
"token": "pay_xK9mN2pQ",
"amount": "5000",
"currency": "XOF",
"status": "failed",
"completedAt": "2026-05-03T15:00:05.000Z"
},
"timestamp": "2026-05-03T15:00:06.000Z"
}
Transfers
Same numbers for recipient.phone. The transfer.completed webhook arrives after ~5s with status: "success" or status: "failed".
Refunds
Refunds are synchronous and don't go through an operator: there is no test number and no delay. To test, create a successful payment (success number above), wait for status: "success", then call POST /v1/payments/{token}/refund. The payment moves immediately to refunded (full) or partially_refunded (partial), and a payment.completed webhook is emitted. This works identically for all countries.
REDIRECT Channel
With channel: "REDIRECT", the simulator does not generate a redirect URL. The checkout session moves directly from pending to success or failed after ~5s. A front-end polling GET /v1/checkout/{token} will receive:
JSON {
"token": "pay_xK9mN2pQ",
"status": "success",
"successUrl": "https://mysite.com/payment/success"
}
Limitations
- Sandbox only. In production these numbers have no special effect.
- No user-side action. No SMS, no payment page. To test the real user experience (Wave, Orange OTP, etc.), use your sandbox PSP credentials with a different number.
- Fixed 5-second delay between init and webhook. Real operators range from a few seconds to several minutes.
Reference Error Codes
Error Format
AfriGate returns two different error shapes depending on where the error is raised. Check both when parsing errors.
1. Gateway errors — auth, rate limit, timeout, service unavailable
Raised by the gateway itself. error is an object with a stable machine code (uppercase), a message, and optional details, wrapped in the standard envelope:
JSON {
"success": false,
"data": null,
"error": {
"code": "INVALID_API_KEY",
"message": "Invalid or expired API key",
"details": {}
},
"meta": {
"request_id": "req_...",
"timestamp": "2024-01-15T15:00:00.000Z"
}
}
2. Business errors — payments, transfers, refunds
Raised by the transaction service and proxied through unchanged. This is the NestJS format — no success, no error object, no details, just an HTTP status and a plain-text message:
JSON {
"statusCode": 400,
"message": "Merchant not active",
"error": "Bad Request"
}
A stable machine code is only reliable for gateway errors. Business errors carry only an HTTP status and a human-readable message (the two structured codes invalid_phone and blocked_number aside) — branch on the HTTP status, not on parsing the message string.
Gateway Errors
Uppercase machine codes returned in the error.code field of the gateway envelope above.
Code HTTP Description MISSING_AUTH401 Missing Authorization header INVALID_API_KEY401 Invalid or expired API key IP_NOT_ALLOWED403 Request IP not in the merchant's allow-list PERMISSION_DENIED403 Key lacks the required permission (defined but not currently enforced) RATE_LIMIT_EXCEEDED429 Rate limit exceeded GATEWAY_TIMEOUT504 Upstream service did not respond in time SERVICE_UNAVAILABLE503 Upstream service temporarily unavailable INTERNAL_SERVER_ERROR500 Unexpected internal error
The errors below are business errors (see format #2). With the exception of invalid_phone and blocked_number (structured codes), they carry no machine code — only an HTTP status and the exact message text shown below. Branch on the HTTP status; the message string is informational and may change.
Payment Errors - POST /v1/payments
HTTP Message / code Meaning 400 Merchant not activeMerchant account is not active 400 blocked_number (structured code)The customer number (customer.phone) is blocked 400 invalid_phone (structured code)customer.phone is not a valid number for the country (wrong dialing code or length) — see Phone Number Format 400 Operator '<x>' only supports channel REDIRECTA non-REDIRECT channel was sent for a REDIRECT-only operator (Wave, Orange Money SN, Opay, Palmpay) 404 Payment not foundPayment token not found
Cancel Errors - POST /v1/payments/{token}/cancel
HTTP Message / code Meaning 404 Payment not foundPayment token not found 400 Wrong merchantThe payment doesn't belong to this merchant 400 Cannot cancelThe payment is in a terminal status (success, failed, cancelled, expired)
Transfer Errors - POST /v1/transfers
HTTP Message / code Meaning 400 Not activeMerchant account is not active 400 blocked_number (structured code)The recipient number (recipient.phone) is blocked 400 invalid_phone (structured code)recipient.phone is not a valid number for the country (wrong dialing code or length) — see Phone Number Format 400 Exceeds single limitAmount exceeds the per-transaction limit 400 Exceeds daily limitDaily cumulative limit exceeded 400 Exceeds monthly limitMonthly cumulative limit exceeded 400 Count limit reachedMax number of daily transfers reached 404 Not foundTransfer token not found
Refund Errors - POST /v1/payments/{token}/refund
HTTP Message / code Meaning 404 Payment not foundPayment token not found 400 Wrong merchantThe payment doesn't belong to this merchant 400 Cannot refund payment in status "<x>"Only success (or partially_refunded) payments can be refunded; <x> is the current status 400 Max refundable: <n> <currency>Amount exceeds the remaining refundable amount (<n>)
Operators by Country
The operator field is required. Use the unified code (lowercase) for the target country and operator. The combination of operator + country determines the exact provider.
Ivory Coast CI - XOF
Code Operator Flow Channel waveWave REDIRECT REDIRECT orangeOrange Money OTP direct OTP (+ otp) — or REDIRECT momoMTN Mobile Money PUSH PUSH moovMoov Money REDIRECT REDIRECT (default)
Senegal SN - XOF
Code Operator Flow Channel orangeOrange Money REDIRECT REDIRECT (enforced) waveWave REDIRECT REDIRECT (enforced) freeFree Money OTP OTP (+ otp)
Nigeria NG - NGN
Code Operator Flow Channel opayOpay REDIRECT REDIRECT (enforced) palmpayPalmpay REDIRECT REDIRECT
Ghana GH - GHS
Code Operator Flow Channel momoMTN Mobile Money OTP / USSD OTP / REDIRECT vodafoneVodafone Cash OTP / USSD OTP / REDIRECT airteltigoAirtelTigo OTP / USSD OTP / REDIRECT
Botswana BW - BWP
Code Operator Flow Channel voucherVoucher (prepaid) VOUCHER PIN in metadata.voucherPin
Kenya KE - KES
Code Operator Flow Channel mpesa (alias safaricom)M-Pesa (Safaricom) PUSH PUSH airtelAirtel Money PUSH PUSH
Tanzania TZ - TZS
Code Operator Flow Channel mpesa (alias vodacom)M-Pesa (Vodacom) PUSH PUSH tigo (alias tigopesa)Tigo Pesa PUSH PUSH halopesa (alias halotel)Halopesa PUSH PUSH airtelAirtel Money PUSH PUSH
Uganda UG - UGX
Code Operator Flow Channel momo (alias mtn)MTN Mobile Money PUSH PUSH airtelAirtel Money PUSH PUSH
Other countries (Benin, Cameroon, Gambia, Liberia, Sierra Leone): see the countries table. Classic Mobile Money operators (MTN momo, Moov moov, Orange orange…) use the Push flow (channel: "PUSH", default); Wave uses Redirect (enforced). The up-to-date list for your account is given by GET /v1/payment-methods.
Unified Code Legend
Code Operator momo (alias mtn)MTN Mobile Money orange (alias om)Orange Money moovMoov Money waveWave freeFree Money (Tigo) vodafoneVodafone Cash (Ghana) airteltigoAirtelTigo (Ghana) mpesa (alias safaricom)M-Pesa (Kenya; Tanzania via Vodacom) airtelAirtel Money (Kenya, Uganda, Tanzania) tigo (alias tigopesa)Tigo Pesa (Tanzania) halopesa (alias halotel)Halopesa (Tanzania) opayOpay (Nigeria) palmpayPalmpay (Nigeria) voucherVoucher — prepaid (Botswana)
The unified code is the same regardless of country. For example, orange means Orange Money in both Ivory Coast and Senegal. It's the operator + country combination that determines the exact operator.
Payment Channels
Channel Description PUSHOperator pushes a validation prompt to the customer who approves it on their phone (default) OTPCustomer generates a code with their operator (USSD) and you send it in the otp field → the payment is authorized directly, no redirect page. See Payment Flows USSDCustomer dials a USSD code manually QRCODEPayment via QR code scan REDIRECTThe creation response contains redirectUrl = https://pay.afrigate.dev/{token}. Always redirect the customer to this AfriGate page; it then forwards them to the operator (Wave, Orange SN, Opay, Palmpay) DIRECTDirect debit (per operator agreements)
Limits & Timeouts
Expiration Timeouts
Type Default Payment — REDIRECT flows 15 minutes Payment — push / momo flows 30 minutes Transfer 10 minutes
Constraints
Constraint Value Minimum amount 1 currency unit Currency length 3 characters Country length 2 characters Cancel/refund reason 500 characters max Idempotency TTL 24 hours Webhook attempts 6 max (1 initial + 5 retries) Webhook timestamp tolerance 5 minutes
Reference Phone Number Format
customer.phone (payment) and recipient.phone (transfer) are validated per country on creation: the number must be a valid mobile number for the request's country (dialing code + exact length). You can send the international E.164 format (+225…, recommended) or the national format (01…) — either way the number must belong to the country (a +233 number sent with country: "CI" is rejected). Otherwise: 400 invalid_phone.
The table below shows the dialing code and the number of national digits (the part after the dialing code) expected per country:
Country Code Dialing code Digits (national) Example (E.164) Benin BJ+229 10 +2290195123456 Botswana BW+267 8 +26771123456 Cameroon CM+237 9 +237671234567 Ivory Coast CI+225 10 +2250123456789 Gambia GM+220 7 +2203012345 Ghana GH+233 9 +233231234567 Kenya KE+254 9 +254712123456 Liberia LR+231 9 +231770123456 Nigeria NG+234 10 +2348021234567 Senegal SN+221 9 +221701234567 Sierra Leone SL+232 8 +23225123456 Tanzania TZ+255 9 +255621234567 Uganda UG+256 9 +256712345678
Validation relies on each country's official numbering rules (lengths and mobile prefixes) — a number that is too short/long or has the wrong dialing code is refused before any debit or routing, avoiding a silent failure on the operator side.