API documentation
JSON over HTTPS, OAuth2 client credentials, idempotent writes. Base URL https://route.cloud.or.ke/api/v1 · OpenAPI spec · Integration guide with code samples
1. Authentication
Exchange your client credentials for a bearer token. Tokens live one hour.
curl -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" { "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "scope": "payments:write payments:read" }
Scopes
payments:read
Read transactions and their status
payments:write
Initiate STK Push collections
disbursements:write
Initiate B2C disbursements to customers
disbursements:approve
Approve held disbursements
transfers:write
Initiate B2B transfers to approved paybills and tills
reversals:write
Request transaction reversals
destinations:read
List destination platforms
destinations:write
Manage destination configuration
routing:read
Read routing rules and run the simulator
routing:write
Manage routing rules
webhooks:read
Read webhook delivery history
webhooks:write
Replay and manage webhook events
expenses:read
Read petty cash requests and their outcomes
expenses:write
Raise petty cash requests and record spending
reports:read
Read ledger, settlement and reconciliation data
2. Endpoints
| Method | Path | Scope | What it does |
|---|---|---|---|
| POST | /api/v1/oauth/token | — | Exchange credentials for a bearer token |
| POST | /api/v1/oauth/revoke | — | Revoke a token immediately |
| GET | /api/v1/oauth/introspect | any | What the current token can do |
| POST | /api/v1/payment-requests | payments:write | Ask for a payment and mint a code |
| GET | /api/v1/payment-requests | payments:read | List payment requests |
| GET | /api/v1/payment-requests/{code} | payments:read | One request and every payment against it |
| POST | /api/v1/payment-requests/{code}/stk-push | payments:write | Push a prompt for an existing code |
| POST | /api/v1/payment-requests/{code}/cancel | payments:write | Void an unsettled request |
| POST | /api/v1/payments/stk-push | payments:write | Send a PIN prompt to a customer handset |
| POST | /api/v1/payments/simulate | payments:write | Simulate a paybill payment (sandbox only) |
| POST | /api/v1/payments/qr | payments:write | Generate a scannable M-PESA QR |
| POST | /api/v1/payments/standing-order | payments:write | Set up a recurring charge (Ratiba) |
| POST | /api/v1/payments/disburse | disbursements:write | Send money to a customer (B2C) |
| POST | /api/v1/payments/transfer | transfers:write | Transfer to an approved paybill or till (B2B) |
| POST | /api/v1/payments/{id}/reverse | reversals:write | Reverse a collection |
| GET | /api/v1/transactions | payments:read | Search the transaction register |
| GET | /api/v1/transactions/{id} | payments:read | One transaction by id, reference or receipt |
| GET | /api/v1/transactions/{id}/ledger | reports:read | Double-entry postings for a transaction |
| POST | /api/v1/transactions/{id}/status-query | payments:read | Ask M-PESA what really happened |
| GET | /api/v1/destinations | destinations:read | List destination platforms |
| GET | /api/v1/destinations/{code}/balance | reports:read | Current balance owed to a platform |
| POST | /api/v1/routing/simulate | routing:read | Dry-run an account reference |
| GET | /api/v1/webhooks/events | webhooks:read | Delivery queue and status |
| POST | /api/v1/webhooks/events/{uuid}/replay | webhooks:write | Re-queue a failed delivery |
| POST | /api/v1/expenses | expenses:write | Ask for petty cash to be authorised |
| GET | /api/v1/expenses | expenses:read | List your expense requests |
| GET | /api/v1/expenses/{id} | expenses:read | One request and its decision |
| POST | /api/v1/expenses/{id}/spend | expenses:write | Record spending against an approval |
| GET | /api/v1/reports/summary | reports:read | Daily volume and value series |
| GET | /api/v1/reports/float | reports:read | Paybill M-PESA float behind every payout |
| GET | /api/v1/status | — | Health check |
3. Idempotency
Send an Idempotency-Key header on every write. Replaying the same key with the same body returns the original response instead of moving money twice. Reusing a key with a different body is an error, not a silent overwrite.
curl -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"}'
4. Receiving webhooks
Final outcomes are pushed to your endpoint, signed, and retried with exponential backoff until you answer 2xx. Deduplicate on the event id — a retry may arrive after you have already processed it.
<?php
// Verify a RouteCloud webhook (PHP)
$secret = getenv('ROUTECLOUD_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ROUTECLOUD_SIGNATURE'] ?? '';
// The header is t=<ts>,v1=<sig> and during a secret rotation it carries
// MORE THAN ONE v1 — the new secret's signature and the old one's. Do
// not use parse_str here: it keeps only the last v1, so whichever
// secret you hold, half the time it is not the one you are comparing
// against, and every webhook is rejected mid-rotation.
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
if (!str_contains($part, '=')) {
continue;
}
[$k, $v] = explode('=', trim($part), 2);
if ($k === 't') { $timestamp = $v; }
if ($k === 'v1') { $signatures[] = $v; }
}
if ($timestamp === null || abs(time() - (int) $timestamp) > 300) {
http_response_code(400); exit('stale timestamp');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
$valid = false;
foreach ($signatures as $candidate) {
$valid = hash_equals($expected, $candidate) || $valid;
}
if (!$valid) {
http_response_code(401); exit('bad signature');
}
$event = json_decode($raw, true);
// Deduplicate on $event['id'] — we retry until you return 2xx.
// Respond 2xx as soon as you have persisted it; do the slow work after.
http_response_code(200);
echo 'ok';
5. Errors
Every error is an RFC 7807 problem document, so you parse one shape.
{
"type": "https://route.cloud.or.ke/errors/validation_failed",
"title": "The request could not be processed. See `errors` for details.",
"status": 422,
"code": "validation_failed",
"errors": { "msisdn": "Customer mobile number must be a valid Kenyan mobile number." }
}
401 unauthenticated
Missing, expired or revoked bearer token.
403 insufficient_scope
Valid token, but the scope was not granted.
409 idempotency_in_flight
The same key is still being processed. Retry after a few seconds.
422 validation_failed
Field-level problems, listed in errors.
429 rate_limited
Back off for Retry-After seconds.
502 mpesa_error
Safaricom rejected the request; hint says what to do.
6. Network notes
Callback allowlist
Only Safaricom's published ranges may post to our callback endpoints: 196.201.214.0/24 196.201.213.0/24 196.201.212.0/24
Correlation IDs
Send X-Correlation-Id and we thread it through every log line, M-PESA call and webhook, so one id traces the whole payment.
Optional request signing
High-value clients can be required to sign requests: HMAC-SHA256(timestamp.METHOD.path.sha256(body)) in X-RC-Signature, with a 5-minute replay window.