GOOD AFTERNOON
Loading…
Profile
Customers
People and contacts linked to your payments
| TYPE / NAME | CONTACT | DATE |
|---|---|---|
| Loading… | ||
Payment channels
Manage the paybill and till accounts your customers can pay into
| CHANNEL ID | CHANNEL TYPE | ACCOUNT NUMBER | STATUS | DATE CREATED | ACTION |
|---|---|---|---|---|---|
| Loading… | |||||
Add a payment channel
Connect your bank, paybill or till so you can receive payments
● Channel preview
See how your payment channels appear when you collect money.
You can add multiple channels and choose which one to use when initiating payments.
Create STK Push
Initiate an M-Pesa payment request to a customer's phone
● Fast, secure payment
The customer receives an M-Pesa PIN prompt on their phone.
Successful payments are credited to your configured channel.
Transactions
Search, filter and export your payment history
| STATUS | AMOUNT | GATEWAY | CHANNEL | PROVIDER REF | EXTERNAL REF | DESCRIPTION | DATE |
|---|---|---|---|---|---|---|---|
| Loading… | |||||||
Pricing
Transaction fees by amount range
| AMOUNT FROM | AMOUNT TO | TRANSACTION FEE |
|---|---|---|
| Loading… | ||
API Keys
Manage the credentials your systems use to access the API
| NAME | API USERNAME | ACCOUNT ID | CREATED | ACTIONS |
|---|---|---|---|---|
| Loading… | ||||
Identity Verification
Verify your identity to unlock payment channels, STK Push, and API keys
Admin Overview
Real-time system activity and key metrics
All Users
Manage accounts and KYC status across the platform
All Transactions
Every STK Push transaction across all accounts
KYC Reviews
Review and verify identity documents submitted by users
API Documentation
Integrate ImaraPay M-Pesa payments into your application
Overview
ImaraPay lets you trigger M-Pesa STK Push payments directly to your customers from your backend. Money is deposited straight into your registered payment channel (Till, Paybill, or Bank account) — you are charged a small per-transaction service fee from your ImaraPay service wallet.
All API requests use HTTPS. The base URL for every endpoint is:
Authentication
Every API request (except public endpoints like /api/pricing) requires an API key pair. You can create keys on the API Keys page inside your ImaraPay dashboard.
When you create a key you receive an API Username and an API Password. Combine them into an HTTP Basic Auth header:
Authorization: Basic base64(API_USERNAME:API_PASSWORD)
Getting your token
const API_USERNAME = 'your_api_username';
const API_PASSWORD = 'your_api_password';
const BASE_URL = 'https://YOUR_DOMAIN/api';
// Build the Basic Auth header once, reuse for every request
const AUTH_HEADER = 'Basic ' + btoa(API_USERNAME + ':' + API_PASSWORD);
const headers = {
'Content-Type': 'application/json',
'Authorization': AUTH_HEADER,
};
import requests
import base64
API_USERNAME = "your_api_username"
API_PASSWORD = "your_api_password"
BASE_URL = "https://YOUR_DOMAIN/api"
credentials = base64.b64encode(f"{API_USERNAME}:{API_PASSWORD}".encode()).decode()
AUTH_HEADER = f"Basic {credentials}"
HEADERS = {
"Content-Type": "application/json",
"Authorization": AUTH_HEADER,
}
Security
Use environment variables
Store your credentials in environment variables — never hard-code them or commit them to source control.
IMARAPAY_API_USERNAME=your_api_username
IMARAPAY_API_PASSWORD=your_api_password
IMARAPAY_BASE_URL=https://your-imarapay-domain.com/api
// config/imarapay.js — import this module, never build the token inline
const AUTH_HEADER = 'Basic ' + Buffer.from(
`${process.env.IMARAPAY_API_USERNAME}:${process.env.IMARAPAY_API_PASSWORD}`
).toString('base64');
const BASE_URL = process.env.IMARAPAY_BASE_URL;
module.exports = { AUTH_HEADER, BASE_URL };
import os, base64
_creds = f"{os.environ['IMARAPAY_API_USERNAME']}:{os.environ['IMARAPAY_API_PASSWORD']}"
AUTH_HEADER = 'Basic ' + base64.b64encode(_creds.encode()).decode()
BASE_URL = os.environ['IMARAPAY_BASE_URL']
Key rotation
If you suspect a key has been compromised, delete it immediately from the API Keys page and create a new one. Old keys are invalidated instantly. The Basic Auth password is shown only once at creation time — if lost, delete and recreate the key.
Quick Start
Get a payment working in under 5 minutes:
- Create an account on ImaraPay and complete identity verification (KYC).
- Go to Payment Channels and add your Till, Paybill, or Bank account.
- Go to API Keys and create a key — copy the Username, Password, and Basic Auth token.
- Top up your service wallet with a small amount to cover transaction fees.
- Call POST /api/stk-push with your channel ID, the customer's phone, and the amount.
Initiate STK Push
Sends an M-Pesa payment prompt to the customer's phone. Money is deposited directly into your payment channel. The fee is deducted from your service wallet at the time of the request.
Request parameters
| Field | Type | Description |
|---|---|---|
| account_id required | integer | ID of the payment channel to deposit funds into. Retrieve from GET /api/accounts. |
| phone_number required | string | Customer's M-Pesa phone number. Accepts formats: 0712345678, 254712345678, +254712345678. |
| amount required | integer | Amount in KES (whole numbers only). Minimum: 1, Maximum: 999,999. |
| external_reference optional | string | Your own order/invoice reference. Stored on the transaction for reconciliation. |
| description optional | string | Short description shown in M-Pesa. Defaults to Payment. |
async function stkPush({ accountId, phone, amount, reference, description }) {
const res = await fetch(`${BASE_URL}/stk-push`, {
method: 'POST',
headers,
body: JSON.stringify({
account_id: accountId,
phone_number: phone,
amount: amount,
external_reference: reference,
description: description,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'STK push failed');
}
return res.json();
// { success: true, transaction_id: 42, checkout_request_id: "ws_CO_...", fee_to_be_charged: 6 }
}
// Example usage
const result = await stkPush({
accountId: 1,
phone: '0712345678',
amount: 500,
reference: 'INV-001',
description: 'Order #1001',
});
console.log('Checkout ID:', result.checkout_request_id);
def stk_push(account_id, phone, amount, reference=None, description="Payment"):
payload = {
"account_id": account_id,
"phone_number": phone,
"amount": amount,
"external_reference": reference,
"description": description,
}
response = requests.post(f"{BASE_URL}/stk-push", json=payload, headers=HEADERS)
response.raise_for_status()
return response.json()
# { "success": True, "transaction_id": 42,
# "checkout_request_id": "ws_CO_...", "fee_to_be_charged": 6 }
# Example usage
result = stk_push(
account_id=1,
phone="0712345678",
amount=500,
reference="INV-001",
description="Order #1001",
)
print("Checkout ID:", result["checkout_request_id"])
Success response
{
"success": true,
"message": "STK push sent successfully",
"transaction_id": 42,
"checkout_request_id": "ws_CO_123456789_20240615123456_1_254712345678",
"fee_to_be_charged": 6
}
Check Transaction Status
M-Pesa payments are asynchronous. After initiating an STK Push, poll this endpoint every 3–5 seconds until the status is completed or failed. Stop polling after 90 seconds.
async function pollTransactionStatus(checkoutRequestId, timeoutMs = 90000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(
`${BASE_URL}/transaction/status/${checkoutRequestId}`,
{ headers }
);
const data = await res.json();
if (data.status === 'completed') {
console.log('Payment successful! Receipt:', data.mpesa_receipt);
return data;
}
if (data.status === 'failed') {
throw new Error(data.result_desc || 'Payment failed');
}
// Still pending — wait 3 seconds and try again
await new Promise(r => setTimeout(r, 3000));
}
throw new Error('Payment timed out — please ask the customer to check M-Pesa');
}
import time
def poll_transaction_status(checkout_request_id, timeout=90):
deadline = time.time() + timeout
while time.time() < deadline:
response = requests.get(
f"{BASE_URL}/transaction/status/{checkout_request_id}",
headers=HEADERS,
)
data = response.json()
if data["status"] == "completed":
print("Payment successful! Receipt:", data["mpesa_receipt"])
return data
if data["status"] == "failed":
raise Exception(data.get("result_desc", "Payment failed"))
time.sleep(3) # wait 3 seconds before polling again
raise Exception("Payment timed out")
Status response
{
"status": "completed",
"amount": 500,
"mpesa_receipt": "RGH8KXXXX",
"result_desc": "The service request is processed successfully.",
"balance": 94
}
The status field will be one of: pending · completed · failed
Webhooks (Callbacks)
Instead of polling, you can register a Callback URL on your account. ImaraPay will instantly POST the payment result to your server as soon as M-Pesa confirms it — no polling needed.
Setting your callback URL
Set your callback URL once via the API or inside Account Settings in your dashboard.
await fetch(`${BASE_URL}/profile`, {
method: 'PUT',
headers,
body: JSON.stringify({
callback_url: 'https://your-app.com/payment/webhook',
}),
});
import requests
requests.put(
f'{BASE_URL}/profile',
headers=headers,
json={'callback_url': 'https://your-app.com/payment/webhook'},
)
Webhook payload
ImaraPay sends a clean, consistent JSON body to your URL. The event field tells you whether the payment succeeded or failed. A header X-ImaraPay-Event is also included.
Successful payment
{
"event": "payment.completed",
"checkout_request_id": "ws_CO_123456789_20240615123456_1_254712345678",
"external_reference": "INV-001",
"phone_number": "254712345678",
"amount": 500,
"mpesa_receipt": "RGH8KXXXX",
"result_code": "0",
"result_desc": "Payment successful",
"timestamp": "2024-06-15T12:35:14.000Z"
}
Failed / cancelled payment
{
"event": "payment.failed",
"checkout_request_id": "ws_CO_123456789_20240615123456_1_254712345678",
"external_reference": "INV-001",
"phone_number": "254712345678",
"amount": 500,
"mpesa_receipt": null,
"result_code": "1032",
"result_desc": "The payment request was not completed. Please try again",
"timestamp": "2024-06-15T12:35:14.000Z"
}
When event is payment.completed the payment succeeded — use mpesa_receipt as the M-Pesa confirmation code. When event is payment.failed, mpesa_receipt is null and result_desc contains the reason.
Handling the webhook
app.post('/payment/webhook', express.json(), (req, res) => {
// Respond 200 immediately — ImaraPay does not retry, but fast ACK is good practice
res.json({ received: true });
const { event, checkout_request_id, external_reference,
phone_number, amount, mpesa_receipt, result_desc } = req.body;
if (event === 'payment.completed') {
// TODO: mark the order/invoice as paid in your DB
console.log(`Payment confirmed: ${mpesa_receipt} — KES ${amount} for ${external_reference}`);
} else if (event === 'payment.failed') {
// TODO: notify the customer or allow retry
console.log(`Payment failed for ${external_reference}: ${result_desc}`);
}
});
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/payment/webhook', methods=['POST'])
def payment_webhook():
data = request.get_json(force=True) or {}
# Respond 200 immediately
response = jsonify(received=True)
event = data.get('event')
checkout_request_id = data.get('checkout_request_id')
external_reference = data.get('external_reference')
amount = data.get('amount')
mpesa_receipt = data.get('mpesa_receipt')
result_desc = data.get('result_desc', '')
if event == 'payment.completed':
# TODO: mark the order/invoice as paid in your DB
print(f"Payment confirmed: {mpesa_receipt} — KES {amount} for {external_reference}")
elif event == 'payment.failed':
# TODO: notify the customer or allow retry
print(f"Payment failed for {external_reference}: {result_desc}")
return response
Payment Channels
A payment channel is a Till, Paybill, or Bank account where funds are deposited. You must pass a channel's id when initiating an STK Push.
List your channels
const res = await fetch(`${BASE_URL}/accounts`, { headers });
const channels = await res.json();
// Find your active till channel
const till = channels.find(c => c.account_type === 'till' && c.is_active);
console.log('Till channel ID:', till?.id);
response = requests.get(f"{BASE_URL}/accounts", headers=HEADERS)
channels = response.json()
# Find your active paybill channel
paybill = next((c for c in channels if c["account_type"] == "paybill" and c["is_active"]), None)
print("Paybill channel ID:", paybill["id"] if paybill else "None")
Channel object
{
"id": 1,
"name": "Paybill 247247",
"account_type": "paybill", // "till" | "paybill" | "bank"
"account_number": "247247",
"account_reference": "247247",
"bank_name": null, // populated only for bank channels
"is_active": true,
"created_at": "2024-06-15T08:00:00.000Z"
}
Add a channel
| Field | Type | Description |
|---|---|---|
| account_type required | string | till · paybill · bank |
| account_number required | string | Till number, Paybill number, or Bank account number. |
| bank_name bank only | string | Bank name (e.g. Equity Bank). Call GET /api/banks for the full list. |
| account_reference optional | string | Account reference sent to M-Pesa. Defaults to account_number. |
Transactions
Query past transactions for reconciliation, reporting, or showing payment history.
List transactions
| Query param | Type | Description |
|---|---|---|
| status optional | string | pending · completed · failed |
| account_id optional | integer | Filter by channel ID. |
| limit optional | integer | Max results (default 100). |
// Get last 50 completed transactions
const res = await fetch(`${BASE_URL}/transactions?status=completed&limit=50`, { headers });
const txs = await res.json();
txs.forEach(tx => {
console.log(`${tx.mpesa_receipt} | KES ${tx.amount} | ${tx.phone_number} | ${tx.external_reference}`);
});
params = {"status": "completed", "limit": 50}
response = requests.get(f"{BASE_URL}/transactions", headers=HEADERS, params=params)
txs = response.json()
for tx in txs:
print(f"{tx['mpesa_receipt']} | KES {tx['amount']} | {tx['phone_number']}")
Transaction object
{
"id": 42,
"phone_number": "254712345678",
"amount": 500,
"fee_charged": 6,
"status": "completed",
"mpesa_receipt": "RGH8KXXXX",
"external_reference": "INV-001",
"description": "Order #1001",
"checkout_request_id": "ws_CO_...",
"result_desc": "The service request is processed successfully.",
"account_name": "Till 3118761",
"channel": "Till - 3118761",
"created_at": "2024-06-15T12:34:56.000Z",
"completed_at": "2024-06-15T12:35:14.000Z"
}
Error Codes
HTTP errors
| Status | Code | Meaning |
|---|---|---|
| 400 | Bad Request | Missing or invalid parameters. Check the error field for details. |
| 401 | Unauthorized | Missing or invalid API credentials. |
| 402 | Payment Required | Insufficient service wallet balance to cover the fee. |
| 403 | Forbidden | KYC verification required, or resource belongs to another account. |
| 404 | Not Found | Channel, transaction, or resource does not exist. |
| 500 | Server Error | Unexpected error. Retry once — if it persists, contact support. |
M-Pesa result codes
These appear in the result_code / ResultCode field of a callback or transaction status response.
| Code | Cause | Action |
|---|---|---|
| 0 | Success | Payment confirmed. Credit the customer. |
| 1032 | Request cancelled by user | Ask the customer to try again. |
| 1037 | No response from customer (timeout) | Prompt timed out on their phone. Ask them to try again. |
| 1 | Insufficient balance | Customer has insufficient M-Pesa balance. |
| 17 | M-Pesa system error | Retry after a few seconds. |
| 26 | Request throttled | Too many requests. Back off and retry. |
| 2001 | Invalid initiator | Check your Daraja credentials configuration. |
Error response shape
All error responses return a JSON body with an error string. The 402 response for insufficient wallet funds also includes balance details.
// Standard error (4xx / 5xx)
{ "error": "account_id, phone_number and amount are required" }
// 402 — insufficient wallet balance
{
"error": "Insufficient funds in your service wallet. You need KES 6.00 but have KES 2.50. Please top up your wallet to continue.",
"required_balance": 6,
"current_balance": 2.5
}
Handling errors in code
try {
const result = await stkPush({ accountId: 1, phone: '0712345678', amount: 500 });
const status = await pollTransactionStatus(result.checkout_request_id);
console.log('Done:', status.mpesa_receipt);
} catch (err) {
if (err.message.includes('Insufficient service balance')) {
// Redirect merchant to top up their ImaraPay wallet
console.error('Top up your service wallet first');
} else {
// Show the M-Pesa error to the customer
console.error('Payment error:', err.message);
}
}
import requests
try:
result = stk_push(account_id=1, phone="0712345678", amount=500)
status = poll_transaction_status(result["checkout_request_id"])
print("Done:", status["mpesa_receipt"])
except requests.HTTPError as e:
body = e.response.json()
if e.response.status_code == 402:
print("Top up your service wallet first")
else:
print("API error:", body.get("error"))
except Exception as e:
print("Payment error:", str(e))
Pricing
ImaraPay charges a flat per-transaction fee based on the payment amount. There are no monthly fees, setup fees, or percentage-based charges.
Fees are deducted from your service wallet at the time the STK push is sent, and refunded automatically if the customer cancels or the push fails.
The fee schedule is available at:
// Public endpoint — no auth required
const res = await fetch(`${BASE_URL}/pricing`);
const tiers = await res.json();
// Find the fee for a given amount
function getFee(amount) {
const tier = tiers.find(t => amount >= t.min && amount <= t.max);
return tier ? tier.fee : 0;
}
console.log(getFee(500)); // 6
console.log(getFee(1000)); // 15
console.log(getFee(10000)); // 50
# Public endpoint — no auth required
response = requests.get(f"{BASE_URL}/pricing")
tiers = response.json()
def get_fee(amount):
tier = next((t for t in tiers if t["min"] <= amount <= t["max"]), None)
return tier["fee"] if tier else 0
print(get_fee(500)) # 6
print(get_fee(1000)) # 15
print(get_fee(10000)) # 50
View the full fee table inside your dashboard on the Pricing page.
TypeScript Types
Copy these interfaces into your project for full type safety when calling the ImaraPay API.
// ── Request types ────────────────────────────────────────────────────────────
export interface StkPushRequest {
account_id: number; // ID from GET /api/accounts
phone_number: string; // 0712345678 | 254712345678 | +254712345678
amount: number; // KES, min 1, max 999999
external_reference?: string; // Your order/invoice reference
description?: string; // Shown in M-Pesa prompt. Default: "Payment"
}
// ── Response types ───────────────────────────────────────────────────────────
export interface StkPushResponse {
success: boolean;
message: string;
transaction_id: number;
checkout_request_id: string;
fee_to_be_charged: number;
}
export interface TransactionStatusResponse {
status: 'pending' | 'completed' | 'failed';
amount: number;
mpesa_receipt: string | null;
result_desc: string;
balance: number;
}
export interface Transaction {
id: number;
phone_number: string;
amount: number;
fee_charged: number;
status: 'pending' | 'completed' | 'failed';
mpesa_receipt: string | null;
external_reference: string | null;
description: string;
checkout_request_id: string;
result_desc: string | null;
account_name: string | null;
channel: string | null;
created_at: string; // ISO 8601
completed_at: string | null;
}
export interface Channel {
id: number;
name: string;
account_type: 'till' | 'paybill' | 'bank';
account_number: string;
account_reference: string;
bank_name: string | null;
is_active: boolean;
created_at: string;
}
// ── Webhook payload ──────────────────────────────────────────────────────────
export interface WebhookPayload {
event: 'payment.completed' | 'payment.failed';
checkout_request_id: string;
external_reference: string | null;
phone_number: string;
amount: number;
mpesa_receipt: string | null; // null on failure
result_code: string;
result_desc: string;
timestamp: string; // ISO 8601
}
// ── Error response ───────────────────────────────────────────────────────────
export interface ImaraPayError {
error: string;
required_balance?: number; // present on 402
current_balance?: number; // present on 402
}
Best Practices
Idempotency with external_reference
Always pass a unique external_reference — your order ID, invoice number, or any key from your system. This makes it trivial to reconcile ImaraPay transactions against your own records and to detect duplicate payments.
// Use a stable reference from your system — not a random ID
await stkPush({
accountId: 1,
phone: '0712345678',
amount: 500,
reference: `INV-${invoice.id}`, // e.g. INV-1042
description: `Payment for ${invoice.description}`,
});
stk_push(
account_id=1,
phone="0712345678",
amount=500,
reference=f"INV-{invoice.id}",
description=f"Payment for {invoice.description}",
)
Retry strategy
Retrying the wrong errors creates duplicate charges. Follow this rule:
| Scenario | Retry? | Why |
|---|---|---|
| Network timeout / connection reset | Yes — check status first | Call GET /api/transaction/status/:id before retrying to confirm the push was not created. |
| 500 Server Error | Yes — once, after 2s | Transient server error. If it persists, contact support. |
| 402 Insufficient wallet | No — top up first | Top up the service wallet, then initiate a new push. |
| 400 Bad Request | No | Fix the parameters — retrying the same payload will fail again. |
| M-Pesa code 1032 / 1037 | Yes — with customer consent | Prompt the customer and initiate a fresh STK push only if they agree. |
Check wallet balance before pushing
Avoid failed requests by checking the wallet balance before initiating a push. Call GET /api/pricing to get the fee for the amount, then compare against GET /api/wallet.
async function ensureSufficientBalance(amount) {
const [pricingRes, walletRes] = await Promise.all([
fetch(`${BASE_URL}/pricing`),
fetch(`${BASE_URL}/wallet`, { headers }),
]);
const tiers = await pricingRes.json();
const wallet = await walletRes.json();
const tier = tiers.find(t => amount >= t.min && amount <= t.max);
const fee = tier?.fee ?? 0;
if (wallet.balance < fee) {
throw new Error(`Top up your ImaraPay wallet — need KES ${fee}, have KES ${wallet.balance}`);
}
}
def ensure_sufficient_balance(amount):
tiers = requests.get(f"{BASE_URL}/pricing").json()
wallet = requests.get(f"{BASE_URL}/wallet", headers=headers).json()
tier = next((t for t in tiers if t["min"] <= amount <= t["max"]), None)
fee = tier["fee"] if tier else 0
if wallet["balance"] < fee:
raise ValueError(f"Top up your ImaraPay wallet — need KES {fee}, have KES {wallet['balance']}")