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
- Register and verify your email in the dashboard.
- To accept cards, complete verification in the dashboard (upload your KYC documents there — no coding needed).
- To collect on M-Pesa, link and activate a till / paybill / bank account in the dashboard. Each account gets its own API credentials.
- 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)
| ResultCode | Meaning | CloudPay status |
|---|---|---|
0 | Success — payment completed | COMPLETED |
1 | Insufficient M-Pesa balance | FAILED |
1032 | Cancelled by user / prompt timed out | CANCELLED |
1037 | Timeout — user unreachable | FAILED |
2001 | Wrong M-Pesa PIN | FAILED |
Card charge
| Code | Meaning |
|---|---|
00 | Approved |
05 | Do not honour |
51 | Insufficient funds |
54 | Expired card |
CloudPay error codes
| errorCode | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing/invalid token or key |
OTP_INVALID | 401 | Wrong/expired one-time code |
INSUFFICIENT_BALANCE | 402 | Wallet can't cover amount + fee |
CARD_DECLINED | 402 | Card charge declined (see code) |
VALIDATION_ERROR | 422 | Missing/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.