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.

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

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

metadata object Optional

Additional data returned in webhooks

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

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
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
transfer.completedTransfer reaches a terminal status

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
Burkina FasoBF+226700000001+226700000002
BeninBJ+229700000001+229700000002
NigeriaNG+234700000001+234700000002
GhanaGH+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
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".

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

Transfer Errors

CodeHTTPDescription
exceeds_single_limit400Amount exceeds per-transaction limit
exceeds_daily_limit400Daily cumulative exceeded
exceeds_monthly_limit400Monthly cumulative exceeded
count_limit_reached400Max daily transfers reached

Refund Errors

CodeHTTPDescription
cannot_refund400Only success payments can be refunded
exceeds_refund_amount400Exceeds remaining refundable amount

Operators by Country

The operator field is required. The combination of operator + country determines the exact provider.

Ivory Coast CI - XOF

CodeOperatorFlow
momoMTN Mobile MoneyOTP
orangeOrange MoneyOTP
moovMoov MoneyOTP
waveWaveREDIRECT

Senegal SN - XOF

CodeOperatorFlow
orangeOrange MoneyREDIRECT
freeFree MoneyOTP
waveWaveREDIRECT

Ghana GH - GHS

CodeOperatorFlow
momoMTN Mobile MoneyOTP / USSD
vodafoneVodafone CashOTP / USSD
airteltigoAirtelTigoOTP / USSD

The unified code is the same regardless of country. For example, orange means Orange Money in both Ivory Coast and Senegal.

Payment Channels

ChannelDescription
PUSHOperator sends a USSD notification to the customer (default)
OTPCustomer provides an OTP code to confirm
USSDCustomer dials a USSD code manually
QRCODEPayment via QR code scan
REDIRECTRedirect to operator's payment page
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