Overview

CloudPay is a unified payment API for M-Pesa (collect via STK push and a deposit wallet), Airtel Money and Cards. Accounts, verification, credentials and settlements are managed in your dashboard; the API is for moving money.

  • Live base URL: https://pay.cloud.or.ke/api
  • Sandbox base URL: https://pay.cloud.or.ke/sandbox/api

All responses share one envelope:

{ "status": "success" | "error", "message": "...", "data": { ... } }
// errors add: "errorCode": "..."

Authentication

Create your account and sign in from the dashboard (sign-in is protected by an email one-time code). Two credential types are used:

  • Dashboard session — manage your business, accounts, verification and settlements. Withdrawals are dashboard-only.
  • Account access token — what your website/server uses to collect payments; see Account Credentials.

Getting Started

  1. Register and verify your email in the dashboard.
  2. To accept cards, complete verification in the dashboard (upload your KYC documents there — no coding needed).
  3. To collect on M-Pesa, link and activate a till / paybill / bank account in the dashboard. Each account gets its own API credentials.
  4. Copy the account's consumer key + secret, get a token, and start collecting.

Account Credentials & Access Token

Every collection account has its own consumer key + secret (live and sandbox), shown in your dashboard. Exchange them for a short-lived access token, then call the payment endpoints with it.

# Get an access token (~1 hour). HTTP Basic of key:secret:
POST /api/oauth/token
Authorization: Basic base64(consumerKey:consumerSecret)

→ 200 { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 }

# JSON body also accepted:
POST /api/oauth/token   { "consumerKey": "...", "consumerSecret": "..." }

# Sandbox keys → the sandbox token endpoint:
POST /sandbox/api/oauth/token

M-Pesa STK Push (collect from a customer)

Authenticate with the account access token, then prompt the customer's phone.

POST /api/payments/mpesa/stkpush
Authorization: Bearer <access_token>
{ "phone": "254712345678", "amount": 1500,
  "transactionReference": "INV-2041", "description": "Order 42" }

→ 200 { "reference": "TXN-XXXXXXXXXX", "checkoutRequestId": "ws_CO_..." }
→ 401 UNAUTHORIZED   token missing/expired — call /api/oauth/token again
→ 403                collection is unavailable for this account right now

# Check status (same Bearer token)
GET /api/payments/status/{reference}
# Your recent transactions
GET /api/transactions

When the customer approves, the result is delivered to your configured webhook, and you can also poll the status endpoint.

M-Pesa Wallet (deposit & settle)

Your dashboard assigns you a unique wallet account number. Money paid to our paybill using that account number credits your wallet — whether it was started through the API or paid offline at an M-Pesa agent / the STK menu. Withdrawals are done from the dashboard.

Deposit via STK (API)

POST /api/wallet/deposit
Authorization: Bearer <access_token>
{ "phone": "254712345678", "amount": 500 }
→ 200 { "reference": "DEP-XXXXXXXXXX" }

Deposit offline (no code)

Customers can pay the paybill shown in your dashboard and enter your wallet account number as the account. We confirm the payment and credit your wallet automatically. Each M-Pesa receipt is processed once, so a balance is never credited twice.

Balance & withdraw

GET  /api/wallet/balance         → { "accountNumber": "78543", "balance": 1200 }
# Withdrawals are initiated from the dashboard (minimum 10 KES).
# A withdrawal fee applies and is shown before you confirm.

Airtel Money

# Wallet balance
GET /api/airtel/balance

# Settle to an Airtel number
POST /api/airtel/withdraw   { "phone": "254733123456", "amount": 1000 }
→ 200 { "customerGets": 1000, "reference": "WDR-..." }
→ 402 INSUFFICIENT_BALANCE

Confirmation arrives at your Airtel webhook; failures are auto-refunded to your wallet.

Card Payments & Wallet

Charge cards; approved charges accrue in your card wallet, which you settle out to M-Pesa, Airtel or a bank account. Card acceptance requires completing verification in your dashboard.

Tokenize & charge

POST /api/payments/card/tokenize
{ "cardNumber": "4111111111111111", "expMonth": 12, "expYear": 2028, "cvv": "123" }
→ { "token": "tok_...", "brand": "VISA", "last4": "1111" }

POST /api/payments/card/charge
{ "token": "tok_...", "amount": 2500, "currency": "KES" }
→ 200 { "reference": "CRD-...", "resultCode": "00", "cardWalletBalance": 2500 }
→ 402 CARD_DECLINED

Balance & withdraw (to M-Pesa / Airtel / bank)

GET  /api/card/balance   → { "balance": 2500 }

POST /api/card/withdraw  { "destination": "mpesa",  "amount": 1000, "phone": "254712345678" }
POST /api/card/withdraw  { "destination": "airtel", "amount": 1000, "phone": "254733123456" }
POST /api/card/withdraw  { "destination": "bank",   "amount": 1000,
                           "bankCode": "01", "accountNumber": "0110xxxx", "accountName": "Acme Ltd" }
→ 200 { "reference": "CWD-...", "status": "COMPLETED" | "PENDING", "cardWalletBalance": 1450 }

Webhooks / Callbacks

Point CloudPay at your server so your system is told the moment a transaction finishes. In your dashboard, open an account (or the wallet / Airtel / card section), paste your callback URL, and copy the signing secret shown next to it.

When a transaction reaches a final state we POST JSON to your URL, signed with HMAC-SHA256 of the raw body using your secret, in the header X-CloudPay-Signature. Always verify the signature and treat the same reference as idempotent (ignore repeats).

Payload

POST <your callback URL>
X-CloudPay-Signature: <hex hmac-sha256>
X-CloudPay-Event: payment.completed
{
  "event":      "payment.completed",     // or payment.failed, deposit.completed,
                                          // withdrawal.completed, withdrawal.failed,
                                          // card.charge.completed
  "channel":    "MPESA",                  // MPESA | WALLET | AIRTEL | CARD
  "reference":  "TXN-XXXXXXXXXX",
  "status":     "COMPLETED",              // COMPLETED | FAILED | CANCELLED
  "amount":     1500.00,
  "currency":   "KES",
  "phone":      "254712345678",
  "receipt":    "SGH3XYZ890",             // M-Pesa receipt when applicable
  "resultCode": "0",
  "created_at": "2026-07-05T12:00:00+03:00"
}

Verify — PHP

<?php
$secret  = 'whsec_your_signing_secret';
$payload = file_get_contents('php://input');
$sig     = $_SERVER['HTTP_X_CLOUDPAY_SIGNATURE'] ?? '';

if (!hash_equals(hash_hmac('sha256', $payload, $secret), $sig)) {
    http_response_code(400); exit('bad signature');
}
$e = json_decode($payload, true);

// Idempotency: ignore a reference you have already handled.
if (already_processed($e['reference'])) { http_response_code(200); exit('ok'); }

if ($e['status'] === 'COMPLETED') {
    mark_order_paid($e['reference'], $e['amount'], $e['receipt'] ?? null);
}
http_response_code(200);
echo 'ok';

Verify — Node.js (Express)

const crypto = require('crypto');
const SECRET = 'whsec_your_signing_secret';

// Use the RAW body for signing:
app.post('/cloudpay/webhook',
  express.raw({ type: '*/*' }), (req, res) => {
    const sig = req.get('X-CloudPay-Signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(req.body).digest('hex');
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
        return res.status(400).send('bad signature');

    const e = JSON.parse(req.body.toString());
    if (e.status === 'COMPLETED') { /* fulfill e.reference */ }
    res.status(200).send('ok');
});

Respond 200 quickly. Non-2xx responses are retried a few times. The same signing/verification applies to every channel — M-Pesa, M-Pesa wallet, Airtel and card; only channel and event differ.

Result Codes

M-Pesa (STK push)

ResultCodeMeaningCloudPay status
0Success — payment completedCOMPLETED
1Insufficient M-Pesa balanceFAILED
1032Cancelled by user / prompt timed outCANCELLED
1037Timeout — user unreachableFAILED
2001Wrong M-Pesa PINFAILED

Card charge

CodeMeaning
00Approved
05Do not honour
51Insufficient funds
54Expired card

CloudPay error codes

errorCodeHTTPMeaning
UNAUTHORIZED401Missing/invalid token or key
OTP_INVALID401Wrong/expired one-time code
INSUFFICIENT_BALANCE402Wallet can't cover amount + fee
CARD_DECLINED402Card charge declined (see code)
VALIDATION_ERROR422Missing/invalid fields

Sandbox

Base URL https://pay.cloud.or.ke/sandbox/api with your sandbox credentials. Identical endpoints, fake money. Resolve a pending payment yourself with the simulator:

POST /sandbox/api/sandbox/simulate/stk-result
{ "reference": "TXN-...", "resultCode": 0 }      # 1032 = cancel

POST /sandbox/api/sandbox/wallet/topup   { "amount": 10000 }

See the sandbox quick-start page.

No-Code Payment Links

Create a link in your dashboard and share https://pay.cloud.or.ke/pay/CODE. The hosted page collects the customer's phone and fires the STK push — no integration required.

WordPress & WooCommerce Plugins

WooCommerce gateway (plugins/cloudpay-woocommerce): adds "M-Pesa (CloudPay)" at checkout. Configure the account's consumer key + secret and the sandbox toggle under WooCommerce ▸ Settings ▸ Payments.

WordPress button plugin (plugins/cloudpay-wp): the shortcode [cloudpay_button link="CODE" text="Pay now"] embeds a hosted payment button.