Talk to Sales
Getting Started

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.

Integration Journey

GET /v1/payment-methods Step 1 — first

Fetch 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 2

Create the payment with the operator + country chosen by the customer and the channel matching the operator's flow. See Payment Flows.

Webhook Step 3

Receive the final result (payment.completed) on your callbackUrl, then confirm via GET /v1/payments/{token}.

Payment Flow

Flow Diagram
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

Flow Diagram
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:

PlatformPublic keyPrivate key
Afrigatepk_live_...sk_live_...
AWSAccess Key IDSecret Access Key
OAuth 2.0client_idclient_secret
Stripepk_live_...sk_live_...
Public key (pk_)Private key (sk_)
RoleIdentifies your merchant accountProves you are the account owner
AnalogyUsernamePassword
Sensitive?No, can be known by third partiesYes, strictly confidential
StorageBack-end env variable (acceptable)Secret manager only
Shown in dashboard?At any timeOnce, 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 .env file, 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:

Header
Authorization: Bearer {publicKey}:{privateKey}

Full example:

Header
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.

EnvironmentPublic keyPrivate keyUsage
Productionpk_live_...sk_live_...Real transactions, real fund movement
Sandboxpk_test_...sk_test_...Testing, no real fund movement, 50,000 sandbox balance included

If your private key leaks

  1. Log into the dashboard.
  2. Rotate the compromised key — the old private key is revoked immediately.
  3. Update the new pair in your back-ends.
  4. Review your transaction history over the suspicious window (illegitimate payments or transfers).

Permissions

PermissionDescription
payment:readView payments
payment:writeCreate/cancel payments
transfer:readView transfers
transfer:writeCreate transfers

Injected Headers

After API key validation, the following headers are automatically added:

HeaderDescription
X-Merchant-IDYour merchant identifier (UUID)
X-Merchant-CodeYour merchant code
X-Key-Typelive or test
X-Request-IDUnique request identifier

Environments

EnvironmentBase URL
Productionhttps://prod.afrigate.dev
Sandboxhttps://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

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.

GET/v1/payment-methods

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

HeaderRequiredDescription
AuthorizationRequiredBearer {publicKey} — your pk_… alone (no :sk_)
Content-TypeOptionalapplication/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

FieldDescription
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, …).

Reference

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.

CountryCodeCurrencyOperators
BeninBJXOFMoov, MTN
BotswanaBWBWPVoucher
CameroonCMXAFMTN, Orange Money
Ivory CoastCIXOFOrange Money, Wave, Moov, MTN
GambiaGMGMDWave, Afrimoney, Qmoney
GhanaGHGHSMTN, Vodafone, AirtelTigo
KenyaKEKESM-Pesa, Airtel
LiberiaLRLRDMTN
NigeriaNGNGNOpay, Palmpay
UgandaUGUGXMTN, Airtel
SenegalSNXOFOrange Money, Wave, Mixx
Sierra LeoneSLSLEAfrimoney, Orange Money
TanzaniaTZTZSHalopesa, 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 / countryFlowWhat YOU must do
Wave — SN, GM, CI, SLRedirectchannel: "REDIRECT" → redirect the customer to redirectUrl (https://pay.afrigate.dev/{token})
Orange Money — CIOTP directCustomer dials #144*82#, gives you the code → send it in otp with channel: "OTP"
Orange Money — SNRedirectchannel: "REDIRECT" (enforced)
Botswana (Voucher)VoucherCustomer buys a voucher and gives you its PIN → send it in metadata.voucherPin
Opay / Palmpay — NGRedirectchannel: "REDIRECT" → redirect to redirectUrl
MTN (MoMo), Moov, M-Pesa, Tigo Pesa, Halopesa, AirtelPush / STKchannel: "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

HeaderRequiredDescription
AuthorizationRequiredBearer {keyId}:{secret} — see Authentication
X-Idempotency-KeyRequiredUnique UUID to prevent duplicates
Content-TypeRequiredapplication/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 Required

Amount in currency units (minimum 1)

currency string Required

ISO 4217 currency code (e.g. XOF, XAF, GHS)

paymentMethod string Required

Payment method (e.g. MOBILE_MONEY)

operator string Required

Unified operator code. See operators table

country string Required

ISO 3166-1 alpha-2 country code (e.g. CI, SN, GH)

customer.phone string Required

Customer phone number (international format)

customer.name string Optional

Customer name

customer.email string Optional

Customer email

successUrl string Required

Redirect URL after successful payment

failedUrl string Required

Redirect URL after failure

callbackUrl string Required

Webhook receiving URL

merchantTransactionId string Optional

Your internal reference

feeBearer string Optional

Who pays fees: merchant (default) or customer

channel string Optional

PUSH (default), OTP, USSD, QRCODE, REDIRECT, DIRECT. See Payment Flows

otp string Optional

Authorization / 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 Optional

Additional 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
ParameterTypeDescription
statusstringFilter by status
fromstringStart date (ISO 8601)
tostringEnd date (ISO 8601)
limitnumberNumber of results (default: 20)
offsetnumberOffset 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)
StatusDescriptionTerminal
initiatedPayment created, awaiting processingNo
pendingBeing routed to the operatorNo
processingOperator is processing the transactionNo
successPayment successfulYes
failedPayment failedYes
cancelledCancelled by the merchantYes
expiredTimeout exceeded (30 min default)Yes
refundedFully refundedYes
partially_refundedPartially refundedYes
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}.
OperatoroperatorcountryNote
WavewaveSN, CI, GM, SLREDIRECT enforced
Orange Money SenegalorangeSNREDIRECT enforced
OpayopayNGREDIRECT enforced
PalmpaypalmpayNGREDIRECT 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:

  1. The customer dials on their phone: #144*82#.
  2. They receive a payment code (OTP) by SMS.
  3. They give you this code on your interface (payment page, app, POS…).
  4. 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

OperatoroperatorcountryFlowchannelThe customer…
WavewaveSN, CIRedirectREDIRECT (enforced in SN)is redirected to redirectUrl
Orange MoneyorangeSNRedirectREDIRECT (enforced)is redirected to redirectUrl
OpayopayNGRedirectREDIRECT (enforced)is redirected to redirectUrl
PalmpaypalmpayNGRedirectREDIRECTis redirected to redirectUrl
Orange MoneyorangeCIOTP directOTP + otp fielddials #144*82#, gives you the code
MTN MoMomomoCI, …PushPUSH (default)approves the prompt with their PIN
MoovmoovCI, BJPushPUSH (default)approves the prompt with their PIN
VouchervoucherBWVoucher— (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 Required

Amount (minimum 1)

currency string Required

ISO 4217 currency code

operator string Required

Unified operator code. See operators table

country string Required

ISO 3166-1 alpha-2 country code

recipient.phone string Required

Recipient phone number

recipient.name string Optional

Recipient name

callbackUrl string Optional

Webhook URL

metadata object Optional

Additional data

Transfer Lifecycle

StatusDescriptionTerminal
initiatedTransfer createdNo
pendingBeing routedNo
processingOperator is processingNo
successFunds sent to recipientYes
failedTransfer failedYes
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 Required

Amount to refund

currency string Required

Currency (must match the payment)

refundType string Optional

full or partial (auto-detected if omitted)

reason string Optional

Refund 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
StatusDescription
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

EventTrigger
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

HeaderDescription
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

AttemptDelayCumulative
1Immediate0s
22s2s
34s6s
48s14s
516s30s

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
ScenarioBehavior
New keyTransaction created normally
Existing keyOriginal response returned (no duplicate)
Key TTL24 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

HeaderDescription
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.

CountryCodeSuccess numberFailed number
SenegalSN+221700000001+221700000002
Ivory CoastCI+225700000001+225700000002
BeninBJ+229700000001+229700000002
NigeriaNG+234700000001+234700000002
GhanaGH+233700000001+233700000002
BotswanaBW+267700000001+267700000002
CameroonCM+237700000001+237700000002
GambiaGM+220700000001+220700000002
KenyaKE+254700000001+254700000002
LiberiaLR+231700000001+231700000002
Sierra LeoneSL+232700000001+232700000002
TanzaniaTZ+255700000001+255700000002
UgandaUG+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).

Countrycountrycurrencyoperator (ex.)channelcustomer.phone (success)
SenegalSNXOFwaveREDIRECT+221700000001
Ivory CoastCIXOFmomoPUSH+225700000001
BeninBJXOFmoovPUSH+229700000001
NigeriaNGNGNopayREDIRECT+234700000001
GhanaGHGHSmomoPUSH+233700000001
BotswanaBWBWPvoucherPUSH *+267700000001
CameroonCMXAFmomoPUSH+237700000001
GambiaGMGMDwaveREDIRECT+220700000001
KenyaKEKESmpesaPUSH+254700000001
LiberiaLRLRDmomoPUSH+231700000001
Sierra LeoneSLSLEorangePUSH+232700000001
TanzaniaTZTZSmpesaPUSH+255700000001
UgandaUGUGXmomoPUSH+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

CodeHTTPDescription
missing_auth401Missing Authorization header
invalid_api_key401Invalid or expired API key
rate_limit_exceeded429Rate limit exceeded
merchant_not_active400Inactive merchant account
blocked_number400Phone number is blocked
internal_server_error500Internal error
service_unavailable503Temporarily unavailable

Payment Errors - POST /v1/payments

CodeHTTPDescription
merchant_not_active400Inactive merchant account
blocked_number400The customer number (customer.phone) is blocked
payment_not_found404Payment token not found

Cancel Errors - POST /v1/payments/{token}/cancel

CodeHTTPDescription
payment_not_found404Payment token not found
wrong_merchant400The payment doesn't belong to this merchant
cannot_cancel400Cannot cancel — the payment is in a terminal status (success, failed, cancelled, expired)

Transfer Errors - POST /v1/transfers

CodeHTTPDescription
merchant_not_active400Inactive merchant account
blocked_number400The recipient number (recipient.phone) is blocked
exceeds_single_limit400Amount exceeds per-transaction limit
exceeds_daily_limit400Daily cumulative exceeded
exceeds_monthly_limit400Monthly cumulative exceeded
count_limit_reached400Max daily transfers reached
transfer_not_found404Transfer token not found

Refund Errors - POST /v1/payments/{token}/refund

CodeHTTPDescription
payment_not_found404Payment token not found
wrong_merchant400The payment doesn't belong to this merchant
cannot_refund400Only success payments can be refunded
exceeds_refund_amount400Exceeds 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

CodeOperatorFlowChannel
waveWaveREDIRECTREDIRECT
orangeOrange MoneyOTP directOTP (+ otp) — or REDIRECT
momoMTN Mobile MoneyPUSHPUSH
moovMoov MoneyREDIRECTREDIRECT (default)

Senegal SN - XOF

CodeOperatorFlowChannel
orangeOrange MoneyREDIRECTREDIRECT (enforced)
waveWaveREDIRECTREDIRECT (enforced)
freeFree MoneyOTPOTP (+ otp)

Nigeria NG - NGN

CodeOperatorFlowChannel
opayOpayREDIRECTREDIRECT (enforced)
palmpayPalmpayREDIRECTREDIRECT

Ghana GH - GHS

CodeOperatorFlowChannel
momoMTN Mobile MoneyOTP / USSDOTP / REDIRECT
vodafoneVodafone CashOTP / USSDOTP / REDIRECT
airteltigoAirtelTigoOTP / USSDOTP / REDIRECT

Botswana BW - BWP

CodeOperatorFlowChannel
voucherVoucher (prepaid)VOUCHERPIN in metadata.voucherPin

Kenya KE - KES

CodeOperatorFlowChannel
mpesa (alias safaricom)M-Pesa (Safaricom)PUSHPUSH
airtelAirtel MoneyPUSHPUSH

Tanzania TZ - TZS

CodeOperatorFlowChannel
mpesa (alias vodacom)M-Pesa (Vodacom)PUSHPUSH
tigo (alias tigopesa)Tigo PesaPUSHPUSH
halopesa (alias halotel)HalopesaPUSHPUSH
airtelAirtel MoneyPUSHPUSH

Uganda UG - UGX

CodeOperatorFlowChannel
momo (alias mtn)MTN Mobile MoneyPUSHPUSH
airtelAirtel MoneyPUSHPUSH

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

CodeOperator
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

ChannelDescription
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

TypeDefault
Payment30 minutes
Transfer10 minutes

Constraints

ConstraintValue
Minimum amount1 currency unit
Currency length3 characters
Country length2 characters
Cancel/refund reason500 characters max
Idempotency TTL24 hours
Webhook attempts5
Webhook timestamp tolerance5 minutes