Kenyan payment gateway with support for M-Pesa, Airtel, EazzyPay, and cards using HMAC-based authentication. Includes C2B, B2B, and B2C APIs with STK push, USSD, and callback integration.
iPay is a leading East African payment processing platform providing comprehensive payment solutions for businesses in Kenya, Uganda, Tanzania, Rwanda, and the Democratic Republic of the Congo. The platform enables customer-to-business (C2B), business-to-business (B2B), and business-to-customer (B2C) payments through multiple channels including mobile money (M-Pesa, Airtel Money, EazzyPay) and card payments.
The iPay API uses a secure two-step transaction flow with HMAC-SHA256 authentication for all requests, supporting modern payment initiation methods like STK push and USSD alongside traditional form-based integration.
Use iPay integration when you need to:
iPay is ideal for East African startups, e-commerce platforms, service providers, and fintech applications requiring local payment processing with minimal friction.
All iPay API requests require HMAC-SHA256 authentication. The merchant must generate a cryptographic signature combining request parameters with their secret key to prove request authenticity.
Obtain from your iPay merchant dashboard:
https://apis.ipayafrica.comhttps://apis.staging.ipayafrica.comThe hash is computed from a concatenated datastring of parameters in strict order, using HMAC-SHA256 with your secret key. Parameter order is critical and varies by endpoint.
⚠️ HMAC parameter order varies by flow. The C2B collection and B2C disbursement APIs use different parameter concatenation orders. Using the C2B hash logic for a B2C request (or vice versa) will result in all requests being rejected with a signature error. Always refer to the endpoint-specific documentation at dev.ipayafrica.com for the exact field order for each API type.
⚠️ B2C disbursements have separate authentication. The B2C payout API (sending money to customers' mobile wallets) uses a different API key and may require separate merchant activation. Contact iPay support ([email protected]) to enable B2C on your account — it is not automatically enabled with C2B access.
ℹ️ C2B integration: API vs form redirect. iPay supports two C2B integration models: (1) API-initiated — your server calls the iPay API to initiate STK push or USSD; (2) Form redirect — you POST an HTML form to the iPay checkout page and the customer completes payment there. The form redirect model does not require HMAC on the frontend, but your backend callback verification must still validate the iPay response signature. Choose API-initiated for embedded UX; form redirect for quickest integration.
<?php
// C2B transaction registration parameters
$amount = "1000.00";
$phone = "254712345678";
$reference = "ORDER-12345";
$redirect_url = "https://yoursite.com/callback";
$secret = "your_secret_key_from_dashboard";
// Construct datastring - ORDER MATTERS!
$datastring = $amount . $phone . $reference;
// Generate HMAC-SHA256 hash
$hash = hash_hmac('sha256', $datastring, $secret, false);
// Request payload
$payload = [
"amount" => $amount,
"phone" => $phone,
"reference" => $reference,
"redirect_url" => $redirect_url,
"hash" => $hash
];
echo json_encode($payload);
?>
const crypto = require('crypto');
const amount = '1000.00';
const phone = '254712345678';
const vid = 'your_merchant_id';
const account = 'ACCT-001';
const secret = 'your_secret_key_from_dashboard';
// STK push datastring - different order
const datastring = phone + vid + amount + account;
// Generate HMAC-SHA256
const hash = crypto
.createHmac('sha256', secret)
.update(datastring)
.digest('hex');
const payload = {
phone,
vid,
amount,
account,
hash
};
console.log(JSON.stringify(payload, null, 2));
Live: https://apis.ipayafrica.com/payments/v2
Staging: https://apis.staging.ipayafrica.com/payments/v2
Register a transaction before processing payment. Returns a session ID (sid) used in subsequent transact calls.
Endpoint: POST /c2b/register
Request Example:
{
"amount": "1000.00",
"phone": "254712345678",
"reference": "ORDER-12345",
"redirect_url": "https://yoursite.com/callback",
"comment": "Payment for order 12345",
"hash": "abc123def456..."
}
Response Example:
{
"status": 200,
"sid": "xf8a2b1c3d4e5f6g7h8i9j0k",
"reference": "ORDER-12345"
}
Process payment after customer authorization through their mobile money app. Can be triggered by customer action or via STK push.
Endpoint: POST /transact
Request Example:
{
"amount": "1000.00",
"phone": "254712345678",
"sid": "xf8a2b1c3d4e5f6g7h8i9j0k",
"channel": "mpesa",
"hash": "abc123def456..."
}
Response Example:
{
"status": "pending",
"transaction_id": "TXN-987654321",
"message": "Transaction initiated. Awaiting payment confirmation.",
"amount": "1000.00"
}
Trigger an automatic STK (SIM Toolkit) prompt on the customer's phone to enter their M-Pesa PIN. No customer app interaction required.
Endpoint: POST /transact/push/mpesa
Request Example:
{
"phone": "254712345678",
"vid": "your_merchant_id",
"amount": "1000.00",
"account": "ACCT-001",
"hash": "abc123def456..."
}
Response Example:
{
"status": "pending",
"transaction_id": "TXN-654321987",
"message": "STK prompt sent to customer's phone"
}
Note: Datastring for STK hash is phone + vid + amount + account (different from C2B).
Process credit/debit card payments via the transaction flow.
Endpoint: POST /transact
Request Example:
{
"amount": "1000.00",
"card_number": "4111111111111111",
"card_holder": "John Doe",
"exp_month": "12",
"exp_year": "2025",
"cvv": "123",
"sid": "xf8a2b1c3d4e5f6g7h8i9j0k",
"channel": "card",
"hash": "abc123def456..."
}
Response Example:
{
"status": "pending",
"transaction_id": "TXN-111222333",
"message": "Card transaction processing",
"amount": "1000.00"
}
Query transaction status by reference or transaction ID.
Endpoint: POST /transaction/search
Request Example:
{
"reference": "ORDER-12345",
"transaction_id": "TXN-987654321",
"hash": "abc123def456..."
}
Response Example:
{
"status": "success",
"transaction_id": "TXN-987654321",
"reference": "ORDER-12345",
"amount": "1000.00",
"payment_status": "paid",
"paid_amount": "1000.00",
"channel": "mpesa",
"paid_date": "2026-02-24 10:30:45"
}
Reverse a completed transaction. Refunded amount returns to customer's payment method.
Endpoint: POST /transaction/refund
Request Example:
{
"transaction_id": "TXN-987654321",
"reference": "ORDER-12345",
"amount": "1000.00",
"hash": "abc123def456..."
}
Response Example:
{
"status": "success",
"refund_id": "REFUND-123456789",
"transaction_id": "TXN-987654321",
"amount": "1000.00",
"message": "Refund processed successfully"
}
iPay sends payment status notifications to your callback URL as HTTP POST requests. Validate the webhook signature using the included hash parameter.
{
"transaction_id": "TXN-987654321",
"reference": "ORDER-12345",
"amount": "1000.00",
"phone": "254712345678",
"channel": "mpesa",
"status": "success",
"payment_status": "paid",
"paid_amount": "1000.00",
"paid_date": "2026-02-24 10:30:45",
"psp_response_code": "00",
"psp_response": "Transaction successful",
"hash": "abc123def456..."
}
<?php
// Receive webhook POST
$callback = json_decode(file_get_contents('php://input'), true);
$secret = "your_secret_key_from_dashboard";
// Construct validation string - order matters!
$datastring = $callback['amount'] . $callback['phone'] . $callback['reference'];
// Compute expected hash
$expected_hash = hash_hmac('sha256', $datastring, $secret, false);
// Verify signature
if ($callback['hash'] === $expected_hash) {
// Signature valid - process payment
echo json_encode(['status' => 'ok', 'message' => 'Payment processed']);
http_response_code(200);
} else {
// Signature mismatch - reject callback
echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
http_response_code(403);
}
?>
transaction_idTraditional web flow redirecting to iPay payment page:
POST /c2b/register to create transaction, receives sidsid parameterredirect_url// Backend: Create transaction
app.post('/api/checkout', async (req, res) => {
const { amount, phone, orderId } = req.body;
// Register transaction with iPay
const response = await fetch('https://apis.ipayafrica.com/payments/v2/c2b/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount,
phone,
reference: orderId,
redirect_url: 'https://yoursite.com/payment-complete',
hash: generateHash(amount, phone, orderId)
})
});
const { sid } = await response.json();
// Redirect to iPay payment page
res.json({
payment_url: `https://apis.ipayafrica.com/payment/${sid}`
});
});
// Receive callback
app.post('/api/callback', (req, res) => {
const callback = req.body;
// Validate signature
if (!validateSignature(callback)) {
return res.status(403).json({ error: 'Invalid signature' });
}
// Update order status
if (callback.status === 'success') {
updateOrderStatus(callback.reference, 'PAID');
}
res.status(200).json({ status: 'ok' });
});
Prompt M-Pesa PIN entry without requiring app launch:
POST /transact/push/mpesa with user's phone number// Mobile/PWA: Initiate STK push
async function initiateSTKPush(amount, phone, orderId) {
const hash = generateSTKHash(phone, merchantId, amount, account);
const response = await fetch(
'https://apis.ipayafrica.com/payments/v2/transact/push/mpesa',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone,
vid: merchantId,
amount,
account: orderId,
hash
})
}
);
const { transaction_id, status } = await response.json();
// Poll for completion (check every 3 seconds)
pollTransactionStatus(transaction_id, orderId);
}
// Poll transaction status
async function pollTransactionStatus(transactionId, orderId) {
let attempts = 0;
const maxAttempts = 20; // ~60 seconds timeout
const pollInterval = setInterval(async () => {
const { payment_status } = await searchTransaction(transactionId);
if (payment_status === 'paid') {
clearInterval(pollInterval);
completeOrder(orderId);
showSuccessMessage('Payment successful!');
} else if (payment_status === 'failed') {
clearInterval(pollInterval);
showErrorMessage('Payment failed. Please try again.');
}
attempts++;
if (attempts >= maxAttempts) {
clearInterval(pollInterval);
showErrorMessage('Payment timeout. Please try again.');
}
}, 3000);
}
Send payments directly to business partner accounts:
// Backend: Initiate B2B payout
async function payBusiness(vendorPhone, amount, reference) {
const hash = generateHash(vendorPhone, amount, reference);
const response = await fetch('https://apis.ipayafrica.com/payments/v2/b2b', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: vendorPhone,
amount,
reference,
hash
})
});
return await response.json();
}
Securely process credit/debit card payments:
// Frontend: Collect card via secure form
function processCardPayment(cardDetails, amount, reference) {
const hash = generateCardHash(cardDetails, amount, reference);
// Send to backend - NEVER expose card data to client
fetch('/api/process-card', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount,
reference,
sid: sessionId // from C2B registration
})
});
}
// Backend: Handle card securely
app.post('/api/process-card', async (req, res) => {
// Card details should ONLY be accepted from secure form
// Never send card data client-side to iPay
const hash = generateHash(
cardDetails.number,
cardDetails.cvv,
amount,
reference
);
const response = await fetch(
'https://apis.ipayafrica.com/payments/v2/transact',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount,
sid: req.body.sid,
channel: 'card',
hash,
// Card details here
})
}
);
// Handle 3DS redirect if required
const result = await response.json();
if (result.requires_3ds) {
res.json({ redirect_url: result.acs_url });
}
});
| Code | Meaning | Action |
|---|---|---|
| 200 | Success | Process response normally |
| 400 | Invalid parameters | Check request format and parameter values |
| 401 | Unauthorized | Verify API credentials and HMAC hash |
| 403 | Forbidden | Confirm merchant account is active |
| 404 | Endpoint not found | Check API endpoint URL |
| 429 | Rate limited | Implement exponential backoff retry |
| 500 | Server error | Retry request after 30 seconds |
| 503 | Service unavailable | Retry with exponential backoff |
When status is failed in response or callback, the psp_response_code contains error details:
| Code | Description | User Action |
|---|---|---|
| 00 | Success | N/A - transaction complete |
| 02 | Declined by issuer | Contact your bank |
| 05 | Insufficient funds | Add funds and retry |
| 08 | Authentication failed | Verify credentials and retry |
| 12 | Invalid transaction | Check amount and try again |
| 13 | Invalid amount | Amount outside allowed range |
| 14 | Invalid card number | Verify card number |
| 15 | No such issuer | Card network not supported |
| 19 | Re-enter transaction | Retry transaction |
| 25 | Transaction not allowed | Contact support |
| 28 | Timeout / Network error | Check connection and retry |
| 39 | Invalid merchant | Verify merchant account |
| 54 | Expired card | Use valid card |
| 55 | PIN attempts exceeded | Retry after 24 hours |
| 62 | Restricted card | Contact issuer |
| 89 | Cryptographic error | Verify hash calculation |
| 91 | Network unavailable | Retry later |
// Implement exponential backoff
async function retryRequest(requestFn, maxRetries = 5) {
let attempt = 0;
while (attempt < maxRetries) {
try {
return await requestFn();
} catch (error) {
if (error.status === 429 || error.status >= 500) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
await sleep(delay);
attempt++;
} else {
throw error; // Non-retryable error
}
}
}
throw new Error('Max retries exceeded');
}
{
"status": 400,
"errors": [
{
"field": "amount",
"message": "Amount must be greater than 1 KES"
},
{
"field": "phone",
"message": "Invalid phone format. Use 254712345678"
}
]
}
Different endpoints require parameters in different orders. Parameter order is not alphabetical and must match iPay's specification:
amount + phone + referencephone + vid + amount + accountamount + phone + referenceVerify the exact order in iPay documentation for each endpoint. A single misplaced parameter breaks signature validation.
When validating callbacks, construct the datastring from the incoming callback data, not your stored request data. Amounts may differ due to fees or currency conversion:
// CORRECT - use callback parameters
const datastring = callback.amount + callback.phone + callback.reference;
// WRONG - using stored request data
const datastring = originalRequest.amount + phone + reference;
Phone numbers must be formatted as 254712345678 (country code 254 for Kenya with leading zero removed):
// CORRECT formats
254712345678 // Kenya Safaricom
254720000000 // Kenya Airtel
254706000000 // Kenya EazzyPay
// INCORRECT formats
+254712345678 // Extra plus sign
0712345678 // Missing country code
712345678 // Missing country code AND leading zero
iPay may send multiple callbacks if network issues occur. Implement idempotency by checking transaction_id:
// Store processed transaction IDs
const processedTransactions = new Set();
app.post('/api/callback', (req, res) => {
const { transaction_id, status } = req.body;
// Skip if already processed
if (processedTransactions.has(transaction_id)) {
return res.status(200).json({ status: 'ok' });
}
// Process payment
updateOrderStatus(transaction_id, status);
processedTransactions.add(transaction_id);
res.status(200).json({ status: 'ok' });
});
The account field in STK push must match your stored reference for the transaction. The hash is computed from phone + vid + amount + account in that exact order. Mixing up reference and account will cause hash mismatches.
Transactions begin as pending after initiation. Status changes to success or failed when payment completes. Check callback webhooks or poll the search endpoint - do NOT assume immediate status change:
// WRONG - assume immediate completion
const response = await registerTransaction();
console.log(response.status); // Still "pending"!
// CORRECT - wait for callback or poll
await waitForCallback(transactionId);
Ensure you're using the correct environment:
https://apis.staging.ipayafrica.com (for testing)https://apis.ipayafrica.com (for production)Merchants have separate credentials in each environment. Using staging credentials with live endpoint results in authentication failures.
iPay only accepts HTTPS requests. HTTP requests are rejected. For development, use the staging endpoint to avoid SSL certificate issues on localhost. Never send card data or API keys over unencrypted connections.
Unlike some payment APIs, iPay does not support idempotent request headers. If you retry a failed request, ensure sufficient time has passed before retry to avoid duplicate transactions. Include your own reference ID and deduplication logic.
Production responses provide minimal error information for security. Error codes like 89 (Cryptographic error) typically indicate HMAC hash issues. Enable detailed logging and test thoroughly in staging.
Transactions may take 5-10 seconds to appear in the transaction search endpoint after completion. Implement a retry loop with delays when immediately searching for transactions.
Documentation Quality Note: This SKILL.md is based on publicly available documentation and search results as of February 2026. Official iPay documentation at dev.ipayafrica.com provides authoritative reference material. Always verify endpoint URLs, parameter requirements, and error codes against current official documentation before implementing production integrations, as payment APIs frequently update endpoints and requirements.