Cyzora API documentation
Collect M-Pesa payments with an STK prompt or a hosted checkout page, pay out to phones, tills, paybills and bank accounts, and hear about every result through signed webhooks.
1. Quickstart
Send your first test payment in a few minutes, without moving real money.
In the dashboard, open API keys and create a Test key. It starts with sk_test_.
Post an amount and a phone. 254700000001 always succeeds in test mode.
Add a webhook URL and we post the signed result to your server.
curl -X POST "https://api.cyzora.co.ke/api/v1/charges" \
-H "X-API-Key: sk_test_..." \
-H "Idempotency-Key: order_1042" \
-H "Content-Type: application/json" \
-d '{"amount": 150000, "phone": "254700000001", "account_ref": "Order 1042"}'2. Authentication
Each key you create in the dashboard is a pair: a secret key and a public key.
| Key | Starts with | Use it for |
|---|---|---|
| Secret key | sk_live_ / sk_test_ | Every server call: charges, checkout sessions, payouts, exports. Send it in the X-API-Key header or as Authorization: Bearer. |
| Public key | pk_live_ / pk_test_ | Safe to put in a web page. It only identifies your business to the hosted checkout and cannot read data or move money. |
X-API-Key: sk_live_yourSecretKeyKeep secret keys on your server. Never put one in a website, a mobile app or a public repository. If a key leaks, revoke it under API keys and create a new one.
3. Test mode
Test keys use the same endpoints, never send a real prompt, and never touch your balance or reports.
Pay with one of these numbers to choose the outcome. The result arrives after a few seconds.
| Phone number | Outcome |
|---|---|
| 254700000001 | Success, with a TEST receipt |
| 254700000002 | Failed: balance too low |
| 254700000003 | Failed: the customer cancelled |
| 254700000004 | Timed out: no PIN entered |
Test webhooks are signed like real ones and carry "livemode": false. Test keys cannot create payouts.
4. Collect with STK push
Send an M-Pesa prompt straight to the customer’s phone. You build the screen; we handle M-Pesa.
| Field | Required | Description |
|---|---|---|
| amount | Yes | Amount in cents. KES 1,500 is 150000. |
| phone | Yes | The customer’s M-Pesa number, like 254712345678 or 0712345678. |
| account_ref | Optional | Your order or invoice number. It shows in your dashboard and in webhooks. |
Idempotency-Key header, such as your order number. If your request is retried, we return the first result instead of prompting the customer twice. {
"id": "7f0c0f43-5d1b-4a43-9a47-2f6f1d6c0b11",
"reference": "chg_9f8a2c1b4d3e5a6b7c8d",
"amount": 150000,
"currency": "KES",
"payer_phone": "254712345678",
"account_ref": "Order 1042",
"status": "pending",
"mpesa_receipt": null
}The charge starts as pending and becomes succeeded, failed or timeout. Wait for the webhook, or check it yourself:
curl "https://api.cyzora.co.ke/api/v1/charges/7f0c0f43-5d1b-4a43-9a47-2f6f1d6c0b11" \
-H "X-API-Key: sk_live_..."5. Hosted checkout
Start a payment on your server and send the customer to our page. It asks for their number, sends the prompt and shows the result.
- Your server calls
POST /api/v1/transactions/initialize. - Redirect the customer to the
checkout_urlyou get back. - They pay with M-Pesa and we send them to your
callback_url. - Confirm the payment with the webhook or the verify call before you hand over anything.
curl -X POST "https://api.cyzora.co.ke/api/v1/transactions/initialize" \
-H "X-API-Key: sk_live_..." \
-H "Idempotency-Key: order-1042" \
-H "Content-Type: application/json" \
-d '{
"amount": 250000,
"account_ref": "Order 1042",
"description": "2 x Margherita pizza",
"callback_url": "https://shop.example.com/orders/1042/thanks",
"cancel_url": "https://shop.example.com/cart",
"customer_phone": "0712345678",
"metadata": { "order_id": "1042" },
"expires_in": 1800
}'{
"reference": "chg_9f8a2c1b4d3e5a6b7c8d",
"checkout_url": "https://checkout.cyzora.co.ke/pay/chg_9f8a2c1b4d3e5a6b7c8d",
"amount": 250000,
"currency": "KES",
"status": "awaiting_payment",
"expires_at": "2026-10-07T12:30:00Z",
"livemode": true
}| Field | Required | Description |
|---|---|---|
| amount | Yes | Amount in cents. The customer can’t change it. |
| account_ref | Optional | Your order number. Returned in webhooks. |
| callback_url | Optional | Where the customer lands after paying. |
| cancel_url | Optional | Where “Back to the store” goes if the payment fails or the link expires. |
| description | Optional | Shown to the customer, up to 200 characters. |
| customer_phone | Optional | Prefills the M-Pesa number. |
| customer_email | Optional | Stored with the session and returned in webhooks. |
| metadata | Optional | Up to 20 text key and value pairs, returned in webhooks and the verify call. |
| expires_in | Optional | Seconds the link stays payable, 300 to 86400. Default 1800. |
callback_url and cancel_url must use https (plain http only works for localhost). If you list allowed redirect domains on the API keys page, the URLs must be on one of them.
The customer returns with ?reference=…&status=success, but anyone can type that address. Only mark an order paid after the charge.succeeded webhook or the verify call says succeeded.
curl "https://api.cyzora.co.ke/api/v1/transactions/verify/chg_9f8a2c1b4d3e5a6b7c8d" \
-H "X-API-Key: sk_live_..."A session can be retried after a failed attempt until it expires, and only one payment can succeed per session. When nobody pays in time you get charge.expired and no money was taken.
6. Payment links
No code at all. Create a link in the dashboard and share it on WhatsApp, SMS or as a printed QR code.
- Fixed price or any amount. Set a price, or let the customer type how much to pay.
- Optional end date. After it, the link stops taking payments and says so.
- Up to three questions, such as full name or order number, each required or optional.
- Totals per link. The dashboard shows how much each link collected and when it was last paid.
Links live at checkout.cyzora.co.ke/pay/<code>. Customers paying by paybill can use the five-digit code as the account number. The customer’s answers are saved on the charge and sent in webhooks:
{
"id": "1b2c3d4e-...",
"amount": 500000,
"status": "succeeded",
"account_ref": "Wedding deposit",
"payment_link_label": "Wedding deposit",
"metadata": {
"payment_link_code": "48213",
"full_name": "Jane Wanjiku",
"wedding_date": "14 Feb 2027"
}
}7. Payouts
Send money from your Cyzora balance to an M-Pesa number, a till, a paybill or a bank account. Over the API you can pay any destination directly, or pay a saved payout channel by its ID. Withdrawals made by hand in the dashboard always go to a saved, verified channel.
Payout channels
| Type | You give us | Ready |
|---|---|---|
| mobile | An M-Pesa number | After you type the 6-digit code we text to that number |
| till | A Buy Goods till number | Straight away |
| paybill | A paybill number and account number | Straight away |
| bank | The bank, account number and account name | Straight away. We pay through the bank’s M-Pesa paybill with your account number as the reference. |
Channels are saved on the Payouts page of the dashboard. Each one gets an ID like ch_1a2b3c4d5e6f7a8b. Saving destinations you pay often is optional for the API.
curl "https://api.cyzora.co.ke/api/v1/payout-channels" -H "X-API-Key: sk_live_..."
{
"channels": [
{
"id": "5c1e...",
"channel_id": "ch_1a2b3c4d5e6f7a8b",
"type": "bank",
"label": "Main account",
"status": "verified",
"display": "Equity Bank ••••6789"
}
]
}Send a payout
| Field | Required | Description |
|---|---|---|
| amount | Yes | Amount in cents that the destination receives in full. The minimum is KES 30 (3000). Cyzora’s 1.5% is taken from your remaining balance, not from this amount. |
| channel_id | Or a destination | A saved, verified channel on your account. |
| destination_type | Or channel_id | mobile, till, paybill or bank. |
| phone | mobile | The M-Pesa number, e.g. 0712345678. |
| short_code | till, paybill | The till or paybill number (5 to 7 digits). |
| account_ref | paybill | The account number on that paybill. |
| bank_code, account_number, account_name | bank | Bank codes come from GET /api/v1/payout-banks. We pay through the bank’s M-Pesa paybill with the account number as the reference. |
curl -X POST "https://api.cyzora.co.ke/api/v1/payouts" \
-H "X-API-Key: sk_live_..." \
-H "Idempotency-Key: payout_2026_10_001" \
-H "Content-Type: application/json" \
-d '{"amount": 250000, "channel_id": "ch_1a2b3c4d5e6f7a8b"}'# An M-Pesa number
curl -X POST "https://api.cyzora.co.ke/api/v1/payouts" -H "X-API-Key: sk_live_..." -H "Idempotency-Key: payout_2026_10_002" \
-H "Content-Type: application/json" \
-d '{"amount": 150000, "destination_type": "mobile", "phone": "0712345678"}'
# A bank account (paid through the bank's paybill)
curl -X POST "https://api.cyzora.co.ke/api/v1/payouts" -H "X-API-Key: sk_live_..." -H "Idempotency-Key: payout_2026_10_003" \
-H "Content-Type: application/json" \
-d '{"amount": 500000, "destination_type": "bank", "bank_code": "68", "account_number": "0123456789", "account_name": "Jane Doe"}'{
"id": "9d4f...",
"amount": 250000,
"fee": 3750,
"channel_id": "ch_1a2b3c4d5e6f7a8b",
"destination_type": "paybill",
"status": "requested",
"created_at": "2026-10-07T12:00:00Z"
}Use a live secret key and an Idempotency-Key so a retry never pays twice. Your business must be verified before payouts run. A payout goes requested, then processing, then paid or failed, and you get payout.paid or payout.failed. List payouts with GET /api/v1/payouts and read one with GET /api/v1/payouts/{id}.
curl "https://api.cyzora.co.ke/api/v1/payouts/fee-preview?amount=250000" -H "X-API-Key: sk_live_..."
{ "amount": 250000, "fee": 3750 }8. Webhooks
Add your server’s URL on the Webhooks page. That’s all you enter: we create the signing secret for you.
- The secret starts with
whsec_and is shown once when you add the URL. You can view it again later with your password. - Each request carries
X-Cyzora-EventandX-Cyzora-Signature: an HMAC-SHA256 of the raw request body with your secret, in hex. - Reply with any 2xx status to confirm. Otherwise we retry after 10 seconds, 30 seconds, 2 minutes and 10 minutes, then mark the delivery failed and email the address on your account.
- Use “Send test” on the Webhooks page to get a
webhook.testevent.
| Event | When |
|---|---|
| charge.succeeded | M-Pesa confirmed a payment. |
| charge.failed | A payment failed, timed out or was cancelled. For checkout sessions, retryable: true means the customer can still try again. |
| charge.expired | A checkout session ended unpaid. No money was taken. |
| payout.paid | A payout reached its destination. Includes channel_id. |
| payout.failed | A payout failed. The amount is back in your balance. Includes channel_id. |
{
"event": "charge.succeeded",
"charge_id": "7f0c0f43-5d1b-4a43-9a47-2f6f1d6c0b11",
"reference": "chg_9f8a2c1b4d3e5a6b7c8d",
"account_ref": "Order 1042",
"amount": 250000,
"currency": "KES",
"status": "succeeded",
"mpesa_receipt": "TBR89X1024",
"metadata": { "order_id": "1042" },
"livemode": true
}{
"event": "payout.paid",
"payout_id": "9d4f...",
"channel_id": "ch_1a2b3c4d5e6f7a8b",
"amount": 250000,
"fee": 3750,
"status": "paid",
"transaction_id": "TJ81AB2C3D"
}import crypto from "node:crypto";
import express from "express";
const app = express();
// Use the raw body: re-serialised JSON will not match the signature.
app.post("/cyzora-webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected = crypto
.createHmac("sha256", process.env.CYZORA_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const received = String(req.headers["x-cyzora-signature"] || "");
const ok = expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.event === "charge.succeeded") {
// mark the order paid, using event.reference or event.metadata
}
res.sendStatus(200);
});<?php
$raw = file_get_contents('php://input');
$secret = getenv('CYZORA_WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_CYZORA_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if ($event['event'] === 'charge.succeeded') {
// mark the order paid
}
http_response_code(200);9. CSV export
Download your transactions for accounting, from the Transactions page or the API.
curl "https://api.cyzora.co.ke/api/v1/charges/export?from=2026-10-01&to=2026-10-07&status=succeeded" \
-H "X-API-Key: sk_live_..." -o transactions.csvfrom and to are dates and to is included. status is optional. Columns: date, customer phone, reference, M-Pesa receipt, status, amount in KES, payment link, and the customer’s answers.
10. WooCommerce
Take M-Pesa at checkout on your WordPress store with the official plugin.
- In WordPress, go to Plugins, Add New, Upload Plugin, choose the zip, then Install and Activate. WooCommerce must already be active.
- Make sure your store uses https, and add its domain under Redirect domains on the API keys page.
- Open WooCommerce, Settings, Payments, Cyzora Pay, paste your secret key and save. Use a test key first.
- Add
https://yourstore.com/wc-api/cyzora_webhookon the Webhooks page so orders update even if the customer closes the tab. - Place a test order with 254700000001, then switch to your live key.
The plugin works with the classic and block checkouts and with WooCommerce’s order storage. It shows only for stores priced in KES, and it confirms every payment with Cyzora before marking an order paid.
11. Errors
Errors come back as JSON with a plain message you can show or log.
{ "error": "channel ch_1a2b... is not verified yet" }| Status | Meaning |
|---|---|
| 400 | Something in the request is missing or wrong, such as the amount, the phone, or a balance too low for the payout. |
| 401 | No key, or the key is wrong or revoked. |
| 403 | Not allowed: a test key creating a payout, your business isn’t verified yet, or the account is suspended. |
| 404 | Not found, including a channel that belongs to another account. |
| 409 | That payout channel is already on your account. |
| 410 | The payment link has expired. |
| 422 | The payout channel isn’t verified yet, or was blocked. |
| 429 | Too many requests. Wait a moment and try again. |
Questions? Email support@cyzora.co.ke.