One paybill.
Every platform paid correctly.
RouteCloud sits between Safaricom's Daraja APIs and your internal systems. Customers pay a single M‑PESA paybill; the gateway reads the account reference, decides which platform the money belongs to, posts it to the ledger and notifies that platform — in one hop, with a full audit trail behind it.
How a payment travels
Five steps, every one of them logged and replayable.
Customer pays
A payment lands on the shared paybill from the M‑PESA menu, an STK push, or a QR scan. Safaricom calls our validation endpoint first.
Reference is read
The account number is normalised — spaces, dashes and the usual keypad typos
(O for 0, I for 1) are corrected before matching.
Rules decide
Ordered rules match on prefix, range, regex or amount. One payment can be split across several platforms by percentage — the parts always re-sum exactly.
Ledger is posted
Double-entry postings credit the destination and record the fee. The webhook event is written in the same database transaction, so it can never be lost.
Platform is notified
A signed webhook is delivered with exponential backoff and a dead-letter queue. Every attempt — connected or failed — is kept with its response body.
Nothing is lost
A payment that matches no rule is never rejected silently: it goes to a suspense queue with a suggested destination for finance to confirm.
Initiate a collection
OAuth2 client credentials, JSON over HTTPS, idempotent by design.
# 1 — get a token (valid one hour) curl -s -X POST https://route.cloud.or.ke/api/v1/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials -d scope="payments:write payments:read" # 2 — push an STK prompt to the customer's handset curl -s -X POST https://route.cloud.or.ke/api/v1/payments/stk-push \ -H "Authorization: Bearer $TOKEN" \ -H "Idempotency-Key: order-88213" \ -H "Content-Type: application/json" \ -d '{ "msisdn": "254712345678", "amount": 1500, "account_ref": "SCH-4821", "description": "Term 2 fees" }' # 202 Accepted { "transaction_id": "9f2c1e40-5b7a-4d1e-9a33-0c7f9b21d5aa", "reference": "RC-8FK2QD9L", "status": "awaiting_callback", "destination": "SCHOOLPAY", "customer_message": "Success. Request accepted for processing" }
What you get on the other side
A signed webhook, once per destination, retried until you acknowledge it.
POST https://your-platform.example/hooks/routecloud X-RouteCloud-Signature: t=1755168000,v1=9c4f… X-RouteCloud-Event: payment.received X-RouteCloud-Delivery: 3f9a2b1c-… { "id": "3f9a2b1c-7d44-4e91-b0e2-71c8a5f3d902", "type": "payment.received", "data": { "reference": "RC-8FK2QD9L", "amount": 1500.00, "fee": 15.00, "net_amount": 1485.00, "account_reference": "4821", "customer": { "msisdn": "254712***678" }, "mpesa": { "receipt": "SGJ4K2LM9P" }, "reconciliation_id": "REC-20260814-A7F2C91D0B" } }
Built for the awkward parts
The failure modes that actually cost money in production.
Duplicate callbacks
Safaricom retries. A unique constraint on the M‑PESA receipt means a replayed confirmation can never create a second credit.
Callbacks that never arrive
A sweeper picks up anything still awaiting a callback and asks the Transaction Status API what really happened, rather than guessing.
Mistyped account numbers
Confusable characters are retried automatically, and finance can pin a permanent alias so the same payer never lands in suspense twice.
Double disbursement
Every payout carries a unique OriginatorConversationID, and payouts above a threshold require a second approver who cannot be the requester.
Tampered records
The audit trail is hash-chained. Editing or deleting any historical entry breaks the chain, and the verifier reports exactly where.
Stolen credentials
Staff sign-in is password plus SMS OTP, sessions are bound to IP and device, and every new account needs an administrator to authorise each permission.