Zetu Pay developer documentation
Zetu Pay lets a business or an individual collect M-Pesa payments and get paid, with one API. You can charge a customer's phone, share a hosted payment link, accept paybill payments, and receive signed webhook events when a payment changes state.
Collection channels
For supported STK Push and payment-link collections, choose an enabled channel in your account. C2B Paybill payments use the account code shown in your dashboard. Available methods depend on channel approval and configuration.
| Channel | Where the money goes | How you get it |
|---|---|---|
| Zetu Pay collection | A confirmed collection is credited to your Zetu Pay wallet after applicable fees. | Eligible wallet balance may be withdrawn to M-Pesa by B2C after required verification and checks. |
| Direct channel | Where approved and enabled, the customer payment is sent to the merchant’s configured Paybill or Till. | Funds settle through the merchant’s provider arrangement and are not credited to the Zetu Pay wallet. |
How a payment flows
- Your server calls
POST /payments(or a customer opens a payment link). - We send an M-Pesa prompt to the customer's phone. The response is
202: the payment isPENDING. - The customer enters their PIN. M-Pesa tells us the result.
- We update the payment and send you a signed webhook. You can also fetch the payment at any time.
Never fulfil an order from the 202 response alone. Fulfil when you receive payment.success, or when GET /payments/:id says SUCCESS.
Quickstart
- Create an account and verify your email.
- In the dashboard, open Developers and create an API key. Copy it now: it is shown once.
- Set your webhook URL (a public HTTPS address) and copy your signing secret.
- Send your first payment request:
curl -X POST https://backend.zetopay.co.ke/api/v1/payments \
-H "Authorization: Bearer zp_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"amount": 500, "phone": "0712345678", "external_reference": "ORDER-1001"}'
const res = await fetch('https://backend.zetopay.co.ke/api/v1/payments', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ZETUPAY_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 500, phone: '0712345678', external_reference: 'ORDER-1001' }),
});
const { success, data, error } = await res.json();
if (!success) throw new Error(`${error.code}: ${error.message}`);
console.log(data.id, data.status); // clx9k2..., PENDING
$ch = curl_init('https://backend.zetopay.co.ke/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('ZETUPAY_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['amount' => 500, 'phone' => '0712345678', 'external_reference' => 'ORDER-1001']),
]);
$out = json_decode(curl_exec($ch), true);
if (!$out['success']) { throw new Exception($out['error']['code'] . ': ' . $out['error']['message']); }
echo $out['data']['status']; // PENDING
import os, requests
r = requests.post(
"https://backend.zetopay.co.ke/api/v1/payments",
headers={"Authorization": f"Bearer {os.environ['ZETUPAY_KEY']}"},
json={"amount": 500, "phone": "0712345678", "external_reference": "ORDER-1001"},
timeout=15,
)
body = r.json()
if not body["success"]:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"]["status"]) # PENDINGThe customer's phone shows the M-Pesa prompt. When they approve it you receive a payment.success webhook.
Authentication
Send your API key as a Bearer token on every request:
Authorization: Bearer zp_live_xxxxxxxxxxxxxxxx
- Create keys in Dashboard, Developers. We store only a hash, so a lost key cannot be recovered: create a new one.
- Keep keys on your server. Never put one in a website, mobile app, repository or support chat.
- A key has the full access of your account. A suspended account's keys stop working.
- Dashboard logins use a short-lived session token instead of a key. Some actions (creating API keys, connecting channels) need a login session and refuse API keys on purpose.
Requests and responses
- Base URL:
https://backend.zetopay.co.ke/api/v1 - Format: JSON in, JSON out, UTF-8, HTTPS only.
- Money: amounts are in Kenyan shillings as numbers, for example
1500. M-Pesa moves whole shillings only, so amounts with cents such as1500.50are rejected with a validation error. - Phone numbers may be
0712345678,254712345678or+254712345678. We return the254…form. - Times are ISO 8601 in UTC.
Every response has the same envelope:
// success
{ "success": true, "data": { ... } }
// failure
{ "success": false, "error": { "code": "INVALID_PHONE", "message": "Enter a valid Kenyan phone number" } }
Branch on error.code (stable), not on message (for people).
| HTTP | Meaning |
|---|---|
| 200 / 201 | Done. |
| 202 | Accepted and being processed. Wait for the webhook or poll. |
| 400 | Invalid input. Fix the request. |
| 401 | Missing or invalid credentials. |
| 402 | Not enough funds or fee balance. |
| 403 | Not allowed yet (for example verification is required). |
| 404 / 409 | Not found / wrong state for this action. |
| 429 | Too many requests. Slow down and retry. |
| 5xx | Our side or M-Pesa. Safe to retry with the same reference. |
Idempotency
- Payments: send a unique
external_referenceper order. Retrying the same reference with the same amount, phone, channel and callback details returns the original payment and does not start another prompt. Reusing a reference with different payment details returns409 REFERENCE_REUSED. - Withdrawals: an
Idempotency-Keyheader is required. Retrying with the same key and same amount, external reference and callback URL returns the original withdrawal. Reusing the key with different details returns409 IDEMPOTENCY_KEY_REUSED.
Errors
| Code | HTTP | What to do |
|---|---|---|
| VALIDATION | 400 | A field is missing or wrong. The message names it. |
| INVALID_PHONE | 400 | Use a Kenyan Safaricom number. |
| INVALID_URL | 400 | Webhook and redirect URLs must be public https addresses. |
| UNAUTHORIZED | 401 | Missing, wrong or revoked key. |
| FORBIDDEN | 403 | Account suspended, or this action needs a login session. |
| EMAIL_NOT_VERIFIED | 403 | Verify your email. |
| KYC_REQUIRED | 403 | Complete the verification steps shown in your dashboard. |
| PROVIDER_ERROR | 502 | The provider definitively rejected the prompt. Check the transaction, then use a new unique reference for a new attempt. |
| REFERENCE_REUSED | 409 | The same external reference was sent with different payment details. Use a new reference for a genuinely new payment. |
| SERVICE_BALANCE_LOW | 402 | Top up your fee balance to collect on your own channel. |
| CHANNEL_NOT_FOUND | 404 | The channel does not exist or is not active. |
| IDEMPOTENCY_KEY_REQUIRED | 400 | Send an Idempotency-Key header. |
| INVALID_AMOUNT | 400 | Outside the allowed range. |
| IDEMPOTENCY_KEY_REUSED | 409 | A withdrawal key was reused with different details. Use a new key for a new withdrawal. |
| INSUFFICIENT_FUNDS | 402 | Wallet balance is below amount plus fee. |
| PAYOUT_PHONE_NOT_VERIFIED | 403 | Add a payout phone and wait for us to verify it. |
| TOO_MANY_ATTEMPTS / TOO_SOON | 429 | Wait a few minutes. |
| NOT_FOUND | 404 | No such resource on your account. |
Registration
Sign-up is a short guided flow at /register: choose Personal or Business, enter your details, verify your email with a 6-digit code, then (when you are ready) verify your identity. You can pause at any step and return later: log in and the flow resumes where you stopped.
These endpoints back that flow. They use a login session, not an API key.
| Endpoint | Purpose |
|---|---|
POST /auth/signup | account_type (PERSONAL | BUSINESS), name, email, phone, password (10+ chars), accept_terms: true. Sends the verification code and returns a session token. |
POST /auth/login | email, password. |
POST /me/email/verify · /me/email/resend | Confirm the 6-digit code (it expires in 15 minutes; 5 wrong tries cancel it) or ask for a new one (once a minute). |
POST /auth/forgot · /auth/reset | Emails a one-time reset link valid for 30 minutes. The reply is the same whether or not the email has an account. |
GET /me | Your profile, wallet, paybill details and email-verification status. |
Account verification
Some account features, including withdrawals or connecting a merchant-owned channel, may require account verification. Complete verification through the authenticated Zetu Pay dashboard and follow the instructions shown there. This public API reference does not expose identity-document submission endpoints.
Where to manage verification
Open the dashboard and follow the verification checklist displayed for your account. Submit personal or business information only through the authenticated dashboard. The dashboard shows your verification status and any next steps. For help, contact support through the Support Centre; never send passwords, API keys, M-Pesa PINs or provider secrets to support.
Notifications and email
Zetu Pay tells you what matters in two places: the bell in your dashboard and email. We send email from [email protected].
| You get an email when |
|---|
| You sign up (your 6-digit code) and when you ask to reset your password. |
| Your verification is received, approved or not approved (with the reason). |
| Our team sends you a message, for example about a payment under review. Staff can re-send these if one does not arrive. |
GET /notifications returns your latest 50 with an unread count. POST /notifications/read-all marks them read. Codes and reset links are never re-sent; request a new one instead. Add [email protected] to your contacts if our email lands in spam.
Payments (M-Pesa prompt)
| Field | Type | Description |
|---|---|---|
| amount | number, required | Whole KES, from 1 to 250,000. |
| phone | string, required | The customer's M-Pesa number. |
| external_reference | string, ≤64 | Your order or invoice number. Unique per account. Makes the call safe to retry. |
| channel_id | string | zetupay (default) or a channel ID from GET /channels. |
| callback_url | https URL | Send this payment's webhooks here instead of your default webhook URL. |
// 202 Accepted
{ "success": true, "data": {
"id": "clx9k2...", "kind": "PAYMENT", "settlement": "POOLED", "status": "PENDING",
"amount": 500, "fee": 7.5, "phone": "254712345678", "external_reference": "ORDER-1001",
"link_id": null, "receipt": null, "created_at": "2026-10-29T08:15:00.000Z" } }
:id is the payment id or your external_reference.
Newest first, up to 100 per page. Includes payments, payment-link payments, paybill payments and fee top-ups (kind: "TOPUP").
Statuses
| Status | Meaning |
|---|---|
| PENDING | Prompt sent. Waiting for the customer. |
| SUCCESS | Paid. receipt holds the M-Pesa code. Safe to fulfil. |
| FAILED | Cancelled, timed out, wrong PIN or insufficient funds. Not charged. You may start a new payment. |
A pending payment that gets no answer is checked with M-Pesa automatically and closed as FAILED after 30 minutes.
Channels
Your account ID identifies you; each channel ID identifies a place money can go. Find both in Dashboard, Developers and Channels.
[ { "id": "zetupay", "name": "Zetu Pay collection", "type": "POOLED", "status": "ACTIVE" },
{ "id": "clx2...", "name": "Main shop till", "type": "DIRECT", "mode": "OWN_DARAJA", "kind": "TILL",
"shortcode": "5551234", "account_number": null, "status": "PENDING", "verified": false } ]
Connecting your own account
Needs approved verification and a login session. Connect or manage a channel in Dashboard, Channels. Only use channel options shown in your dashboard for your account. Never paste provider credentials into a public website, code sample, support message or unapproved API request.
Available channel types and the information needed to connect them are displayed in the authenticated dashboard. For safety, this public guide does not publish provider-credential submission payloads or internal verification steps. Zetu Pay staff will never ask you to send a consumer secret, passkey, password or API key through support.
The number of channels available to an account may be limited. Use the dashboard to enable or disable a channel. Before using a live channel for a real customer, confirm the setup with a controlled test plan; live requests can initiate real M-Pesa transactions.
Fee balance for direct channels
Where your account uses a prepaid fee balance for direct-channel collections, the dashboard shows the current balance and available top-up action. Use the top-up instructions provided in your authenticated dashboard.
Payment links
A payment link is a hosted page at https://zetopay.co.ke/pay/<slug>. Share it on WhatsApp, SMS or social media. The payer enters their phone number, approves the M-Pesa prompt, and sees a confirmation. You need no website.
| Field | Description |
|---|---|
| title | Required, shown to the payer. |
| description | Optional, up to 300 characters. |
| amount | Fixed price in KES. Leave out to let the payer choose. A fixed link ignores any amount sent by the payer. |
| min_amount | Smallest amount when the payer chooses (default KES 10). |
| channel_id | zetupay or one of your channels. |
| success_url | Optional https page to send the payer to after paying. |
GET /links lists your links with paid_count and paid_total. POST /links/:id/active { "active": false } switches a link off.
Link payments appear in your transactions with link_id set, and trigger the same webhooks as API payments. Abuse-prevention limits may apply to repeated attempts. The payer only ever sees "waiting", "paid" or "failed". If a link's channel is disabled, or your fee balance is empty, the page shows "currently unavailable".
Paybill payments
Customers can also pay without a prompt: M-Pesa, Lipa na M-Pesa, Pay Bill, then your business number and your personal account number. Both are on your dashboard and in GET /me as paybill.shortcode and paybill.account.
- Where M-Pesa validation is switched on for our paybill, a wrong account number is declined while the customer pays. Otherwise the payment arrives and our team matches it.
- A good payment is credited to your wallet and appears in your transactions with
external_referenceC2B-<receipt>. You receivepayment.successlike any other payment. - If money arrives for an account number we cannot match, we keep it safe and our team resolves it.
Wallet and fees
| Field | Meaning |
|---|---|
| available | Ready to withdraw. |
| held | Payments under review. |
| service | Prepaid fee balance for direct channels. |
Returns the effective pooled-collection, direct-collection and withdrawal fee rules configured for your account. On the wallet channel the fee is taken from each payment before it reaches your wallet. On a direct channel you receive the full amount and the fee comes from your fee balance. The fee field on every payment shows exactly what was charged. Current rates are shown in your dashboard and may change; changes never affect a payment already made.
Withdrawals
Move your wallet balance to your M-Pesa phone. Needs: verified email, approved verification, a payout phone that we have verified, and an account older than 24 hours.
POST /api/v1/withdrawals
Authorization: Bearer zp_live_...
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{ "amount": 2500, "external_reference": "PAYOUT-2026-10" }
// 202 Accepted
{ "success": true, "data": { "reference": "clx5...", "status": "QUEUED", "external_reference": "PAYOUT-2026-10" } }
Your wallet is debited at once, then the payout runs in the background. GET /withdrawals/:reference returns the status, amount, fee and M-Pesa receipt, and GET /withdrawals lists recent ones. Withdrawals do not send webhooks yet: poll the status.
| Status | Meaning |
|---|---|
| PENDING_APPROVAL | Large amount waiting for staff approval. |
| QUEUED / PROCESSING | On its way. |
| SUCCESS | Sent. receipt is the M-Pesa code. |
| FAILED | The payout was refused. The full amount and fee return to your wallet automatically. |
| NEEDS_REVIEW | We could not confirm the result. We check it manually and never pay twice. You do not need to retry. |
Your applicable payout limits and fees are shown in the dashboard before you submit a withdrawal. High-value or unusual payouts may require additional review. The dashboard is the source of truth for the limits currently applied to your account.
Webhooks
We send an HTTPS POST to your webhook URL (set in Dashboard, Developers, or per payment with callback_url) when a payment changes state.
| Event | When |
|---|---|
| payment.success | The customer paid. Fulfil the order. |
| payment.failed | The prompt failed or was cancelled. |
{ "event": "payment.success",
"data": { "id": "clx9k2...", "kind": "PAYMENT", "settlement": "POOLED", "status": "SUCCESS", "amount": 500, "fee": 7.5,
"phone": "254712345678", "external_reference": "ORDER-1001", "link_id": null, "receipt": "SBX1A2B3C", "created_at": "2026-10-29T08:15:00.000Z" },
"created": "2026-10-29T08:15:21.000Z" }
Verify the signature
Every request carries X-ZetuPay-Timestamp (Unix seconds) and X-ZetuPay-Signature = sha256= followed by the hex HMAC-SHA256 of timestamp + "." + rawBody, keyed with your signing secret. Use the raw request body, not re-serialised JSON, and compare in constant time. Reject requests older than 5 minutes.
import crypto from 'crypto';
export function verify(rawBody, headers, secret) {
const ts = headers['x-zetupay-timestamp'], sig = headers['x-zetupay-signature'] || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: app.post('/zetupay-webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!verify(req.body.toString(), req.headers, SECRET)) return res.sendStatus(400); ... res.sendStatus(200); });
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_ZETUPAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_ZETUPAY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) { http_response_code(400); exit; }
$event = json_decode($raw, true); // handle $event['event'], then:
http_response_code(200);
import hmac, hashlib, time
def verify(raw: bytes, ts: str, sig: str, secret: str) -> bool:
if not ts or abs(time.time() - int(ts)) > 300:
return False
expected = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Delivery rules
- Reply with any
2xxwithin 10 seconds. Anything else counts as a failure. - We retry up to 6 times with growing delays (about 10 seconds, then longer). After that, fetch the payment with
GET /payments/:id. - Events can arrive twice or out of order. Make your handler idempotent: look the payment up by
idand ignore it if you have already handled that status. - The URL must be a public
httpsaddress. We do not follow redirects. - Do the slow work after replying
200.
Limits and rate limits
| Limit | Value |
|---|---|
| Single payment | Applicable amount limits depend on the enabled payment route and provider; confirm the limit shown for your account before submitting. |
| API and account endpoints | Requests may be rate limited; observe HTTP 429 responses and retry with backoff. |
| Public payment-link page | Requests may be rate limited to protect the service. |
| Abuse-prevention limits | Protective controls may apply to unusual or repeated activity; internal thresholds are not public. |
On 429, wait before retrying and use exponential backoff. Repeated failed prompts may trigger protective controls; do not repeatedly prompt the same customer. Account-specific limits and eligibility are shown in the dashboard.
If a request times out or returns a server error, check the existing transaction using its ID or external reference before making another payment request. Reuse the same external reference for a retry of the same order.
Testing
Only use a sandbox or test endpoint if it has been explicitly enabled for your account and is documented for your environment. Do not assume a live API request is a simulation: live collection requests may send a real M-Pesa prompt and move real money. Confirm the environment and transaction status before testing.
Go-live checklist
- ☐ Email verified and identity verification approved.
- ☐ API key stored securely and never exposed in browser-side code or public repositories.
- ☐ Webhook endpoint is public HTTPS, verifies the signature using the raw body, and is idempotent.
- ☐ Orders are fulfilled on
payment.success, never on the202. - ☐ Every payment carries a unique
external_reference. - ☐ You handle failed, pending and timed-out payments in your UI; verify the final status before fulfilling an order.
- ☐ Payout phone added and verified; test withdrawal done.
- ☐ One real test payment on each channel you use.
Support
Include your account ID and the payment id or external_reference when you contact us. Never send an API key, signing secret, password, M-Pesa PIN or one-time code. Visit Support for contact details.