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 sandbox balance included |
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 |
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 |
| Nigeria | NG | NGN | Opay, Palmpay |
| Uganda | UG | UGX | MTN, Airtel |
| Senegal | SN | XOF | Orange Money, Wave, Mixx |
| 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.
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 currency units (minimum 1)
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 (international 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 (30 min default) 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, CIRedirect REDIRECT (enforced in SN)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)
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
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",
"failureCode": null,
"failureMessage": null,
"completedAt": "2024-01-15T15:05:00.000Z"
},
"timestamp": "2024-01-15T15:05:01.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
Attempt Delay Cumulative 1 Immediate 0s 2 2s 2s 3 4s 6s 4 8s 14s 5 16s 30s
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: 60 requests per 60 seconds.
Response Headers
Header Description X-RateLimit-LimitMax requests in the window X-RateLimit-RemainingRemaining requests X-RateLimit-ResetUnix timestamp of next window
Exceeded 429
JSON {
"error": "rate_limit_exceeded",
"details": { "limit": 60, "window": 60, "retry_after": 45 }
}
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 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",
"failureCode": null,
"failureMessage": null,
"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",
"failureCode": "SIMULATED_FAILURE",
"failureMessage": "Simulated failure outcome",
"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
JSON {
"success": false,
"error": "error_code",
"message": "Human-readable description",
"details": {}
}
General Errors
Code HTTP Description missing_auth401 Missing Authorization header invalid_api_key401 Invalid or expired API key rate_limit_exceeded429 Rate limit exceeded merchant_not_active400 Inactive merchant account blocked_number400 Phone number is blocked internal_server_error500 Internal error service_unavailable503 Temporarily unavailable
Payment Errors - POST /v1/payments
Code HTTP Description merchant_not_active400 Inactive merchant account blocked_number400 The customer number (customer.phone) is blocked payment_not_found404 Payment token not found
Cancel Errors - POST /v1/payments/{token}/cancel
Code HTTP Description payment_not_found404 Payment token not found wrong_merchant400 The payment doesn't belong to this merchant cannot_cancel400 Cannot cancel — the payment is in a terminal status (success, failed, cancelled, expired)
Transfer Errors - POST /v1/transfers
Code HTTP Description merchant_not_active400 Inactive merchant account blocked_number400 The recipient number (recipient.phone) is blocked exceeds_single_limit400 Amount exceeds per-transaction limit exceeds_daily_limit400 Daily cumulative exceeded exceeds_monthly_limit400 Monthly cumulative exceeded count_limit_reached400 Max daily transfers reached transfer_not_found404 Transfer token not found
Refund Errors - POST /v1/payments/{token}/refund
Code HTTP Description payment_not_found404 Payment token not found wrong_merchant400 The payment doesn't belong to this merchant cannot_refund400 Only success payments can be refunded exceeds_refund_amount400 Exceeds remaining refundable amount
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 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 5 Webhook timestamp tolerance 5 minutes