route.cloud

Integration guide

Ask for a payment, give the customer a code, get told when it is paid — whether they paid at the paybill or through a prompt. Base URL https://route.cloud.or.ke/api/v1

How it fits together

Three calls on your side. Everything else is us telling you what happened.

  Your system                RouteCloud                    Customer
      |                          |                             |
      |  POST /payment-requests  |                             |
      |------------------------->|                             |
      |   { code: "YTA4XP" }     |                             |
      |<-------------------------|                             |
      |                          |                             |
      |  show code / send prompt |                             |
      |------------------------------------------------------->|
      |                          |                             |
      |                          |   pays paybill 4320161      |
      |                          |   account YTA4XP  (offline) |
      |                          |<----------------------------|
      |                          |         ...or accepts the STK prompt
      |                          |                             |
      |  POST your webhook       |                             |
      |  payment_request.paid    |                             |
      |<-------------------------|                             |
      |  200 OK                  |                             |
      |------------------------->|                             |

Both payment routes produce the same event. Your webhook handler does not need to know which one the customer chose.

Step 1 — Ask for a payment

Send your own order id as external_reference. It comes back on every webhook, so you never need a lookup table.

# 1. Get a token (valid one hour — cache it, do not fetch one per request)
TOKEN=$(curl -s -X POST https://route.cloud.or.ke/api/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  | jq -r .access_token)

# 2. Ask for an invoice
curl -X POST https://route.cloud.or.ke/api/v1/payment-requests \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8821"          \
  -d '{
        "amount": 1500,
        "destination": "SCHOOLPAY",
        "description": "Term 2 fees",
        "external_reference": "ORDER-8821",
        "msisdn": "0712345678",
        "expires_in_minutes": 1440
      }'

What you get back

{
  "code": "YTA4XP",
  "request_id": "9f2c1e40-5b7a-4d1e-9a33-0c7f9b21d5aa",
  "status": "pending",
  "external_reference": "ORDER-8821",
  "amount_expected": 1500,
  "amount_paid": 0,
  "amount_outstanding": 1500,
  "currency": "KES",
  "destination": "SCHOOLPAY",
  "pay": {
    "paybill": "4320161",
    "account_number": "YTA4XP",
    "amount": 1500,
    "sms_text": "Pay KES 1,500.00: M-PESA > Lipa na M-PESA > Pay Bill > Business no. 4320161 > Account no. YTA4XP > Amount 1,500.00",
    "ussd": "*334#"
  },
  "expires_at": "2026-08-16T23:12:57+03:00"
}

Store the code first

Save code against your order before showing it to anyone. The webhook can arrive before your page finishes rendering.

Idempotency-Key

Same key and same body returns the original invoice instead of minting a second one. Use your order id and retries become safe.

Whole shillings only

M-PESA cannot settle cents, so an amount with decimals is refused rather than creating a code that can never be paid exactly.

Step 1 in your language

PHP

<?php
// Raising an invoice from your system.

final class RouteCloud
{
    private ?string $token = null;
    private int $tokenExpires = 0;

    public function __construct(
        private string $base = 'https://route.cloud.or.ke/api/v1',
        private string $clientId = '',
        private string $clientSecret = '',
    ) {}

    /** Tokens last an hour. Cache it — do not fetch one per request. */
    private function token(): string
    {
        if ($this->token !== null && time() < $this->tokenExpires - 60) {
            return $this->token;
        }

        $ch = curl_init($this->base . '/oauth/token');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_USERPWD        => $this->clientId . ':' . $this->clientSecret,
            CURLOPT_POSTFIELDS     => http_build_query(['grant_type' => 'client_credentials']),
            CURLOPT_TIMEOUT        => 20,
        ]);
        $body = json_decode((string) curl_exec($ch), true);
        curl_close($ch);

        if (empty($body['access_token'])) {
            throw new RuntimeException('RouteCloud auth failed');
        }

        $this->tokenExpires = time() + (int) $body['expires_in'];

        return $this->token = $body['access_token'];
    }

    /**
     * @param string $orderId your own id — pass it as external_reference so the
     *                        webhook can be matched back without a lookup table
     */
    public function requestPayment(string $orderId, float $amount, string $msisdn, string $what): array
    {
        $ch = curl_init($this->base . '/payment-requests');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_HTTPHEADER     => [
                'Authorization: Bearer ' . $this->token(),
                'Content-Type: application/json',
                // Same key + same body = same invoice. Safe to retry on timeout.
                'Idempotency-Key: ' . $orderId,
            ],
            CURLOPT_POSTFIELDS => json_encode([
                'amount'             => $amount,
                'destination'        => 'SCHOOLPAY',
                'description'        => $what,
                'external_reference' => $orderId,
                'msisdn'             => $msisdn,
                'expires_in_minutes' => 1440,
            ]),
            CURLOPT_TIMEOUT => 20,
        ]);
        $raw    = (string) curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $body = json_decode($raw, true);

        if ($status !== 201) {
            throw new RuntimeException('RouteCloud: ' . ($body['title'] ?? $raw));
        }

        return $body;
    }
}

// Usage
$rc      = new RouteCloud(clientId: getenv('RC_ID'), clientSecret: getenv('RC_SECRET'));
$invoice = $rc->requestPayment('ORDER-8821', 1500, '0712345678', 'Term 2 fees');

// Store the code against your order BEFORE showing it to anyone. If the
// webhook arrives first you still need to know which order it belongs to.
$db->exec('UPDATE orders SET payment_code = ? WHERE id = ?', [$invoice['code'], 8821]);

echo "Pay {$invoice['pay']['paybill']}, account {$invoice['pay']['account_number']}";

Node.js

// Node.js — raising an invoice
const BASE = 'https://route.cloud.or.ke/api/v1';

let cached = { token: null, expires: 0 };

async function token() {
  if (cached.token && Date.now() < cached.expires - 60_000) return cached.token;

  const auth = Buffer.from(`${process.env.RC_ID}:${process.env.RC_SECRET}`).toString('base64');
  const res  = await fetch(`${BASE}/oauth/token`, {
    method:  'POST',
    headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    'grant_type=client_credentials',
  });

  if (!res.ok) throw new Error(`RouteCloud auth failed: ${res.status}`);

  const body = await res.json();
  cached = { token: body.access_token, expires: Date.now() + body.expires_in * 1000 };
  return cached.token;
}

export async function requestPayment(orderId, amount, msisdn, description) {
  const res = await fetch(`${BASE}/payment-requests`, {
    method: 'POST',
    headers: {
      Authorization:     `Bearer ${await token()}`,
      'Content-Type':    'application/json',
      'Idempotency-Key': orderId,          // retry-safe
    },
    body: JSON.stringify({
      amount,
      destination:        'SCHOOLPAY',
      description,
      external_reference: orderId,         // comes back on the webhook
      msisdn,
      expires_in_minutes: 1440,
    }),
  });

  const body = await res.json();
  if (res.status !== 201) throw new Error(body.title ?? 'RouteCloud error');

  return body;   // { code: "YTA4XP", pay: { paybill, account_number, sms_text }, ... }
}

Python

# Python — raising an invoice
import os, time, requests

BASE = "https://route.cloud.or.ke/api/v1"
_token, _expires = None, 0

def token() -> str:
    global _token, _expires
    if _token and time.time() < _expires - 60:
        return _token

    r = requests.post(
        f"{BASE}/oauth/token",
        auth=(os.environ["RC_ID"], os.environ["RC_SECRET"]),
        data={"grant_type": "client_credentials"},
        timeout=20,
    )
    r.raise_for_status()
    body = r.json()
    _token, _expires = body["access_token"], time.time() + body["expires_in"]
    return _token

def request_payment(order_id: str, amount: float, msisdn: str, description: str) -> dict:
    r = requests.post(
        f"{BASE}/payment-requests",
        headers={
            "Authorization":   f"Bearer {token()}",
            "Idempotency-Key": order_id,        # retry-safe
        },
        json={
            "amount":             amount,
            "destination":        "SCHOOLPAY",
            "description":        description,
            "external_reference": order_id,     # returned on the webhook
            "msisdn":             msisdn,
            "expires_in_minutes": 1440,
        },
        timeout=20,
    )
    if r.status_code != 201:
        raise RuntimeError(r.json().get("title", r.text))
    return r.json()

Step 2 — Let the customer pay

Two routes, one code. You can offer either or both.

A

Paybill, offline

Show them pay.sms_text verbatim. They open M-PESA, pay business number 4320161, account number YTA4XP. No internet, no app, no callback from you. Works on any handset.

B

STK prompt

Push a PIN prompt to their phone carrying the same code. If they dismiss it and pay by hand instead, it still settles the same invoice.

# Push a prompt for an invoice you already raised.
# The prompt carries the SAME code, so if the customer dismisses it and pays
# the paybill by hand instead, it still settles this one invoice.

curl -X POST https://route.cloud.or.ke/api/v1/payment-requests/YTA4XP/stk-push \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"msisdn":"0712345678"}'

# 202 Accepted — the prompt is on its way. This does NOT mean paid.
# Wait for the payment_request.paid webhook.

Step 3 — Receive the result

This is the part worth getting right. We retry with exponential backoff until you answer 2xx, so your endpoint must be idempotent.

What arrives

{
  "id": "c19f8633-8ada-4336-a1f7-6b7a36357ae7",
  "type": "payment_request.paid",
  "created_at": "2026-08-15T15:00:21+03:00",
  "destination": "SCHOOLPAY",
  "livemode": true,
  "data": {
    "code": "YTA4XP",
    "request_id": "9f2c1e40-5b7a-4d1e-9a33-0c7f9b21d5aa",
    "external_reference": "ORDER-8821",
    "status": "paid",
    "amount_expected": 1500,
    "amount_paid": 1500,
    "amount_outstanding": 0,
    "currency": "KES",
    "destination": "SCHOOLPAY",
    "description": "Term 2 fees",
    "customer": { "msisdn": "254712***678", "name": "JOHN DOE" },
    "payments": [
      {
        "transaction_id": "b650adf6-337a-470f-996e-3a42748712a1",
        "reference": "RC-TDTCVGPC",
        "receipt": "UHEK037AWR",
        "channel": "c2b",
        "amount": 1500,
        "paid_at": "2026-08-15 15:00:20"
      }
    ],
    "created_at": "2026-08-15 14:41:03",
    "expires_at": "2026-08-16 14:41:03",
    "settled_at": "2026-08-15 15:00:21"
  }
}

Verify the signature

X-RouteCloud-Signature: t=<unix>,v1=<hmac> where the HMAC is sha256(t + "." + rawBody) under your webhook secret. Compare in constant time. During a secret rotation there may be more than one v1 — accept if any matches.

Use the RAW body

Sign over the bytes as received. Parsing JSON first and re-encoding changes them, and verification will fail. This is the most common integration bug.

Deduplicate on id

Retries mean the same event will arrive twice eventually. Make the event id a unique column and let the database reject the repeat.

Answer fast

Persist, return 200, then do the slow work. Sending email while holding the connection open is how deliveries time out and get retried.

Webhook receivers

Complete and runnable, including the parts people skip.

PHP

<?php
/**
 * webhook.php — receives payment notifications from RouteCloud.
 *
 * This one endpoint handles BOTH ways a customer can pay:
 *   - they entered the code as a paybill account number, offline
 *   - they accepted an STK prompt
 * You do not need to care which; `data.payments[].channel` tells you if you do.
 */

$secret = getenv('ROUTECLOUD_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ROUTECLOUD_SIGNATURE'] ?? '';

// --- 1. Verify the signature -----------------------------------------------
// Header looks like: t=1755205977,v1=9f86d081884c7d65...
// A rotation sends several v1 values; collect them all.
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $piece) {
    [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
    if ($k === 't')       { $timestamp = $v; }
    elseif ($k === 'v1')  { $signatures[] = $v; }
}
$parts = ['t' => $timestamp, 'v1' => $signatures[0] ?? ''];

// Reject anything older than 5 minutes: a captured request must not be
// replayable tomorrow.
if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > 300) {
    http_response_code(400);
    exit('stale or malformed signature');
}

$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

// hash_equals, never ===, so the comparison is not timing-attackable.
// Accept if ANY signature matches — during a secret rotation you will be sent
// one per active secret, so this keeps working while you swap yours over.
$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);

// --- 2. Deduplicate ---------------------------------------------------------
// We retry until you answer 2xx, so the same event WILL arrive twice sooner or
// later. Make the id a unique column and let the database enforce it.
try {
    $db->prepare('INSERT INTO webhook_events (id, type, payload) VALUES (?, ?, ?)')
       ->execute([$event['id'], $event['type'], $raw]);
} catch (PDOException $e) {
    if (str_contains($e->getMessage(), 'Duplicate')) {
        http_response_code(200);   // already handled — say OK so we stop retrying
        exit('duplicate');
    }
    throw $e;
}

// --- 3. Answer immediately, work afterwards ---------------------------------
// Persist, respond, then do anything slow. Holding the connection open while
// you send email or call another API is how deliveries time out and retry.
http_response_code(200);
echo 'ok';

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

// --- 4. Act on it -----------------------------------------------------------
$data = $event['data'];

switch ($event['type']) {

    case 'payment_request.paid':
        // The invoice is fully settled. external_reference is YOUR order id,
        // so no lookup table is needed.
        $order = $data['external_reference'];          // "ORDER-8821"
        $paid  = $data['amount_paid'];                 // 1500

        $db->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE id = ?')
           ->execute(['paid', $order]);

        foreach ($data['payments'] as $p) {
            // $p['channel'] is "c2b" for an offline paybill payment,
            // "stk_push" if they accepted a prompt.
            error_log("Order {$order} settled by {$p['channel']}, receipt {$p['receipt']}");
        }
        break;

    case 'payment_request.partially_paid':
        // Some money arrived but not all of it. Do NOT release goods yet.
        $db->prepare('UPDATE orders SET amount_paid = ? WHERE id = ?')
           ->execute([$data['amount_paid'], $data['external_reference']]);
        break;

    case 'payment_request.overpaid':
        // They paid more than asked. The money is yours; decide on a refund.
        notifyFinance($data['external_reference'], $data['amount_paid']);
        break;

    case 'payment_request.expired':
    case 'payment_request.cancelled':
        $db->prepare('UPDATE orders SET status = ? WHERE id = ?')
           ->execute(['unpaid', $data['external_reference']]);
        break;

    case 'payment.received':
        // A payment with no invoice attached — someone paid the paybill with a
        // plain account number. Useful if you also accept ad-hoc payments.
        break;
}

Node.js / Express

// Express — receiving payment notifications.
import express from 'express';
import crypto  from 'node:crypto';

const app    = express();
const SECRET = process.env.ROUTECLOUD_WEBHOOK_SECRET;

// The signature covers the RAW body. Parsing it first changes the bytes and
// verification will fail — this is the single most common mistake.
app.post('/webhooks/routecloud',
  express.raw({ type: 'application/json' }),
  async (req, res) => {

    const header = req.get('X-RouteCloud-Signature') ?? '';

    // During a secret rotation the header carries SEVERAL v1 values, so do not
    // use Object.fromEntries — it keeps only the last one and you will reject
    // every webhook for as long as the rotation overlap lasts.
    let timestamp = null;
    const signatures = [];
    for (const part of header.split(',')) {
      const i = part.indexOf('=');
      if (i === -1) continue;
      const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
      if (k === 't')  timestamp = v;
      if (k === 'v1') signatures.push(v);
    }

    if (!timestamp || signatures.length === 0
        || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale or malformed signature');
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // Accept if ANY signature matches. timingSafeEqual throws on a length
    // mismatch, so compare lengths first.
    const a = Buffer.from(expected);
    const valid = signatures.some((sig) => {
      const b = Buffer.from(sig);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });

    if (!valid) {
      return res.status(401).send('bad signature');
    }

    const event = JSON.parse(req.body.toString());

    // Deduplicate — retries mean you will see the same id twice.
    const inserted = await db.query(
      'INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING',
      [event.id, event.type, req.body.toString()],
    );
    if (inserted.rowCount === 0) return res.status(200).send('duplicate');

    // Answer first; do the slow work after.
    res.status(200).send('ok');

    const d = event.data;

    switch (event.type) {
      case 'payment_request.paid':
        // d.external_reference is your own order id.
        await orders.markPaid(d.external_reference, d.amount_paid);
        // d.payments[].channel === 'c2b' means they paid the paybill offline.
        break;

      case 'payment_request.partially_paid':
        await orders.recordPartial(d.external_reference, d.amount_paid, d.amount_outstanding);
        break;

      case 'payment_request.overpaid':
        await finance.flagOverpayment(d.external_reference, d.amount_paid);
        break;

      case 'payment_request.expired':
      case 'payment_request.cancelled':
        await orders.markUnpaid(d.external_reference);
        break;
    }
  });

Python / Flask

# Flask — receiving payment notifications
import hmac, hashlib, time, json
from flask import Flask, request, abort

app    = Flask(__name__)
SECRET = os.environ["ROUTECLOUD_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/routecloud")
def routecloud():
    raw    = request.get_data()          # RAW bytes — do not use request.json here
    header = request.headers.get("X-RouteCloud-Signature", "")
    # A rotation sends SEVERAL v1 values. dict() would keep only the last one
    # and you would reject every webhook until the rotation finished, so collect
    # them all.
    timestamp, signatures = None, []
    for part in header.split(","):
        if "=" not in part:
            continue
        k, v = part.strip().split("=", 1)
        if k == "t":
            timestamp = v
        elif k == "v1":
            signatures.append(v)

    if timestamp is None or not signatures or abs(time.time() - int(timestamp)) > 300:
        abort(400, "stale or malformed signature")

    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()

    # Accept if ANY of them matches.
    if not any(hmac.compare_digest(expected, sig) for sig in signatures):
        abort(401, "bad signature")

    event = json.loads(raw)

    # Deduplicate: retries guarantee you will see the same id again.
    if not events.insert_if_new(event["id"], event["type"], raw):
        return "duplicate", 200

    d = event["data"]

    if event["type"] == "payment_request.paid":
        # d["external_reference"] is your own order id.
        orders.mark_paid(d["external_reference"], d["amount_paid"])
        # d["payments"][i]["channel"] == "c2b" -> paid at the paybill, offline
    elif event["type"] == "payment_request.partially_paid":
        orders.record_partial(d["external_reference"], d["amount_paid"], d["amount_outstanding"])
    elif event["type"] == "payment_request.overpaid":
        finance.flag_overpayment(d["external_reference"], d["amount_paid"])
    elif event["type"] in ("payment_request.expired", "payment_request.cancelled"):
        orders.mark_unpaid(d["external_reference"])

    return "ok", 200

Paying your customers (B2C)

Send money from the balance you have collected - refunds, winnings, commissions, supplier payments.

You can only pay out what you have collected A payout is capped at your current balance: everything routed to you, less fees, less anything already paid out or settled. The paybill's M-PESA float is shared between every platform on this gateway, so without that cap one platform's payouts would quietly spend another's money.

1. Check what is available

# What can I pay out right now?
curl -s https://route.cloud.or.ke/api/v1/destinations/OPUSPAY/balance \
  -H "Authorization: Bearer $TOKEN"

# {
#   "destination":       "OPUSPAY",
#   "balance":           3000,      <- your payout ceiling
#   "settled_to_date":   120400,
#   "currency":          "KES",
#   "ledger_consistent": true
# }

2. Request the payout

# Pay one of your customers from the balance you have collected.
curl -X POST https://route.cloud.or.ke/api/v1/payments/disburse \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: payout-4471" \
  -d '{
        "msisdn":     "0712345678",
        "amount":     2500,
        "remarks":    "Refund for order 4471",
        "command_id": "BusinessPayment"
      }'

3. What you get back

202 Accepted
{
  "transaction_id":  "b650adf6-337a-470f-996e-3a42748712a1",
  "reference":       "RC-PAYOUT9K2",
  "status":          "submitted",
  "amount":          2500,
  "msisdn":          "254712***678",
  "conversation_id": "AG_20260815_2010ead4245"
}

202 means submitted, not paid. M-PESA has accepted the instruction; the money has not necessarily moved yet. Wait for disbursement.completed before you treat it as done.

When it is refused

422 Unprocessable
{
  "type":   "https://route.cloud.or.ke/errors/disbursement_blocked",
  "title":  "This platform has KES 3,000.00 available to pay out; KES 4,000.00 was
             requested. A payout cannot exceed what has been collected and not yet settled.",
  "status": 422,
  "code":   "disbursement_blocked"
}

A 422 disbursement_blocked means no money moved and your balance is untouched. Three causes: more than your collected balance, over your daily cap, or the paybill's float is exhausted. Only the last is temporary.

In code

<?php
// Paying a customer, with the two things that actually matter.

function payCustomer(string $payoutId, string $msisdn, float $amount, string $why): array
{
    $ch = curl_init('https://route.cloud.or.ke/api/v1/payments/disburse');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . token(),
            'Content-Type: application/json',
            // THE important header. A timeout is not a failure - the payout may
            // already have been submitted. Retrying with the same key returns
            // the original result instead of paying the customer twice.
            'Idempotency-Key: ' . $payoutId,
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'msisdn'     => $msisdn,
            'amount'     => $amount,
            'remarks'    => $why,
            'command_id' => 'BusinessPayment',
        ]),
        CURLOPT_TIMEOUT => 30,
    ]);

    $raw    = (string) curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error  = curl_error($ch);
    curl_close($ch);

    // Network failure: do NOT record this as "not paid". Retry the same key.
    if ($error !== '') {
        throw new RuntimeException("Unknown outcome for {$payoutId}: {$error}. Retry the SAME key.");
    }

    $body = json_decode($raw, true);

    if ($status === 202) {
        // Submitted, NOT paid. Wait for disbursement.completed on your webhook.
        return $body;
    }

    if ($status === 422 && ($body['code'] ?? '') === 'disbursement_blocked') {
        // Over your collected balance, over a cap, or float exhausted.
        // No money moved and your balance is untouched.
        throw new DomainException($body['title']);
    }

    throw new RuntimeException($body['title'] ?? $raw);
}

Receiving the outcome

<?php
// The same webhook endpoint receives payout outcomes.

switch ($event['type']) {

    case 'disbursement.completed':
        // The money has left. THIS is when a payout is done - not the 202 you
        // got back when you asked for it.
        $d = $event['data'];
        $db->prepare('UPDATE payouts SET status = ?, receipt = ?, paid_at = NOW() WHERE reference = ?')
           ->execute(['paid', $d['mpesa']['receipt'], $d['reference']]);
        break;

    case 'disbursement.failed':
        // Nothing left the account and your balance was never debited.
        // $d['mpesa']['result_desc'] says why: wrong number, customer limit
        // exceeded, unregistered subscriber.
        $d = $event['data'];
        $db->prepare('UPDATE payouts SET status = ?, failure = ? WHERE reference = ?')
           ->execute(['failed', $d['mpesa']['result_desc'], $d['reference']]);
        break;
}

Always send Idempotency-Key

It matters more here than anywhere else. A timeout is not a failure - the payout may have gone through. Retry with the same key and you get the original result rather than a second payment.

Whole shillings, KES 1 minimum

M-PESA cannot send cents. An amount with decimals is refused rather than silently rounded to something you did not ask for.

Command IDs

BusinessPayment for general payouts, SalaryPayment for payroll (different limits), PromotionPayment for rewards.

Large payouts may wait

Above a configured threshold a payout parks for a second approver and comes back as pending_approval. It is not lost; it completes once approved.

Failure means untouched

On disbursement.failed nothing left the account and your balance was never debited. Safe to correct the number and try again.

Scope required

disbursements:write, deliberately separate from payments:write so a key that only raises invoices can never send money out.

Paying another business (B2B)

Moving money from your collected balance to a supplier, a bank paybill, or another organisation's till.

Recipients must be approved first A B2C payout goes to a phone number. A B2B transfer moves the same money to an organisation in a single call, which makes it the fastest route out of this gateway if a key is ever stolen. So the shortcode you are paying has to be on an allowlist that an administrator maintains. Ask for a recipient to be added before you write the integration, not while you are debugging it.

Making a transfer

# Pay a supplier, a bank paybill, or another business.
curl -X POST https://route.cloud.or.ke/api/v1/payments/transfer \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: transfer-inv-2291" \
  -d '{
        "shortcode":         "888880",
        "amount":            15000,
        "account_reference": "ACME-01",
        "remarks":           "Invoice INV-2291",
        "command_id":        "BusinessPayBill"
      }'

The response is the same shape as a payout, and means the same thing: 202 is submitted, not paid. Wait for disbursement.completed.

When it is refused

422 Unprocessable
{
  "type":   "https://route.cloud.or.ke/errors/transfer_blocked",
  "title":  "Shortcode 888880 is not on the approved recipient list. An administrator
             must add it under Payouts -> B2B recipients before money can be sent there.",
  "status": 422,
  "code":   "transfer_blocked"
}

A recipient can also be pinned to one account at their paybill, which turns "pay this supplier" into exactly that rather than "pay anyone at their bank":

422 Unprocessable
{
  "type":   "https://route.cloud.or.ke/errors/transfer_blocked",
  "title":  "Payments to Acme Supplies must use the account reference ACME-01.
             \"SOMEONE-ELSE\" was requested.",
  "status": 422,
  "code":   "transfer_blocked"
}

Its own scope

transfers:write, separate from disbursements:write. Refunding customers and paying other organisations are different risks, so they should be different keys.

Same balance rule

A transfer cannot exceed what your platform has collected and not yet settled — including anything already in flight. Check /destinations/{code}/balance first.

Commands

BusinessPayBill needs an account reference; BusinessBuyGoods pays a till and does not. Bulk, disburse-to-business and business-transfer variants are also accepted.

Wrong shortcode is final

Money sent to the wrong organisation reaches a real business that is under no obligation to return it. This is why the allowlist exists and why the approval threshold is lower than for B2C.

Two other ways to collect

Both land as ordinary payment.received webhooks, so if you have already built the receiver above, you have already built the hard part.

A scannable QR

# A QR the customer scans instead of typing a paybill and account number.
curl -X POST https://route.cloud.or.ke/api/v1/payments/qr \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 1500, "account_reference": "INV-8821" }'

# {
#   "qr_code": "iVBORw0KGgoAAAANSUhEUg...",   <- base64 PNG
#   "account_reference": "INV-8821"
# }
#
# Render it directly:  <img src="data:image/png;base64,{qr_code}">
# The payment arrives as an ordinary payment.received webhook.

A recurring charge (Ratiba)

# A recurring charge the customer authorises once (Ratiba).
curl -X POST https://route.cloud.or.ke/api/v1/payments/standing-order \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name":              "Gold plan",
        "msisdn":            "0712345678",
        "amount":            999,
        "account_reference": "SUB-4471",
        "start_date":        "2026-09-01",
        "end_date":          "2027-09-01",
        "frequency":         "4"
      }'

# frequency: 1 one-off, 2 daily, 3 weekly, 4 monthly,
#            5 two-monthly, 6 quarterly, 7 half-yearly, 8 yearly
#
# Every charge afterwards arrives as a normal payment.received webhook
# carrying SUB-4471. Nothing downstream needs to know it was recurring.

The customer authorises it once on their handset. Every charge after that is indistinguishable from a normal paybill payment carrying the same account reference — which is the point: your subscription logic is just your existing payment logic.

Checking the gateway can actually pay

# The paybill's own M-PESA float, which funds every payout on the gateway.
curl -s https://route.cloud.or.ke/api/v1/reports/float \
  -H "Authorization: Bearer $TOKEN"

# {
#   "utility": 412000,     <- what payouts actually draw on
#   "stale":   false
# }
#
# Your balance says what you are OWED. This says whether the gateway can
# pay it right now. Both have to be sufficient.

Petty cash

Your system asks to spend; a person here decides; you are told the answer on your webhook.

201 does not mean go ahead Raising a request only queues it for a human. Nothing is authorised until expense.approved arrives — the response even says "spendable": false so there is no ambiguity about it.

Asking

# Ask for petty cash. This is a REQUEST, not permission to spend.
curl -X POST https://route.cloud.or.ke/api/v1/expenses \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "amount":             2500,
        "description":        "Courier for signed contracts",
        "category":           "petty_cash",
        "external_reference": "EXP-8821"
      }'

# 201 Created
# {
#   "expense_id": "0d9f...", "reference": "EX-9VP95RGT",
#   "status": "pending", "spendable": false
# }
#
# 201 means a person now has to decide. Wait for the webhook.

The decision

<?php
// The decision arrives on the same webhook endpoint as everything else.

switch ($event['type']) {

    case 'expense.approved':
        // You may now spend, up to data.amount, until data.valid_until.
        $d = $event['data'];
        $db->prepare('UPDATE expenses SET status = ?, approved_until = ? WHERE ref = ?')
           ->execute(['approved', $d['valid_until'], $d['external_reference']]);
        break;

    case 'expense.rejected':
        // data.decision.note says why. Do not retry automatically — someone
        // said no, and asking again in a loop is not a workflow.
        $d = $event['data'];
        $db->prepare('UPDATE expenses SET status = ?, reason = ? WHERE ref = ?')
           ->execute(['rejected', $d['decision']['note'], $d['external_reference']]);
        break;
}

Recording what you spent

# Approval is authority to spend, not a record that you did.
curl -X POST https://route.cloud.or.ke/api/v1/expenses/$EXPENSE_ID/spend \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 1000 }'

# { "spent": 1000, "remaining": 1500 }

Approvals expire

valid_until is when the authority lapses. A standing permission nobody remembers granting is how petty cash stops being petty.

Send external_reference

Your own id makes the call idempotent: asking twice returns the original request rather than raising a second claim on the same money.

Rejection is final

Someone said no. Surface it to a person rather than retrying in a loop.

When a customer overpays

Nothing for you to handle — but worth knowing what the gateway does with the difference.

You are told what you invoiced, not what was tendered A customer paying at the paybill types the amount, so a KES 1,500 invoice sometimes arrives as KES 2,000. You asked for 1,500, so payment_request.paid reports amount_paid: 1500 and amount_outstanding: 0. Your order closes normally and you need no reconciliation logic for it.

The extra 500 is the payer's, not yours, so it is held by the gateway rather than added to your balance — you cannot settle or pay out with money that has to be given back. If the customer claims it, it is returned to the handset that sent it. Underpayment is unchanged: paying less still produces payment_request.partially_paid with the balance outstanding.

Two kinds of reference

Both are paid the same way — paybill 4320161, the code as the account number. They differ in lifetime.

1

Payment request

One invoice, raised in advance, expires, tracks an expected amount, closes when paid. Use for orders and one-off bills.
POST /payment-requests

2

Static reference

Permanent, belongs to one customer for as long as the relationship lasts. Never expires, nothing raised in advance — a subscriber id, member number or rent account they pay whenever they like.
Created by an administrator in the portal.

A payment to a static reference emits payment.received carrying the reference, its label and your own id, so you can attribute it without a lookup. If you set an expected amount, anything else is flagged for review — never refused. A subscriber who underpays has still paid, and bouncing their money at the till helps nobody.

Keeping your endpoint correct

URLs are verified before they save

When an operator changes your callback address, we send a signed webhook.verification challenge first. A wrong path or a typo in the hostname is refused at that point instead of silently queueing events you never see.

Secret rotation without a cutover

During a rotation the signature header carries two v1 values — old and new. Accept the request if any of them matches and you can update your secret on your own schedule without dropping an event.

Answering the verification

Treat it like any other event: verify the signature, return 2xx. Echoing data.challenge in your response body is optional, and proves your signature checking works.

Events

Subscribe per destination. Every one carries the same envelope.

EventWhenWhat to do
payment_request.paid The invoice is fully settled Release the goods or service. This is the one that matters.
payment_request.partially_paid Some money arrived, more is due Record the part payment. Do not release anything yet.
payment_request.overpaid They paid more than asked Money is yours; decide whether to refund the difference.
payment_request.expired The code passed its deadline unpaid Mark the order unpaid, or raise a fresh code.
payment_request.cancelled Someone voided the code Mark the order unpaid.
payment.received A payment with no invoice attached Only if you also accept ad-hoc paybill payments.
payment.failed An STK prompt was declined or timed out Nothing was charged. Offer to retry.
payment.reversed A completed payment was reversed Undo whatever you released. The money has gone back.
payment.held_in_suspense Money arrived but could not be routed Informational — an operator is triaging it.
disbursement.completed A payout to a customer succeeded Close the payout.
disbursement.failed A payout failed The money did not leave. Retry or escalate.
settlement.paid A settlement batch was released to you Reconcile your bank/M-PESA.

If your endpoint was down

We retry for roughly a day with exponential backoff, then dead-letter the event so an operator can replay it. You can also just ask.

# Fallback, if your endpoint was unreachable for a while.
# Polling is the safety net, not the primary path — use the webhook.

curl -s https://route.cloud.or.ke/api/v1/payment-requests/YTA4XP \
  -H "Authorization: Bearer $TOKEN"

# Or reconcile a batch by your own reference:
curl -s "https://route.cloud.or.ke/api/v1/payment-requests?status=paid&per_page=100" \
  -H "Authorization: Bearer $TOKEN"

Things worth knowing

Codes avoid lookalikes

The alphabet excludes 0 O 1 I L 2 Z 5 S 8 B, so a customer reading a code over the phone cannot key a valid but different one.

Part payments

Off by default. Enable allow_partial and the code stays open, emitting partially_paid until settled.

Overpayment

Allowed by default — refusing a customer's money at the till is worse than sorting it out after. You get overpaid with the real figure.

Expiry

Default 24 hours. An expired code is refused before any money moves, and you get payment_request.expired.

Codes are never reused

Permanently unique, so a customer paying an old code from a stale SMS can never settle somebody else's invoice.

Callback allowlist

Only Safaricom's ranges may reach our callback endpoints: 196.201.214.0/24 196.201.213.0/24 196.201.212.0/24

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