Copied to clipboard!

RwandaPay API Reference

Version 1.0 ยท Last updated September 2026

The RwandaPay API provides a secure, PCI-compliant way to integrate mobile money payments (MTN MoMo & Airtel Money) into your application. Built with defense-in-depth security, idempotency guarantees, and fine-grained API key scopes.

Secure & Reliable

API key authentication, HMAC webhooks, idempotency, rate limiting.

RESTful JSON API

Consistent JSON responses, standard HTTP methods, clear error codes.

Webhook + Polling

Real-time webhooks with polling fallback for maximum reliability.

Merchant API Base URL: https://api.rwandapay.rw/api/v1

Hosted Checkout Base URL: https://pay.rwandapay.rw

Environment: Production (Live). Test mode available with pk_test_* keys.

Authentication

๐Ÿ” API Key Format: All merchant API requests require X-Public-Key and X-Secret-Key headers.

Header Format Description
X-Public-Key pk_{environment}_{random} Identifies your merchant account.
X-Secret-Key sk_{environment}_{random} Authenticates the request (never expose client-side).
Authenticated Request (curl)
curl -X POST https://api.rwandapay.rw/api/v1/checkout/initialize \
  -H "X-Public-Key: pk_live_01ccdc1..." \
  -H "X-Secret-Key: sk_live_..." \
  -H "Idempotency-Key: 9dcca83f-b379-4320-b..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 15000,
    "currency": "RWF",
    "tx_ref": "CHK-20260928-001",
    "customer": {
      "name": "Jane Smith",
      "phone": "0788123456",
      "email": "jane@example.com"
    }
  }'

โš ๏ธ Important: Secret keys are only shown once when created. Store them securely and never commit them to version control.

Idempotency

What is Idempotency? Idempotency ensures that making the same request multiple times produces the same result as making it once. This is critical for financial operations where duplicate transactions could cause serious problems.

How It Works

1 Generate a unique Idempotency-Key for each financial operation.
2 Include the key in the Idempotency-Key request header.
3 RwandaPay stores the key with the first request's result.
4 If you retry with the same key:
  • Same body โ†’ Returns the original result (no duplicate).
  • Different body โ†’ Returns 409 Conflict.

Generating Idempotency Keys

JavaScript / Node.js

const key = crypto.randomUUID();

PHP

$key = bin2hex(random_bytes(16));

Python

import uuid; key = str(uuid.uuid4())

Java

String key = UUID.randomUUID().toString();
POST /v1/checkout/initialize Auth Required

Initialize Checkout

Creates a hosted checkout session. Returns a checkout URL where your customer can complete payment.

Required Scope: checkout:create

Idempotency: Required

Auth: API Keys Required

๐Ÿ’ก Note: The returned payment_url always points at https://pay.rwandapay.rw/checkout/{session_id}.

Request Parameters

ParameterTypeRequiredDescription
amountdecimal*Amount in RWF (min: 100, max: 1,000,000)
currencystringโ—‹Currency code (default: RWF)
tx_refstring*Your unique transaction reference (alphanumeric, dashes, underscores only)
customer.namestring*Customer's full name
customer.phonestring*Customer's phone โ€” 10 digits, starts with 0
customer.emailemail*Customer's email
redirect_urlurlโ—‹URL the customer is redirected to after payment
webhook_urlurlโ—‹Webhook URL for real-time notifications
descriptionstringโ—‹Payment description
metaobjectโ—‹Custom metadata returned with the transaction
Request Example
{
  "amount": 15000,
  "currency": "RWF",
  "tx_ref": "CHK-20260928-001",
  "customer": {
    "name": "Jane Smith",
    "phone": "0788123456",
    "email": "jane@example.com"
  },
  "description": "Payment for order #ORD-1234",
  "redirect_url": "https://your-site.com/order/confirmation",
  "webhook_url": "https://your-site.com/webhook/checkout",
  "meta": {
    "order_id": "ORD-1234"
  }
}
200 OK Response
{
  "success": true,
  "message": "Checkout session created successfully",
  "data": {
    "reference": "CHK-20260928-001",
    "session_id": "CHK-ABCDEFGHIJKLMNOP",
    "payment_url": "https://pay.rwandapay.rw/checkout/CHK-ABCDEFGHIJKLMNOP",
    "status": "pending",
    "amount": 15000,
    "currency": "RWF",
    "mode": "live",
    "expires_at": "2026-09-28T12:30:00.000000Z"
  }
}
GET /v1/checkout/session/{session_id} Auth Required

Get Checkout Session

Retrieves the details of a previously created checkout session.

Required Scope: checkout:read

Response
{
  "success": true,
  "data": {
    "session_id": "CHK-ABCDEFGHIJKLMNOP",
    "reference": "CHK-20260928-001",
    "status": "pending",
    "amount": 15000,
    "currency": "RWF",
    "mode": "live",
    "payment_url": "https://pay.rwandapay.rw/checkout/CHK-ABCDEFGHIJKLMNOP",
    "customer": {
      "name": "Jane Smith",
      "phone": "0788123456",
      "email": "jane@example.com"
    },
    "expires_at": "2026-09-28T12:30:00.000000Z",
    "created_at": "2026-09-28T12:00:00.000000Z"
  }
}
POST /v1/checkout/{session_id}/process Public

Process Payment

Processes a payment from the hosted checkout page. Called by the checkout page via AJAX.

Auth: โš ๏ธ No API Keys Required

Source: Browser AJAX

โš ๏ธ Important: This endpoint does NOT require API authentication. It uses the session's merchant_id for all operations.

Request Parameters

ParameterTypeRequiredDescription
phonestring*Customer phone โ€” 10 digits starting with 0
networkstring*MTN or Airtel
customer_namestring*Customer's full name
emailemailโ—‹Customer's email
Request Example
{
  "phone": "0788123456",
  "network": "MTN",
  "customer_name": "Jane Smith",
  "email": "jane@example.com"
}
Success Response (Live Mode)
{
  "status": "success",
  "message": "Payment initiated. Please check your phone to complete the payment.",
  "data": {
    "reference": "d234d032-7386-4431-9bd8-90c53eab7e91",
    "merchant_reference": "CHK-20260928-001",
    "mode": "live",
    "redirect_url": "https://pay.rwandapay.rw/checkout/waiting/d234d032-7386-4431-9bd8-90c53eab7e91",
    "amount": 15000,
    "currency": "RWF"
  }
}
Success Response (Test Mode)
{
  "status": "success",
  "message": "Test mode: Payment completed successfully!",
  "data": {
    "reference": "PAY-TEST-XXXXXXXXXXXX-20260928120403",
    "merchant_reference": "CHK-20260928-001",
    "mode": "test",
    "redirect_url": "https://pay.rwandapay.rw/checkout/success/PAY-TEST-XXXXXXXXXXXX-20260928120403",
    "amount": 15000,
    "currency": "RWF"
  }
}
GET /v1/checkout/{reference}/verify Public

Verify Payment

Verifies the status of a payment. Used for polling from the checkout page.

Auth: โš ๏ธ No API Keys Required

Rate Limit: 30 requests/minute per IP

๐Ÿ“Œ Note: Reads from the local database only. Safe for frequent polling.

Response (Pending)
{
  "status": "pending",
  "completed": false,
  "success": false,
  "message": "Waiting for payment confirmation. Please check your phone.",
  "mode": "live",
  "reference": "d234d032-7386-4431-9bd8-90c53eab7e91"
}
Response (Successful)
{
  "status": "successful",
  "completed": true,
  "success": true,
  "message": "Payment successful!",
  "redirect_url": "https://pay.rwandapay.rw/checkout/success/PAY-LIVE-XXXXXXXXXXXX-20260928120403",
  "amount": 15000,
  "mode": "live",
  "reference": "PAY-LIVE-XXXXXXXXXXXX-20260928120403"
}
Response (Failed)
{
  "status": "failed",
  "completed": true,
  "success": false,
  "message": "Payment failed. Please check if you have enough funds on your mobile money and try again.",
  "redirect_url": "https://pay.rwandapay.rw/checkout/failed/PAY-LIVE-XXXXXXXXXXXX-20260928120403",
  "reference": "PAY-LIVE-XXXXXXXXXXXX-20260928120403"
}
POST /v1/collections Auth Required

Create Collection

Creates a payment request (collection) that generates a payment link for the customer.

Required Scope: collections:create

Idempotency: Required

Auth: API Keys Required

๐Ÿ’ก Best Practice: Always include a webhook_url to receive real-time payment confirmations.

Request Example
{
  "amount": 5000,
  "currency": "RWF",
  "reference": "INV-20260928-001",
  "customer": {
    "name": "John Doe",
    "phone": "0788123456",
    "email": "john@example.com"
  },
  "description": "Payment for invoice #INV-20260928-001",
  "redirect_url": "https://your-merchant-site.com/payment-success",
  "webhook_url": "https://your-merchant-site.com/webhook",
  "expires_at": "2026-10-08T00:00:00Z",
  "metadata": {
    "order_id": "ORD-12345",
    "product": "Premium Subscription"
  }
}
201 Created Response
{
  "success": true,
  "message": "Collection created successfully",
  "data": {
    "reference": "COL-XXXXXXXXXX-20260928",
    "status": "pending",
    "amount_type": "fixed",
    "amount": 5000,
    "fee": 175,
    "net_amount": 4825,
    "currency": "RWF",
    "payment_url": "https://pay.rwandapay.rw/pay/COL-XXXXXXXXXX-20260928",
    "customer": {
      "name": "John Doe",
      "phone": "0788123456",
      "email": "john@example.com"
    },
    "description": "Payment for invoice #INV-20260928-001",
    "redirect_url": "https://your-merchant-site.com/payment-success",
    "webhook_url": "https://your-merchant-site.com/webhook",
    "success_redirect_url": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=successful",
    "failed_redirect_url": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=failed",
    "expires_at": "2026-10-08T00:00:00Z",
    "created_at": "2026-09-28T12:00:00Z",
    "metadata": {
      "order_id": "ORD-12345",
      "product": "Premium Subscription"
    }
  }
}
GET /v1/collections Auth Required

List Collections

Retrieves a paginated list of collections for your merchant account.

Required Scope: collections:read

Query Parameters

ParameterTypeDescription
statusstringFilter by status (pending, completed, failed, cancelled)
amount_typestringFilter by type (fixed, custom)
from_datedateFilter by created_at >= this date
to_datedateFilter by created_at <= this date
searchstringSearch by reference, customer name, phone, or email
per_pageintegerResults per page (default: 20)
Response
{
  "success": true,
  "data": [
    {
      "reference": "COL-XXXXXXXXXX-20260928",
      "status": "completed",
      "amount": 5000,
      "fee": 175,
      "net_amount": 4825,
      "currency": "RWF",
      "payment_url": "https://pay.rwandapay.rw/pay/COL-XXXXXXXXXX-20260928",
      "created_at": "2026-09-28T12:00:00Z",
      "paid_at": "2026-09-28T12:05:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 20,
    "total": 1
  }
}
GET /v1/collections/{reference} Auth Required

Get Collection

Retrieves the details of a collection request.

Required Scope: collections:read

Response
{
  "success": true,
  "data": {
    "reference": "COL-XXXXXXXXXX-20260928",
    "status": "completed",
    "amount_type": "fixed",
    "amount": 5000,
    "fee": 175,
    "net_amount": 4825,
    "currency": "RWF",
    "customer": {
      "name": "John Doe",
      "phone": "0788123456",
      "email": "john@example.com"
    },
    "description": "Payment for invoice #INV-20260928-001",
    "redirect_url": "https://your-merchant-site.com/payment-success",
    "webhook_url": "https://your-merchant-site.com/webhook",
    "payment_url": "https://pay.rwandapay.rw/pay/COL-XXXXXXXXXX-20260928",
    "paypack_status": "successful",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "paid_at": "2026-09-28T12:05:00Z",
    "created_at": "2026-09-28T12:00:00Z"
  }
}
GET /v1/collections/{reference}/status Auth Required

Check Collection Status

Gets the real-time payment status of a collection.

Required Scope: collections:read

๐Ÿ“Œ Note: Reads from the local database only. Safe for frequent polling.

Response (Pending)
{
  "success": true,
  "data": {
    "reference": "COL-XXXXXXXXXX-20260928",
    "collection_status": "pending",
    "is_paid": false,
    "is_processing": false,
    "is_pending": true,
    "is_expired": false,
    "is_failed": false,
    "amount": 5000,
    "amount_paid": 0,
    "currency": "RWF",
    "payment_method": null,
    "redirect_url": "https://your-merchant-site.com/payment-success",
    "webhook_url": "https://your-merchant-site.com/webhook",
    "paypack_status": "pending",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "processing_status": "pending"
  }
}
Response (Successful)
{
  "success": true,
  "data": {
    "reference": "COL-XXXXXXXXXX-20260928",
    "collection_status": "completed",
    "is_paid": true,
    "is_processing": false,
    "is_pending": false,
    "is_expired": false,
    "is_failed": false,
    "amount": 5000,
    "amount_paid": 5000,
    "currency": "RWF",
    "payment_method": "MTN",
    "paypack_status": "successful",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "processing_status": "processed",
    "success_redirect_url": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=successful",
    "redirect_to": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=successful"
  }
}
Response (Failed)
{
  "success": true,
  "data": {
    "reference": "COL-XXXXXXXXXX-20260928",
    "collection_status": "failed",
    "is_paid": false,
    "is_processing": false,
    "is_pending": false,
    "is_expired": false,
    "is_failed": true,
    "amount": 5000,
    "amount_paid": 0,
    "currency": "RWF",
    "payment_method": "MTN",
    "paypack_status": "failed",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "processing_status": "processed",
    "failed_redirect_url": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=failed",
    "redirect_to": "https://your-merchant-site.com/payment-success?reference=COL-XXXXXXXXXX-20260928&status=failed"
  }
}
DELETE /v1/collections/{reference} Auth Required

Cancel Collection

Cancels a pending collection. Only works if the collection hasn't been paid.

Required Scope: collections:write

โš ๏ธ Note: Collections with status completed or paid cannot be cancelled.

Response
{
  "success": true,
  "message": "Collection cancelled successfully"
}
GET /v1/balance Auth Required New

Get Balance

Returns your current merchant balance breakdown: available, ledger, reserved, pending, and totals.

Required Scope: balance:read

๐Ÿ“Œ Note: Reads from the local ledger. Safe to call frequently (subject to standard rate limits).

Response
{
  "success": true,
  "data": {
    "balance": 81447.50,
    "balance_formatted": "81,447.50",
    "ledger_balance": 81447.50,
    "reserved_balance": 10400.00,
    "pending_balance": 0,
    "held_balance": 5100.00,
    "total_collected": 119300.00,
    "total_withdrawn": 30000.00,
    "total_refunded": 0.00,
    "total_fees": 1483.50,
    "currency": "RWF",
    "pending_withdrawals": 1,
    "last_reconciled_at": "2026-09-28T15:04:58.000000Z"
  }
}
GET /v1/balance/transactions Auth Required

Balance Transactions

Returns a paginated list of ledger entries affecting your balance.

Required Scope: balance:read

Response
{
  "success": true,
  "data": [
    {
      "id": 42,
      "type": "credit",
      "amount": 96.50,
      "balance_after": 81447.50,
      "description": "Payment from Customer",
      "reference": "PAY-LIVE-XXXXXXXXXXXX",
      "created_at": "2026-09-28T12:05:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 25,
    "total": 1
  }
}
POST /v1/payments/collect Auth Required New

Collect Payment (Direct)

Initiates a direct mobile money collection without the hosted checkout flow.

Required Scope: payments:create

Idempotency: Required

Request Example
{
  "amount": 100,
  "currency": "RWF",
  "phone": "0788123456",
  "network": "MTN",
  "reference": "PAY-20260928-001",
  "customer": {
    "name": "Jane Smith",
    "email": "jane@example.com"
  }
}
Response
{
  "success": true,
  "message": "Collection initiated. Please check the customer's phone.",
  "data": {
    "reference": "PAY-20260928-001",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "status": "pending",
    "amount": 100,
    "currency": "RWF"
  }
}
GET /v1/payments/{reference} Auth Required

Get Payment Status

Returns the current status of a direct payment.

Response
{
  "success": true,
  "data": {
    "reference": "PAY-20260928-001",
    "status": "successful",
    "amount": 100,
    "fee": 3.50,
    "net_amount": 96.50,
    "currency": "RWF",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440000",
    "completed_at": "2026-09-28T12:05:00Z"
  }
}
POST /v1/payments/{reference}/refund Auth Required

Refund Payment

Issues a full or partial refund for a successful payment.

Required Scope: payments:refund

Idempotency: Required

Request Example
{
  "amount": 100,
  "reason": "Customer requested refund"
}
Response
{
  "success": true,
  "message": "Refund initiated successfully",
  "data": {
    "reference": "REF-20260928-001",
    "payment_reference": "PAY-20260928-001",
    "amount": 100,
    "status": "pending"
  }
}
GET /v1/transactions Auth Required

List Transactions

Returns a paginated list of all transactions for your merchant account.

Required Scope: transactions:read

Response
{
  "success": true,
  "data": [
    {
      "reference": "PAY-LIVE-XXXXXXXXXXXX",
      "type": "payment",
      "amount": 100,
      "fee": 3.50,
      "net_amount": 96.50,
      "currency": "RWF",
      "status": "successful",
      "customer_name": "Jane Smith",
      "customer_phone": "0788123456",
      "payment_method": "mtn_momo",
      "created_at": "2026-09-28T12:00:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
GET /v1/transactions/{reference} Auth Required

Get Transaction

Retrieves full details of a single transaction.

Response
{
  "success": true,
  "data": {
    "reference": "PAY-LIVE-XXXXXXXXXXXX",
    "type": "payment",
    "source": "api_payment",
    "amount": 100,
    "fee": 3.50,
    "net_amount": 96.50,
    "currency": "RWF",
    "status": "successful",
    "payment_method": "mtn_momo",
    "customer": {
      "name": "Jane Smith",
      "phone": "0788123456",
      "email": "jane@example.com"
    },
    "balance_before": 81351.00,
    "balance_after": 81447.50,
    "completed_at": "2026-09-28T12:05:00Z",
    "created_at": "2026-09-28T12:00:00Z"
  }
}
POST /v1/disbursements Auth Required

Create Disbursement (Withdrawal)

Initiates an automated withdrawal to a mobile money account.

Required Scope: disbursements:create

Idempotency: Required

โš ๏ธ Security: Disbursements use atomic fund reservation. Concurrent withdrawals cannot double-spend.

Request Example
{
  "amount": 50000,
  "currency": "RWF",
  "destination": {
    "phone": "0788123456",
    "network": "MTN"
  },
  "reference": "WD-20260928-0001",
  "description": "Salary payout for September"
}
201 Created Response
{
  "success": true,
  "data": {
    "reference": "WD-20260928-0001",
    "status": "authorized",
    "amount": 50000,
    "fee": 2000,
    "net_amount": 48000,
    "currency": "RWF",
    "destination": {
      "phone": "0788123456",
      "network": "MTN"
    },
    "created_at": "2026-09-28T12:00:00Z"
  }
}

๐Ÿ“Œ Note: Initial response shows status: "authorized". Poll GET /v1/disbursements/{reference} for final status.

GET /v1/disbursements Auth Required

List Disbursements

Returns a paginated list of disbursements.

Response
{
  "success": true,
  "data": [
    {
      "reference": "WD-20260928-0001",
      "status": "successful",
      "amount": 50000,
      "fee": 2000,
      "net_amount": 48000,
      "currency": "RWF",
      "created_at": "2026-09-28T12:00:00Z"
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
GET /v1/disbursements/{reference} Auth Required

Get Disbursement

Retrieves the details and current status of a disbursement.

Required Scope: disbursements:read

Response
{
  "success": true,
  "data": {
    "reference": "WD-20260928-0001",
    "status": "successful",
    "amount": 50000,
    "fee": 2000,
    "net_amount": 48000,
    "currency": "RWF",
    "destination": {
      "phone": "0788123456",
      "network": "MTN"
    },
    "description": "Salary payout for September",
    "paypack_reference": "550e8400-e29b-41d4-a716-446655440001",
    "processing_time": "2026-09-28T12:05:00Z",
    "created_at": "2026-09-28T12:00:00Z"
  }
}
GET /v1/disbursements/{reference}/status Auth Required

Disbursement Status

Lightweight status check for a disbursement โ€” safe for frequent polling.

Response
{
  "success": true,
  "data": {
    "reference": "WD-20260928-0001",
    "status": "successful",
    "is_completed": true,
    "is_pending": false,
    "is_failed": false,
    "amount": 50000,
    "currency": "RWF"
  }
}

Webhooks Overview

๐Ÿ”— Provider Webhook (MTN โ†’ RwandaPay): https://api.rwandapay.rw/api/webhooks/mtn

๐Ÿ“Œ Merchant Webhook (RwandaPay โ†’ You): When you register a webhook_url on your merchant account, RwandaPay forwards the normalized event to your endpoint as a POST request with JSON body and an HMAC-SHA256 signature.

RwandaPay uses both webhooks and polling for maximum reliability. The dual-path pipeline means a payment is confirmed even if one path fails:

1

Payment Initiated

Customer initiates payment from the hosted page.

2

Provider Webhook (Primary)

MTN sends a webhook to api.rwandapay.rw/api/webhooks/mtn with the payment status.

{
  "financialTransactionId": "30861309738",
  "externalId": "PAY-LIVE-XXXXXXXXXXXX-20260928120403",
  "amount": "100",
  "currency": "RWF",
  "payer": { "partyIdType": "MSISDN", "partyId": "250788123456" },
  "status": "SUCCESSFUL"
}
3

Polling (Fallback)

The hosted page polls /api/v1/checkout/{reference}/verify every 3 seconds.

  • 30 requests/minute rate limit
  • Returns the cached status from the database
  • Stops polling when the payment is completed
4

Merchant Webhook (Optional)

RwandaPay forwards the normalized event to your webhook_url with an HMAC signature.

โš ๏ธ Important: Always return a 2xx status code from your webhook receiver. Non-2xx responses trigger retries.

๐Ÿงช Debug endpoint: For testing your receiver configuration, you can POST to https://api.rwandapay.rw/api/webhook-test โ€” this simply echoes back whatever payload you send and writes it to the server log.

Webhook Setup Guide

1. Create a Webhook Endpoint

PHP Webhook Receiver
<?php
// webhook.php โ€” Your webhook receiver endpoint

function verifySignature(string $rawBody, string $signature, string $secret): bool {
    $expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
    return hash_equals($expected, trim($signature));
}

$rawBody    = file_get_contents('php://input');
$signature  = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret     = 'whsec_your_webhook_secret';

if (!verifySignature($rawBody, $signature, $secret)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$payload   = json_decode($rawBody, true);
$event     = $payload['event_kind'] ?? null;
$status    = $payload['status'] ?? null;
$reference = $payload['paypack_reference'] ?? null;

if ($event === 'transaction:processed') {
    if ($status === 'successful') {
        handleSuccessfulPayment($reference, $payload);
    } elseif ($status === 'failed') {
        handleFailedPayment($reference, $payload);
    }
}

http_response_code(200);
echo json_encode(['status' => 'success']);

function handleSuccessfulPayment(string $reference, array $payload): void {
    error_log("Payment successful: $reference");
}

function handleFailedPayment(string $reference, array $payload): void {
    error_log("Payment failed: $reference");
}
?>

2. Node.js / Express Receiver

Node.js / Express
// webhook.js
const crypto = require('crypto');
const express = require('express');
const app = express();

app.use(express.json({
    verify: (req, res, buf) => { req.rawBody = buf; }
}));

function verifySignature(rawBody, signature, secret) {
    const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('base64');
    const a = Buffer.from(expected);
    const b = Buffer.from(signature || '');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhook', (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const secret    = 'whsec_your_webhook_secret';

    if (!verifySignature(req.rawBody, signature, secret)) {
        return res.status(401).json({ error: 'Invalid signature' });
    }

    const { event_kind, status, paypack_reference } = req.body;
    console.log(`Webhook: ${event_kind} โ€” ${status}`);

    if (event_kind === 'transaction:processed') {
        status === 'successful'
            ? handleSuccess(paypack_reference, req.body)
            : handleFailure(paypack_reference, req.body);
    }

    res.json({ status: 'success' });
});

function handleSuccess(ref, body) { console.log(`โœ… ${ref} โ€” ${body.amount} RWF`); }
function handleFailure(ref, body) { console.log(`โŒ ${ref}`); }

app.listen(3000, () => console.log('Webhook server running on :3000'));

Webhook Event Reference

Event Description When Sent
transaction:processedPayment processed (successful or failed)Immediately after provider response
payment.successfulPayment completed successfullyWhen payment is confirmed
payment.failedPayment failedWhen payment is rejected
collection.createdCollection was created via APIImmediately after POST /v1/collections

Webhook Payload Structure

Normalized Payload Forwarded to Merchants
{
  "event_id": "245801aa-9a86-11f1-a305-deadd43720af",
  "event_kind": "transaction:processed",
  "transaction_kind": "CASHIN",
  "paypack_reference": "d234d032-7386-4431-9bd8-90c53eab7e91",
  "status": "successful",
  "amount": 100,
  "number": "250788123456",
  "network": "MTN",
  "provider": "mtn",
  "created_at": "2026-09-28T21:53:43.150125Z"
}

Webhook Security

โš ๏ธ Always verify signatures: Webhooks are signed with HMAC-SHA256. Never trust unverified webhooks.

Signature Verification (PHP)

HMAC Verification
function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
    $expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
    return hash_equals($expected, trim($signature));
}

$rawBody   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret    = 'whsec_your_webhook_secret';

if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}
// Process webhook...

Best Practices

โœ… DO:

  • Verify the signature before processing.
  • Use a secure secret (minimum 32 characters).
  • Return 200 OK quickly, process asynchronously.
  • Log all webhook attempts for debugging.
  • Use HTTPS for your webhook endpoint.
  • Use event_id to deduplicate.

โŒ DON'T:

  • Process webhooks without verification.
  • Share your webhook secret.
  • Return non-2xx responses (triggers retries).
  • Do heavy processing synchronously.
  • Trust webhooks as the only confirmation.

Error Codes

HTTP Code Error Code Description Retry?
400BAD_REQUESTMalformed request syntaxโŒ No
401AUTH_FAILEDInvalid API credentialsโŒ No
403MERCHANT_INACTIVEMerchant account is not activeโŒ No
404NOT_FOUNDResource does not existโŒ No
409DUPLICATE_REFERENCETransaction reference already existsโŒ No
409IDEMPOTENCY_KEY_CONFLICTSame key with different parametersโŒ No
422VALIDATION_ERRORInvalid request parametersโœ… Yes (fix params)
429RATE_LIMIT_EXCEEDEDToo many requestsโœ… Yes (after delay)
500INTERNAL_SERVER_ERRORServer-side errorโœ… Yes (with idempotency)
Error Response Format
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The amount field is required.",
    "details": {
      "amount": ["The amount field is required."]
    }
  }
}

Rate Limits

Operation Type Rate Limit Time Window
Read operations100 requests1 minute
Write operations50 requests1 minute
Financial operations20 requests1 minute
Checkout polling30 requests1 minute

Rate Limit Headers: All responses include rate limit information.

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1628496000

Security

๐Ÿ” API Key Security

Secret keys are hashed with password_hash(). Raw secrets are never stored and displayed only once at creation.

๐Ÿ” Environment Isolation

Test keys (pk_test_*) cannot perform live operations. Live keys cannot be used in test mode.

๐Ÿ›ก๏ธ Atomic Fund Reservation

Withdrawals use SELECT ... FOR UPDATE row-level locking. Concurrent withdrawals cannot double-spend.

๐Ÿ“ Immutable Audit Trail

Financial records are append-only. Corrections create new transactions, never modify history.

Need Help?

Our developer support team is here to help you integrate.