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
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.
Payments (Collect)
Initiate a fund collection from a customer's mobile money account to your merchant account.
Create a Payment
Required Headers
| Header | Required | Description |
|---|---|---|
Authorization | Required | Bearer {keyId}:{secret} — see Authentication |
X-Idempotency-Key | Required | Unique UUID to prevent duplicates |
Content-Type | Required | application/json |
Request Body
{
"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
metadata object OptionalAdditional data returned in webhooks
Response 201 Created
{
"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 for operators with a REDIRECT flow (e.g. wave in CI/SN, orange in SN). For OTP/PUSH operators, this field is absent.
Get a Payment
Returns the same structure as the creation response.
List Payments
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status |
from | string | Start date (ISO 8601) |
to | string | End date (ISO 8601) |
limit | number | Number of results (default: 20) |
offset | number | Offset for pagination |
Cancel a Payment
{ "reason": "Customer changed their mind" } Only payments with initiated or pending status can be cancelled.
Payment Lifecycle
initiated --> pending --> processing --> success
--> failed
--> expired
--> cancelled
success --> refunded (full refund)
--> partially_refunded (partial refund) | Status | Description | Terminal |
|---|---|---|
initiated | Payment created, awaiting processing | No |
pending | Being routed to the operator | No |
processing | Operator is processing the transaction | No |
success | Payment successful | Yes |
failed | Payment failed | Yes |
cancelled | Cancelled by the merchant | Yes |
expired | Timeout exceeded (30 min default) | Yes |
refunded | Fully refunded | Yes |
partially_refunded | Partially refunded | Yes |
Transfers
Send funds from your merchant account to a recipient's mobile money account.
Create a Transfer
{
"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 |
|---|---|---|
initiated | Transfer created | No |
pending | Being routed | No |
processing | Operator is processing | No |
success | Funds sent to recipient | Yes |
failed | Transfer failed | Yes |
expired | Timeout exceeded (10 min default) | Yes |
Refunds
Refund all or part of a successful payment.
{
"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
successpayments can be refunded - Amount cannot exceed remaining refundable amount
- Full refund sets status to
refunded - Partial refund sets status to
partially_refunded
List Refunds
Checkout Session
The checkout is a payment page hosted by AfriGate. Use it to offer a turnkey payment experience.
Get Session
Check Status
| Status | Description |
|---|---|
pending | Waiting for customer action |
processing | Payment in progress |
redirect | Customer must be redirected (operatorRedirectUrl present) |
success | Payment successful (successUrl present) |
failed | Payment failed (failedUrl present) |
expired | Session expired |
Webhooks
Webhooks notify you in real-time of status changes on your transactions.
Events
| Event | Trigger |
|---|---|
payment.completed | Payment reaches a terminal status |
transfer.completed | Transfer reaches a terminal status |
Payload Format
{
"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-Id | Unique delivery identifier (UUID) |
X-Webhook-Timestamp | Timestamp in milliseconds (epoch) |
X-Webhook-Signature | HMAC-SHA256 signature |
Signature Verification
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 });
}); 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 OKimmediately, process asynchronously - Use
X-Webhook-Request-Idto 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.
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-Limit | Max requests in the window |
X-RateLimit-Remaining | Remaining requests |
X-RateLimit-Reset | Unix timestamp of next window |
Exceeded 429
{
"error": "rate_limit_exceeded",
"details": { "limit": 60, "window": 60, "retry_after": 45 }
} 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 |
| Burkina Faso | BF | +226700000001 | +226700000002 |
| Benin | BJ | +229700000001 | +229700000002 |
| Nigeria | NG | +234700000001 | +234700000002 |
| Ghana | GH | +233700000001 | +233700000002 |
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 -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:
{
"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:
{
"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".
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:
{
"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.
Error Codes
Error Format
{
"success": false,
"error": "error_code",
"message": "Human-readable description",
"details": {}
} General Errors
| Code | HTTP | Description |
|---|---|---|
missing_auth | 401 | Missing Authorization header |
invalid_api_key | 401 | Invalid or expired API key |
rate_limit_exceeded | 429 | Rate limit exceeded |
merchant_not_active | 400 | Inactive merchant account |
blocked_number | 400 | Phone number is blocked |
internal_server_error | 500 | Internal error |
service_unavailable | 503 | Temporarily unavailable |
Transfer Errors
| Code | HTTP | Description |
|---|---|---|
exceeds_single_limit | 400 | Amount exceeds per-transaction limit |
exceeds_daily_limit | 400 | Daily cumulative exceeded |
exceeds_monthly_limit | 400 | Monthly cumulative exceeded |
count_limit_reached | 400 | Max daily transfers reached |
Refund Errors
| Code | HTTP | Description |
|---|---|---|
cannot_refund | 400 | Only success payments can be refunded |
exceeds_refund_amount | 400 | Exceeds remaining refundable amount |
Operators by Country
The operator field is required. The combination of operator + country determines the exact provider.
Ivory Coast CI - XOF
| Code | Operator | Flow |
|---|---|---|
momo | MTN Mobile Money | OTP |
orange | Orange Money | OTP |
moov | Moov Money | OTP |
wave | Wave | REDIRECT |
Senegal SN - XOF
| Code | Operator | Flow |
|---|---|---|
orange | Orange Money | REDIRECT |
free | Free Money | OTP |
wave | Wave | REDIRECT |
Ghana GH - GHS
| Code | Operator | Flow |
|---|---|---|
momo | MTN Mobile Money | OTP / USSD |
vodafone | Vodafone Cash | OTP / USSD |
airteltigo | AirtelTigo | OTP / USSD |
The unified code is the same regardless of country. For example, orange means Orange Money in both Ivory Coast and Senegal.
Payment Channels
| Channel | Description |
|---|---|
PUSH | Operator sends a USSD notification to the customer (default) |
OTP | Customer provides an OTP code to confirm |
USSD | Customer dials a USSD code manually |
QRCODE | Payment via QR code scan |
REDIRECT | Redirect to operator's payment page |
DIRECT | Direct 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 |