LIPAWIN API — the simple guide
Three things you can do with one API key. Pick the one you need; each has its own short section with copy-paste code.
Customer gets an M-Pesa prompt on their phone, enters PIN, you get notified.
Go to STK push →Customer in Uganda, Tanzania, Nigeria… sends KES to your M-Pesa number. We confirm it for you.
Go to International →Your website pays any M-Pesa number: refunds, winnings, commissions.
Go to Payouts →Base URL for every request: https://lipalink.co.ke · All requests and responses are JSON · HTTPS only.
Your first payment in 5 minutes
- Get your API key. Dashboard → Integration. It starts with
pk_live_. Keep it on your server only. - Make sure you can be billed. Complete KYC, then top up your wallet (Wallet → Top up) or activate a subscription. Without this the API answers
402. Details. - Create a business. Businesses page → Add. Note its
business_idand set a Webhook URL (can be added later). - Send a prompt to your own phone. Run the command below from a terminal (replace the key, your number and business_id). Your phone will ring with an M-Pesa prompt.
- Check the result. Call the status endpoint, or watch your webhook. Done — you have collected your first payment.
curl -X POST "https://lipalink.co.ke/api/stk_push.php" \
-H "Content-Type: application/json" \
-H "X-Api-Key: pk_live_YOUR_KEY" \
-d '{"amount":10,"msisdn":"2547XXXXXXXX","business_id":1,"reference":"TEST-1"}'
# Then check it (use the transaction_id from the response)
curl "https://lipalink.co.ke/api/transaction_status.php?transaction_id=PFX..." -H "X-Api-Key: pk_live_YOUR_KEY"
Your API key
Send it in a header on every request. One key works for every product.
X-Api-Key: pk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
KYC must be approved before live payments. Rate limits are generous for normal use; a 429 means slow down and retry.
Credits, plans & fees — in plain words
Your customers are never charged by LIPAWIN. You pay for the service in one of two ways, and the API checks this before it sends a prompt:
Top up a prepaid wallet by M-Pesa (Wallet → Top up). A small fee is taken from the wallet only when a payment succeeds. Failed or cancelled prompts cost nothing.
If the wallet can't cover the next fee → 402 Insufficient wallet balance.
Fixed monthly price with a monthly number of transactions. No per-payment fee. When used up → 402 Monthly transaction limit reached until upgrade/renewal. We email you before that.
Pay-as-you-go fee (wallet)
| Payment amount (KES) | Fee per successful payment |
|---|---|
| 1.00 – 20.00 | KES 1.00 |
| 21.00 – 100.00 | KES 2.00 |
| 101.00 – 500.00 | KES 4.00 |
| 501.00 – 2,000.00 | KES 8.00 |
| 2,001.00 – 50,000.00 | KES 30.00 |
| 20,000.01 – and above | KES 50.00 |
Subscription plans
| Plan | Billing | Price | Transactions / month |
|---|---|---|---|
| ultimate | monthly | KES 1,000.00 | 500 |
| Weekly | weekly | KES 250.00 | 100 |
| Analytics Pro | monthly | KES 1,800.00 | 1,000 |
Paybill collections (My Paybill)
If you use the platform paybill 4160829 with your own account number, each payment is credited to your collections balance minus a platform fee. The fee is taken once; if a payment is reversed later, the fee is not charged to you.
6.00% of each payment.
Withdrawals
Collections balance → your M-Pesa. Minimum KES 0.00; charge KES 0.00 + 5% per withdrawal. Once approved it is paid automatically by M-Pesa B2C — you get an SMS from M-Pesa and an email with the receipt, and it shows as Paid.
403 Your API access is paused) and you are emailed. The admin reviews and restores your access. After a restore the failure counter starts from zero.What each 402 message means
| Message | Fix |
|---|---|
| No active billing plan. Subscribe or top up your wallet. | Top up the wallet or subscribe from the dashboard. |
| Insufficient wallet balance. Need KES X for commission. | Wallet can't cover the next fee — top up. |
| Monthly transaction limit reached (N). | Upgrade the plan or wait for renewal. |
Collect in Kenya — STK push
What happens: you send the customer's number and amount → their phone shows the M-Pesa prompt → they enter PIN → Safaricom tells us the result → we tell you (status endpoint + webhook).
/api/stk_push.phpcurl -X POST "https://lipalink.co.ke/api/stk_push.php" \
-H "Content-Type: application/json" -H "X-Api-Key: YOUR_API_KEY" \
-d '{ "amount": 100, "msisdn": "254712345678", "business_id": 12,
"reference": "ORDER-001", "description": "Order 1" }'
# Response
{ "success": true, "transaction_id": "PFX20260101ABCDEF", "status": "Pending",
"message": "STK push sent. Ask the customer to enter their M-Pesa PIN." }
| Field | Required | What to send |
|---|---|---|
| amount | yes | KES, minimum 1 (platform limits may apply) |
| msisdn | yes | Customer's number: 2547XXXXXXXX or 2541XXXXXXXX. 07… is accepted and converted. |
| business_id | yes | One of your active businesses (Businesses page) |
| reference | no | Your order id — the customer sees it as the account reference (letters/digits, ≤20) |
| description | no | For your records only |
Success, Failed or Cancelled. Nothing is failed on a timer — a pending prompt is re-checked automatically for 24 hours.Copy-paste: PHP, Node.js, Python
$ch = curl_init('https://lipalink.co.ke/api/stk_push.php');
curl_setopt_array($ch, [CURLOPT_POST=>1, CURLOPT_RETURNTRANSFER=>1,
CURLOPT_HTTPHEADER=>['Content-Type: application/json', 'X-Api-Key: '.API_KEY],
CURLOPT_POSTFIELDS=>json_encode(['amount'=>100, 'msisdn'=>$phone, 'business_id'=>12, 'reference'=>$orderId])]);
$r = json_decode(curl_exec($ch), true);
if (empty($r['success'])) die($r['error']);
$txn = $r['transaction_id']; // save it with the order
Check a payment
/api/transaction_status.php?transaction_id=PFX…{ "success": true, "transaction_id": "PFX20260101ABCDEF", "status": "Success",
"amount": 100, "msisdn": "254712345678", "mpesa_receipt": "TJ7ABC12XY", "reference": "ORDER-001",
"result_desc": "The service request is processed successfully.",
"transaction_date": "2026-10-07 10:14:00", "currency": "KES", "country": "KEN" }
status is Pending, Success, Failed or Cancelled. Poll every 3 seconds from your server and stop on anything but Pending. Works for international payments too (then you also get country and provider: "MPESA_XB").
Webhook — get notified automatically
Set a Webhook URL on your business (Businesses page). Every confirmed payment is POSTed to it as JSON. If your server doesn't answer 2xx we retry with back-off for about 3 hours; you can resend from the Integration page.
POST https://yoursite.com/webhook
X-Pesaflux-Event: payment.success
X-Pesaflux-Signature: t=1760000000,v1=9f2c…
{ "event": "payment.success", "transaction_id": "PFX20260101ABCDEF", "status": "Success",
"amount": 1500, "currency": "KES", "amount_kes": 1500, "fx_rate": 1,
"msisdn": "256771234567", "mpesa_receipt": "TJ7ABC12XY", "reference": "ORDER-001",
"business_id": 12, "country": "UGA", "provider": "MPESA_XB", "redirected": false,
"transaction_date": "2026-10-07 10:14:00", "test": false }
Verify it is really from us (3 lines)
The signature is HMAC-SHA256 of "<t>.<raw body>" using your API key.
$raw = file_get_contents('php://input');
[$t, $v1] = array_map(fn($p) => explode('=', $p, 2)[1], explode(',', $_SERVER['HTTP_X_PESAFLUX_SIGNATURE']));
$ok = hash_equals(hash_hmac('sha256', "$t.$raw", API_KEY), $v1) && abs(time() - (int)$t) < 600;
if (!$ok) { http_response_code(400); exit; }
$e = json_decode($raw, true);
if ($e['status'] === 'Success' && empty($e['test'])) { /* mark order $e['reference'] paid — once per transaction_id */ }
http_response_code(200);
transaction_id can be delivered twice — handle it once.Collect from other countries — how it works
Customers in Tanzania, Uganda, Rwanda, Zambia, Malawi, Ethiopia, DRC, Mozambique, Botswana, Nigeria, Ghana, South Africa and the diaspora pay straight to your own M-Pesa number from their mobile money (MTN MoMo, Airtel Money, Vodacom M-Pesa, Orange Money) or a transfer app (Grey, Eversend, LemFi, Mukuru, WorldRemit, Remitly, Sendwave…). It lands in KES in your M-Pesa. LIPAWIN never holds the money — it only confirms it.
- You create an expected payment (
POST /api/collect.php). We return the amount, your number and the steps for the customer's country. - The customer sends the money from their own app: Send Money → International → Kenya M-Pesa → your number, exact KES amount.
- M-Pesa texts your phone. A free forwarder app on that phone relays the SMS to LIPAWIN.
- We match it (amount + sender number) →
Success→ webhook + receipt.
msisdn): that is how the money is recognised instantly and safely.International — one-time setup (5 minutes, no code)
- Enable international for your account (ask the admin if it isn't on for everyone).
- Open Receive via M-Pesa and save the Safaricom number that will receive the money, plus the name on the line.
- On the Android phone with that SIM, tap the install button for the free forwarder app (SMS to URL Forwarder or MacroDroid) and paste your private bridge URL. The page shows Online after the first message.
- Set a Webhook URL on your business.
Until step 2 is done, /api/collect.php answers 409. If the phone goes offline while payments are pending, you (and the admin) are emailed — you can confirm manually by pasting the M-Pesa message on the same page.
Create an expected payment
/api/collect.phpcurl -X POST "https://lipalink.co.ke/api/collect.php" \
-H "Content-Type: application/json" -H "X-Api-Key: YOUR_API_KEY" \
-d '{ "amount": 1500, "msisdn": "256771234567", "business_id": 12,
"reference": "ORDER-1001", "country": "UGA" }'
# Response (show these to the customer)
{ "success": true, "transaction_id": "PFX20260101ABCDEF", "status": "Pending", "gateway": "mpesa_xb",
"country": "UGA", "country_name": "Uganda", "currency": "KES", "amount": 1500,
"local_currency": "UGX", "local_estimate": 42254, "customer_phone": "256771234567",
"pay_to": { "type": "mpesa_number", "number": "254712345678", "number_display": "0712 345 678", "name": "ACME SHOP" },
"steps": [ "Open MTN MoMo or Airtel Money", "Choose Send Money → International → Kenya (M-Pesa)",
"Enter the number and the amount shown", "Confirm with your PIN" ] }
| Field | Required | What to send |
|---|---|---|
| amount | yes | KES you must receive, 1 – 250,000 |
| msisdn | strongly recommended | Customer's number in international format without + or 0, e.g. 256771234567. Kenyan numbers are rejected (use STK). |
| business_id | yes | One of your active businesses |
| reference | no | Your order id (≤100 chars) |
| country | no | ISO-3 code (see Countries); detected from msisdn if omitted; OTH for transfer apps / diaspora |
| test | no | true → sandbox transaction (Test mode) |
Calling again for the same customer + amount within 10 minutes returns the same transaction ("duplicate": true) — safe to retry. Expected payments stay open 24 h, then become Failed (EXPIRED); a late SMS still corrects them to Success for up to 72 h.
Website code (copy-paste)
Three small pieces: create the expected payment on your server, show the instructions, poll the status while the customer pays.
// 1) checkout.php — create the expected payment (server side)
$ch = curl_init('https://lipalink.co.ke/api/collect.php');
curl_setopt_array($ch, [CURLOPT_POST=>1, CURLOPT_RETURNTRANSFER=>1,
CURLOPT_HTTPHEADER=>['Content-Type: application/json','X-Api-Key: '.API_KEY],
CURLOPT_POSTFIELDS=>json_encode(['amount'=>1500,'msisdn'=>$_POST['phone'],'business_id'=>12,'reference'=>$orderId])]);
$r = json_decode(curl_exec($ch), true);
if (empty($r['success'])) die($r['error']);
saveOrderTxn($orderId, $r['transaction_id']);
// 2) show $r['amount'], $r['pay_to']['number_display'], $r['pay_to']['name'], $r['steps'] and start polling
// 3) order-status.php — the browser polls this every 3 s (your server adds the key)
$t = json_decode(file_get_contents('https://lipalink.co.ke/api/transaction_status.php?transaction_id='.$txn, false,
stream_context_create(['http'=>['header'=>"X-Api-Key: ".API_KEY]])), true);
echo json_encode(['status'=>$t['status'], 'receipt'=>$t['mpesa_receipt'] ?? null]);
<!-- browser: what to show and how to wait -->
<p>Send <b>KES 1,500</b> (≈ UGX 42,254) to M-Pesa Kenya <b>0712 345 678</b> (ACME SHOP) from your mobile money app.</p>
<script>
const iv = setInterval(async () => {
const d = await fetch('/order-status.php?order=1001').then(r => r.json());
if (d.status === 'Success') { clearInterval(iv); showPaid(d.receipt); }
else if (d.status === 'Failed' || d.status === 'Cancelled') { clearInterval(iv); showFailed(); }
}, 3000); // keep going up to ~30 min; transfers can take 1–10 min
</script>
Customer code fallback
/api/claim_code.phpIf the customer paid from a different number, or the SMS is slow, let them type the M-Pesa code from their own confirmation message. If the SMS already arrived it is matched at once; otherwise the code is remembered and matched when it arrives. The amount is always verified.
{ "transaction_id": "PFX20260101ABCDEF", "code": "TJ7ABC12XY" }
→ { "success": true, "transaction_id": "PFX…", "status": "Success" | "Pending", "message": "…" }
Countries
| Code | Country | Customer pays from | Local estimate |
|---|---|---|---|
TZA | Tanzania | M-Pesa (Vodacom), Tigo Pesa or Airtel Money | TZS |
UGA | Uganda | MTN MoMo or Airtel Money | UGX |
RWA | Rwanda | MTN MoMo or Airtel Money | RWF |
ZMB | Zambia | MTN MoMo or Airtel Money | ZMW |
MWI | Malawi | Airtel Money or TNM Mpamba | MWK |
ETH | Ethiopia | Safaricom Ethiopia M-Pesa or telebirr | ETB |
COD | DR Congo | M-Pesa (Vodacom), Airtel Money or Orange Money | CDF |
MOZ | Mozambique | M-Pesa (Vodacom) | MZN |
BWA | Botswana | Orange Money | BWP |
NGA | Nigeria | a transfer app that sends to M-Pesa Kenya (Grey, Eversend, LemFi, Flutterwave Send) | NGN |
GHA | Ghana | a transfer app that sends to M-Pesa Kenya (Eversend, LemFi, Chipper) | GHS |
ZAF | South Africa | Mukuru, Eversend or your bank's international transfer | ZAR |
OTH | Other country / transfer app | Use WorldRemit, Remitly, Sendwave, Wise or any app that pays to M-Pesa Kenya | USD |
Local amounts are estimates only — the customer always sends the exact KES amount.
Payment Links — the easiest integration
Create a link per order, send it to the customer (SMS, WhatsApp, email, your app) and wait for the webhook. The hosted page handles the country selector, instructions, live confirmation and the code fallback. One link works for Kenyan customers (STK prompt) and international customers.
/api/payment_link.phpcurl -X POST "https://lipalink.co.ke/api/payment_link.php" \
-H "Content-Type: application/json" -H "X-Api-Key: YOUR_API_KEY" \
-d '{ "title": "Order 1001", "amount": 1500, "business_id": 12, "reference": "ORDER-1001",
"audience": "both", "customer_phone": "256771234567", "single_use": true,
"expires_in": 86400, "success_url": "https://yoursite.com/thanks",
"metadata": { "order_id": 1001 } }'
# Response
{ "success": true, "link_id": 88, "slug": "3f9a2c1d7e5b4a60",
"url": "https://lipalink.co.ke/pay/?s=3f9a2c1d7e5b4a60",
"amount": 1500, "currency": "KES", "reference": "ORDER-1001", "audience": "both",
"single_use": true, "is_active": true, "expires_at": "2026-10-08 11:20:00", "pay_count": 0 }
| Field | Required | What to send |
|---|---|---|
| amount | no | KES. 0 = customer types the amount. International max 250,000. |
| business_id | yes | One of your businesses (its webhook URL is used) |
| reference | recommended | Your order id; comes back in the webhook; searchable with GET ?reference= |
| audience | no | both (default) · kenya (STK only) · intl (international only) |
| country | no | Pre-selected country (ISO-3) |
| customer_phone | no | Pre-fills the number — recommended, it is the matching key |
| single_use | no | true → link closes itself after one successful payment |
| expires_in / expires_at | no | Seconds from now, or an absolute datetime |
| success_url | no | Where to send the customer after paying |
| metadata | no | Any JSON; echoed in the webhook |
/api/payment_link.php?slug=… or ?reference=ORDER-1001{ "success": true, "slug": "3f9a2c1d7e5b4a60", "status": "Success", // Unpaid | Pending | Success | Failed | Cancelled
"is_active": false, "paid_txn_id": "PFX…", "pay_count": 1,
"last_transaction": { "transaction_id": "PFX…", "status": "Success", "amount": 1500,
"msisdn": "256771234567", "mpesa_receipt": "TJ7ABC12XY", "country": "UGA" } }
Deactivate: POST /api/payment_link.php with { "action": "deactivate", "slug": "…" }. Webhooks for link payments also carry link_id, link_slug and your metadata.
Typical automation (PHP): order → link → WhatsApp → webhook marks paid
$link = lipa('POST', '/api/payment_link.php', ['amount'=>$order->total, 'business_id'=>12, 'reference'=>$order->id,
'audience'=>$order->country === 'KE' ? 'kenya' : 'intl', 'customer_phone'=>$order->phone,
'single_use'=>true, 'expires_in'=>86400, 'metadata'=>['order_id'=>$order->id]]);
sendWhatsApp($order->phone, "Pay for order {$order->id}: {$link['url']}");
// later, in webhook.php:
if ($e['status']==='Success' && !$e['test']) markPaid($e['metadata']['order_id'] ?? $e['reference']);
Send money (Payouts / B2C) — how it works
Payouts are the reverse of collecting: your server says who to pay and how much, and our M-Pesa B2C line sends the money within seconds. Use it for refunds, winnings, agent commissions, supplier payments. It is a separate endpoint with separate credentials; your STK integration is untouched.
- Fund your balance. Pay to Pay Bill 4160829, account your paybill account number (My Paybill page). The paybill fee applies on the way in; the net lands in your collections balance.
- Send.
POST /api/payout.phpwith phone + amount. Amount + payout fee leave your balance immediately. - Get the result. Webhook
payout.success/payout.failed+ an email. A failed payout refunds amount + fee automatically.
Payout fee
KES 0.00 per payment — e.g. send KES 500 → KES 500.00 leaves your balance.
The admin may give your account a custom rate or exempt it. No fee on refunds.
Activation & security
Money goes out, so activation and every call have extra protection. Here is what to expect, in order:
- Pay the activation fee — one-time KES 0 by M-Pesa prompt from Dashboard → Payouts. (Not wallet credit.)
- Enter the 6-digit code we email you (valid 15 minutes).
- Our team reviews (KYC must be approved) and approves. You receive your payout secret by email — shown once, store it in your server environment.
- Call the API with your API key + payout secret (or a signature). Every result is emailed to you.
| Protection | What it does |
|---|---|
| Payout secret | Separate from the API key. Send as X-Payout-Secret, or sign requests (below). Rotate it from the dashboard — needs an email code, old secret stops at once. |
| Signed requests (recommended) | X-Payout-Timestamp: <unix seconds> + X-Payout-Signature: hex(hmac_sha256("<timestamp>.<raw body>", secret)). Requests older than 5 minutes are rejected. |
| IP allow-list | Optional list of IPs / CIDR ranges (Payouts → Security). Anything else gets 403. |
| Limits | Max KES 0 per payout and KES 0 per day by default; you can lower both. |
| Email confirmation | Payouts at or above your threshold (default KES 0) return 202 Pending_Confirm, reserve the funds and wait for you to click Approve in the email (30 min, else cancelled + refunded). The email shows the requesting IP. |
| Notifications & pause | Email for every result (success emails optional, failures always). "Pause payouts" on the Payouts page blocks the API instantly. |
| Idempotency | Send a unique reference; a retry with the same reference returns the original payout and never pays twice. |
Send a payout
/api/payout.phpcurl -X POST "https://lipalink.co.ke/api/payout.php" \
-H "Content-Type: application/json" -H "X-Api-Key: YOUR_API_KEY" -H "X-Payout-Secret: pos_…" \
-d '{ "phone": "254712345678", "amount": 500, "reference": "REFUND-1023",
"description": "Refund order 1023", "callback_url": "https://yoursite.com/payout-hook",
"metadata": { "order_id": 1023 } }'
# 201 Created — sent to M-Pesa
{ "success": true, "duplicate": false, "balance_after": 4485.00,
"payout": { "payout_ref": "PO2510111205A1F2", "reference": "REFUND-1023", "status": "Sent",
"phone": "254712345678", "amount": 500, "fee": 15, "total": 515,
"mpesa_receipt": null, "refunded": false, "created_at": "2026-10-11 12:05:33" } }
# 202 Accepted — above your confirmation threshold
{ "success": true, "payout": { "status": "Pending_Confirm", … },
"message": "Above your confirmation threshold — approve it from the email we just sent (30 minutes)." }
| Field | Required | What to send |
|---|---|---|
| phone | yes | Recipient M-Pesa number 2547XXXXXXXX / 2541XXXXXXXX (07… accepted) |
| amount | yes | KES, whole shillings. Min 0, max your per-payout limit |
| reference | recommended | Your unique id (≤64, letters/digits/._-). Same reference = same payout. |
| description | no | ≤100 chars, shown in your history |
| callback_url | no | Where to POST the result. Default: your first business webhook URL. |
| metadata | no | Any JSON (≤4 KB), echoed back |
Signed request (recommended) — PHP example
$body = json_encode(['phone'=>'254712345678', 'amount'=>500, 'reference'=>'REFUND-1023']);
$ts = time();
$sig = hash_hmac('sha256', $ts . '.' . $body, getenv('LIPAWIN_PAYOUT_SECRET'));
// headers: X-Api-Key, X-Payout-Timestamp: $ts, X-Payout-Signature: $sig, Content-Type: application/json
// then POST $body to https://lipalink.co.ke/api/payout.php
Statuses
Queued → Sent → Success | Failed (refunded) | Timeout (Safaricom queue timed out — our team verifies; becomes Success or Failed + refund). Pending_Confirm → Queued when you approve; Cancelled / Expired (refunded) otherwise.
/api/payout.php?ref=PO… or ?reference=REFUND-1023 or ?list=1curl "https://lipalink.co.ke/api/payout.php?reference=REFUND-1023" -H "X-Api-Key: …" -H "X-Payout-Secret: …"
# { "success": true, "payout": { "status": "Success", "mpesa_receipt": "TJK1ABC2DE", "recipient_name": "254712345678 - JANE DOE", … } }
curl "https://lipalink.co.ke/api/payout.php" -H "X-Api-Key: …" -H "X-Payout-Secret: …"
# { "success": true, "balance": 4485.00, "limits": { "min": 10, "max_single": 70000, "daily": 150000, "confirm_above": 20000 },
# "fund_via": { "paybill": "4160829", "account": "…" } }
Check the registered name before paying (optional)
If name lookup is enabled on the platform, GET /api/payout.php?lookup=254712345678 returns { "success": true, "phone": "254712345678", "name": "JANE WANJIRU DOE" } (404 when the number is not an M-Pesa customer). Show the name to your user before they confirm a payout — it prevents paying the wrong number.
| HTTP | Meaning |
|---|---|
| 201 / 202 / 200 | Sent / waiting for your email confirmation / duplicate reference or a status read |
| 401 | Bad API key, bad or missing secret/signature, stale timestamp |
| 402 | Insufficient balance (the message says how much is needed and where to top up) |
| 403 | Payouts not activated / suspended / KYC / IP not allow-listed |
| 422 | Validation (phone, amount, reference, callback_url) |
| 429 | Daily limit reached |
| 503 | Payouts paused platform-wide — retry later, nothing was debited |
Payout webhook
Same transport and signature as the payment webhook (X-Pesaflux-Signature, HMAC of "<t>.<body>" with your API key, retried until you answer 2xx). X-Pesaflux-Event is payout.success, payout.failed or payout.pending.
{ "event": "payout.success", "payout_ref": "PO2510111205A1F2", "reference": "REFUND-1023", "status": "Success",
"msisdn": "254712345678", "amount": 500, "fee": 15, "total": 515,
"mpesa_receipt": "TJK1ABC2DE", "recipient_name": "254712345678 - JANE DOE",
"refunded": false, "metadata": { "order_id": 1023 }, "completed_at": "2026-10-11 12:05:41" }
{ "event": "payout.failed", "status": "Failed", "refunded": true, "result_desc": "The initiator information is invalid.", … }
payout_ref as the idempotency key on your side — a webhook may be delivered more than once.Error codes (all products)
Non-2xx responses look like {"success": false, "error": "message"}. The message always says what to fix.
| HTTP | Meaning |
|---|---|
| 400 / 422 | Validation: missing or invalid field, unsupported country, amount out of range |
| 401 | Missing or invalid API key (or payout secret) |
| 402 | Billing: no plan, wallet too low, monthly limit reached, or (payouts) insufficient balance |
| 403 | API suspended, KYC not approved, international/payouts not enabled, IP not allowed, simulating a live transaction |
| 404 | Transaction / payout not found |
| 409 | International: receiving M-Pesa number not set up yet |
| 429 | Rate limited or daily payout limit — retry later |
| 503 | Feature switched off on the platform |
Test mode (international)
Add "test": true to /api/collect.php: the transaction is a sandbox one — no real money, no fee, and it is never matched by real SMS. Then simulate the outcome:
/api/xb_simulate.php{ "transaction_id": "PFX…", "outcome": "success" } # or "failed"
→ { "success": true, "status": "Success", "mpesa_receipt": "TEST3F9A2C", "applied": true, "test": true }
Your webhook fires with "test": true and the status endpoint returns the new status — so your whole pipeline can be exercised. Live transactions return 403 from the simulator. Ignore test rows in your own books.
For Kenya STK and payouts there is no simulator: test with KES 10 to your own number.
Test scenarios — collections
Run A–F in test mode and G–S with the real phone bridge before going live. "Expected" is exactly what the platform guarantees.
| # | Scenario | How to run | Expected |
|---|---|---|---|
| A | Happy path (sandbox) | collect test:true → simulate success | Success, receipt TEST…, webhook with test:true, no wallet/fee change |
| B | Customer never pays | collect test:true → simulate failed | Failed, EXPIRED, no webhook |
| C | Simulate twice | simulate success twice | second call applied:false; exactly one webhook |
| D | Simulate a live txn | collect without test → simulate | 403, nothing changes |
| E | Retry create | same msisdn+amount twice within 10 min | same transaction_id, duplicate:true |
| F | Validation | msisdn 2547…, amount 300000, country XXX, missing business_id | 400 with clear messages; Kenyan number → "use /api/stk_push.php" |
| G | Real payment, right number | customer sends exact KES from the number given | Success seconds after the SMS; webhook; mpesa_receipt = M-Pesa code |
| H | Operator rounding | KES 1,497 arrives for 1,500 | still matched (tolerance ±5, admin setting) |
| I | Paid from another number | customer sends from a different phone | stays Pending; appears under Receive via M-Pesa → "needs attention"; one-tap assign or customer code → Success |
| J | Transfer app, no sender number | exactly one expectation of that amount | Success; if two share the amount → held for assignment |
| K | Unrelated Kenyan receipt | a local customer pays your number the same amount | ignored; expected payment stays Pending |
| L | Duplicate SMS | forwarder resends | no-op; no second webhook |
| M | Phone offline | phone off with a payment pending | email to you (cc admin); paste the SMS → Success |
| N | Late SMS after expiry | SMS arrives >24h but <72h | Failed/EXPIRED self-corrects to Success; webhook fires |
| O | Webhook down | return 500, then fix | retries with back-off up to ~3h; Resend on Integration page |
| P | Kenya STK unchanged | run your existing STK integration | identical behaviour and payloads |
| Q | Link (international) | link audience:intl + customer_phone, single_use → open | country pre-selected, number pre-filled; after payment link inactive; GET ?reference= shows Success; webhook has link_slug, metadata |
| R | Link (Kenya) | link audience:kenya → open | no country selector; STK prompt; webhook has the link's reference |
| S | Paid / expired link reopened | open after payment or expiry | "already paid" / "expired"; no new transaction |
Test scenarios — payouts
Start with KES 10 to your own number.
| # | Scenario | How to run | Expected |
|---|---|---|---|
| P1 | Happy path | POST amount 10 to your phone | 201 Sent → M-Pesa SMS, webhook payout.success with receipt, email, balance −(10+fee) |
| P2 | Retry same reference | POST the same body again | 200 duplicate:true, same payout_ref, no second debit |
| P3 | Wrong secret | X-Payout-Secret: pos_wrong | 401, nothing debited |
| P4 | Stale / tampered signature | timestamp 10 min old, or body changed after signing | 401 |
| P5 | IP allow-list | set your server IP, call from elsewhere | 403 "Request IP … is not in your payout IP allow-list" |
| P6 | Insufficient balance | amount + fee > balance | 402 with the exact shortfall and where to top up |
| P7 | Above threshold | amount ≥ your confirmation threshold | 202 Pending_Confirm; email Approve/Cancel; Approve → Success; Cancel or 30 min → refunded |
| P8 | Daily limit | exceed your daily total | 429, nothing debited |
| P9 | M-Pesa rejects | pay an unregistered number | Failed, webhook payout.failed with refunded:true, balance restored, email |
| P10 | Paused | click Pause payouts, then POST | 403 "Payouts are suspended" |
| P11 | Rotated secret | rotate, call with the old one | 401; new secret works immediately |
| P12 | STK unaffected | run your normal STK flow | identical — payouts never touch collections, wallet or STK credit |
Go-live checklist
- KYC approved. Wallet topped up or subscription active. API key kept server-side.
- Webhook URL set, signature verified, handler idempotent by
transaction_id, ignorestest:truein production books. - International: Receive via M-Pesa shows Online; forwarder app excluded from battery optimisation; checkout always sends the customer's
msisdn. - Polling stops on Success/Failed/Cancelled; UI offers the M-Pesa code fallback after a few minutes.
- Payouts: secret in server environment, IP allow-list set, confirmation threshold chosen, scenarios P1–P12 pass.
- Scenarios A–S pass.