LIPAWIN DocsSimple developer guideMy API key →

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.

1 · Collect in Kenya

Customer gets an M-Pesa prompt on their phone, enters PIN, you get notified.

Go to STK push →
2 · Collect from other countries

Customer in Uganda, Tanzania, Nigeria… sends KES to your M-Pesa number. We confirm it for you.

Go to International →
3 · Send money (Payouts)

Your website pays any M-Pesa number: refunds, winnings, commissions.

Go to Payouts →
No code at all? Use Payment Links: create a link, send it on WhatsApp/SMS, get a webhook when it is paid. Works for Kenya and international from the same link.

Base URL for every request: https://lipalink.co.ke · All requests and responses are JSON · HTTPS only.

Your first payment in 5 minutes

  1. Get your API key. Dashboard → Integration. It starts with pk_live_. Keep it on your server only.
  2. 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.
  3. Create a business. Businesses page → Add. Note its business_id and set a Webhook URL (can be added later).
  4. 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.
  5. 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
Never put the key in browser JavaScript, a mobile app or Git. Call the API from your server (PHP, Node, Python…). If a key leaks, generate a new one on the Integration page.

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:

Wallet credits (pay as you go)

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.

Monthly subscription

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.00KES 1.00
21.00 – 100.00KES 2.00
101.00 – 500.00KES 4.00
501.00 – 2,000.00KES 8.00
2,001.00 – 50,000.00KES 30.00
20,000.01 – and aboveKES 50.00

Subscription plans

PlanBillingPriceTransactions / month
ultimatemonthlyKES 1,000.00500
WeeklyweeklyKES 250.00100
Analytics PromonthlyKES 1,800.001,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.

Auto-pause. If a run of your prompts all fail or get cancelled, your API is paused (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
MessageFix
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).

POST/api/stk_push.php
curl -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." }
FieldRequiredWhat to send
amountyesKES, minimum 1 (platform limits may apply)
msisdnyesCustomer's number: 2547XXXXXXXX or 2541XXXXXXXX. 07… is accepted and converted.
business_idyesOne of your active businesses (Businesses page)
referencenoYour order id — the customer sees it as the account reference (letters/digits, ≤20)
descriptionnoFor your records only
The result is final only when Safaricom says so: 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
const r = await fetch('https://lipalink.co.ke/api/stk_push.php', { method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.LIPA_KEY },
  body: JSON.stringify({ amount: 100, msisdn: phone, business_id: 12, reference: orderId }) }).then(r => r.json());
if (!r.success) throw new Error(r.error);
const txn = r.transaction_id;   // save it with the order
import requests
r = requests.post('https://lipalink.co.ke/api/stk_push.php', headers={'X-Api-Key': KEY},
                  json={'amount': 100, 'msisdn': phone, 'business_id': 12, 'reference': order_id}).json()
assert r['success'], r.get('error')
txn = r['transaction_id']   # save it with the order

Check a payment

GET/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);
const [t, v1] = req.get('X-Pesaflux-Signature').split(',').map(p => p.split('=')[1]);
const mac = crypto.createHmac('sha256', KEY).update(`${t}.${rawBody}`).digest('hex');
if (mac !== v1 || Math.abs(Date.now()/1000 - t) > 600) return res.sendStatus(400);
const e = JSON.parse(rawBody);
if (e.status === 'Success' && !e.test) markPaid(e.reference, e.transaction_id);
res.sendStatus(200);
t, v1 = [p.split('=', 1)[1] for p in request.headers['X-Pesaflux-Signature'].split(',')]
mac = hmac.new(KEY.encode(), f"{t}.{raw}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, v1): abort(400)
e = json.loads(raw)
if e['status'] == 'Success' and not e.get('test'): mark_paid(e['reference'], e['transaction_id'])
Golden rule: fulfil the order from the webhook (or the status endpoint on your server), never from the browser. The same 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.

  1. You create an expected payment (POST /api/collect.php). We return the amount, your number and the steps for the customer's country.
  2. The customer sends the money from their own app: Send Money → International → Kenya M-Pesa → your number, exact KES amount.
  3. M-Pesa texts your phone. A free forwarder app on that phone relays the SMS to LIPAWIN.
  4. We match it (amount + sender number) → Success → webhook + receipt.
There is no "push prompt" abroad — the customer starts the transfer in their own app. Always send the customer's number (msisdn): that is how the money is recognised instantly and safely.

International — one-time setup (5 minutes, no code)

  1. Enable international for your account (ask the admin if it isn't on for everyone).
  2. Open Receive via M-Pesa and save the Safaricom number that will receive the money, plus the name on the line.
  3. 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.
  4. 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

POST/api/collect.php
curl -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" ] }
FieldRequiredWhat to send
amountyesKES you must receive, 1 – 250,000
msisdnstrongly recommendedCustomer's number in international format without + or 0, e.g. 256771234567. Kenyan numbers are rejected (use STK).
business_idyesOne of your active businesses
referencenoYour order id (≤100 chars)
countrynoISO-3 code (see Countries); detected from msisdn if omitted; OTH for transfer apps / diaspora
testnotrue → 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]);
// 1) create the expected payment
const r = await fetch('https://lipalink.co.ke/api/collect.php', { method:'POST',
  headers:{ 'Content-Type':'application/json', 'X-Api-Key': process.env.LIPA_KEY },
  body: JSON.stringify({ amount:1500, msisdn:req.body.phone, business_id:12, reference:orderId }) }).then(r=>r.json());
if (!r.success) throw new Error(r.error);
// show r.amount, r.pay_to.number_display, r.pay_to.name, r.steps

// 2) status proxy for the browser
app.get('/order-status', async (req,res)=>{
  const t = await fetch(`https://lipalink.co.ke/api/transaction_status.php?transaction_id=${txnFor(req.query.order)}`,
            { headers:{ 'X-Api-Key': process.env.LIPA_KEY } }).then(r=>r.json());
  res.json({ status:t.status, receipt:t.mpesa_receipt||null });
});
import requests
r = requests.post('https://lipalink.co.ke/api/collect.php', headers={'X-Api-Key': KEY},
                  json={'amount':1500,'msisdn':phone,'business_id':12,'reference':order_id}).json()
assert r['success'], r.get('error')
# show r['amount'], r['pay_to']['number_display'], r['pay_to']['name'], r['steps']

def status(txn):
    return requests.get('https://lipalink.co.ke/api/transaction_status.php',
                        params={'transaction_id': txn}, headers={'X-Api-Key': KEY}).json()
<!-- 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

POST/api/claim_code.php

If 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

CodeCountryCustomer pays fromLocal estimate
TZATanzaniaM-Pesa (Vodacom), Tigo Pesa or Airtel MoneyTZS
UGAUgandaMTN MoMo or Airtel MoneyUGX
RWARwandaMTN MoMo or Airtel MoneyRWF
ZMBZambiaMTN MoMo or Airtel MoneyZMW
MWIMalawiAirtel Money or TNM MpambaMWK
ETHEthiopiaSafaricom Ethiopia M-Pesa or telebirrETB
CODDR CongoM-Pesa (Vodacom), Airtel Money or Orange MoneyCDF
MOZMozambiqueM-Pesa (Vodacom)MZN
BWABotswanaOrange MoneyBWP
NGANigeriaa transfer app that sends to M-Pesa Kenya (Grey, Eversend, LemFi, Flutterwave Send)NGN
GHAGhanaa transfer app that sends to M-Pesa Kenya (Eversend, LemFi, Chipper)GHS
ZAFSouth AfricaMukuru, Eversend or your bank's international transferZAR
OTHOther country / transfer appUse WorldRemit, Remitly, Sendwave, Wise or any app that pays to M-Pesa KenyaUSD

Local amounts are estimates only — the customer always sends the exact KES amount.

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.

  1. 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.
  2. Send. POST /api/payout.php with phone + amount. Amount + payout fee leave your balance immediately.
  3. 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:

  1. Pay the activation fee — one-time KES 0 by M-Pesa prompt from Dashboard → Payouts. (Not wallet credit.)
  2. Enter the 6-digit code we email you (valid 15 minutes).
  3. Our team reviews (KYC must be approved) and approves. You receive your payout secret by email — shown once, store it in your server environment.
  4. Call the API with your API key + payout secret (or a signature). Every result is emailed to you.
ProtectionWhat it does
Payout secretSeparate 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-listOptional list of IPs / CIDR ranges (Payouts → Security). Anything else gets 403.
LimitsMax KES 0 per payout and KES 0 per day by default; you can lower both.
Email confirmationPayouts 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 & pauseEmail for every result (success emails optional, failures always). "Pause payouts" on the Payouts page blocks the API instantly.
IdempotencySend a unique reference; a retry with the same reference returns the original payout and never pays twice.

Send a payout

POST/api/payout.php
curl -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)." }
FieldRequiredWhat to send
phoneyesRecipient M-Pesa number 2547XXXXXXXX / 2541XXXXXXXX (07… accepted)
amountyesKES, whole shillings. Min 0, max your per-payout limit
referencerecommendedYour unique id (≤64, letters/digits/._-). Same reference = same payout.
descriptionno≤100 chars, shown in your history
callback_urlnoWhere to POST the result. Default: your first business webhook URL.
metadatanoAny 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.

GET/api/payout.php?ref=PO… or ?reference=REFUND-1023 or ?list=1
curl "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.

HTTPMeaning
201 / 202 / 200Sent / waiting for your email confirmation / duplicate reference or a status read
401Bad API key, bad or missing secret/signature, stale timestamp
402Insufficient balance (the message says how much is needed and where to top up)
403Payouts not activated / suspended / KYC / IP not allow-listed
422Validation (phone, amount, reference, callback_url)
429Daily limit reached
503Payouts 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.", … }
Use 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.

HTTPMeaning
400 / 422Validation: missing or invalid field, unsupported country, amount out of range
401Missing or invalid API key (or payout secret)
402Billing: no plan, wallet too low, monthly limit reached, or (payouts) insufficient balance
403API suspended, KYC not approved, international/payouts not enabled, IP not allowed, simulating a live transaction
404Transaction / payout not found
409International: receiving M-Pesa number not set up yet
429Rate limited or daily payout limit — retry later
503Feature 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:

POST/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.

#ScenarioHow to runExpected
AHappy path (sandbox)collect test:true → simulate successSuccess, receipt TEST…, webhook with test:true, no wallet/fee change
BCustomer never payscollect test:true → simulate failedFailed, EXPIRED, no webhook
CSimulate twicesimulate success twicesecond call applied:false; exactly one webhook
DSimulate a live txncollect without test → simulate403, nothing changes
ERetry createsame msisdn+amount twice within 10 minsame transaction_id, duplicate:true
FValidationmsisdn 2547…, amount 300000, country XXX, missing business_id400 with clear messages; Kenyan number → "use /api/stk_push.php"
GReal payment, right numbercustomer sends exact KES from the number givenSuccess seconds after the SMS; webhook; mpesa_receipt = M-Pesa code
HOperator roundingKES 1,497 arrives for 1,500still matched (tolerance ±5, admin setting)
IPaid from another numbercustomer sends from a different phonestays Pending; appears under Receive via M-Pesa → "needs attention"; one-tap assign or customer code → Success
JTransfer app, no sender numberexactly one expectation of that amountSuccess; if two share the amount → held for assignment
KUnrelated Kenyan receipta local customer pays your number the same amountignored; expected payment stays Pending
LDuplicate SMSforwarder resendsno-op; no second webhook
MPhone offlinephone off with a payment pendingemail to you (cc admin); paste the SMS → Success
NLate SMS after expirySMS arrives >24h but <72hFailed/EXPIRED self-corrects to Success; webhook fires
OWebhook downreturn 500, then fixretries with back-off up to ~3h; Resend on Integration page
PKenya STK unchangedrun your existing STK integrationidentical behaviour and payloads
QLink (international)link audience:intl + customer_phone, single_use → opencountry pre-selected, number pre-filled; after payment link inactive; GET ?reference= shows Success; webhook has link_slug, metadata
RLink (Kenya)link audience:kenya → openno country selector; STK prompt; webhook has the link's reference
SPaid / expired link reopenedopen after payment or expiry"already paid" / "expired"; no new transaction

Test scenarios — payouts

Start with KES 10 to your own number.

#ScenarioHow to runExpected
P1Happy pathPOST amount 10 to your phone201 Sent → M-Pesa SMS, webhook payout.success with receipt, email, balance −(10+fee)
P2Retry same referencePOST the same body again200 duplicate:true, same payout_ref, no second debit
P3Wrong secretX-Payout-Secret: pos_wrong401, nothing debited
P4Stale / tampered signaturetimestamp 10 min old, or body changed after signing401
P5IP allow-listset your server IP, call from elsewhere403 "Request IP … is not in your payout IP allow-list"
P6Insufficient balanceamount + fee > balance402 with the exact shortfall and where to top up
P7Above thresholdamount ≥ your confirmation threshold202 Pending_Confirm; email Approve/Cancel; Approve → Success; Cancel or 30 min → refunded
P8Daily limitexceed your daily total429, nothing debited
P9M-Pesa rejectspay an unregistered numberFailed, webhook payout.failed with refunded:true, balance restored, email
P10Pausedclick Pause payouts, then POST403 "Payouts are suspended"
P11Rotated secretrotate, call with the old one401; new secret works immediately
P12STK unaffectedrun your normal STK flowidentical — payouts never touch collections, wallet or STK credit

Go-live checklist

  1. KYC approved. Wallet topped up or subscription active. API key kept server-side.
  2. Webhook URL set, signature verified, handler idempotent by transaction_id, ignores test:true in production books.
  3. International: Receive via M-Pesa shows Online; forwarder app excluded from battery optimisation; checkout always sends the customer's msisdn.
  4. Polling stops on Success/Failed/Cancelled; UI offers the M-Pesa code fallback after a few minutes.
  5. Payouts: secret in server environment, IP allow-list set, confirmation threshold chosen, scenarios P1–P12 pass.
  6. Scenarios A–S pass.