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.
Two ways your money can settle
Every payment goes through a channel. You choose the channel for each payment, link or sale.
| Channel | Where the money goes | How you get it |
|---|---|---|
| Zetu Pay wallet (default) | Zetu Pay receives the payment and credits your wallet, minus the fee. | You withdraw the balance to M-Pesa. |
| Straight to you | The customer pays your own paybill, till or bank account. Zetu Pay never holds the money. | Immediately, in your own account. Our fee comes from a prepaid fee balance. |
UNDER_REVIEW.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.zetupay.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.zetupay.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.zetupay.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.zetupay.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.zetupay.co.ke/api/v1 - Format: JSON in, JSON out, UTF-8, HTTPS only.
- Money: amounts are in Kenyan shillings as numbers, for example
1500or1500.50. M-Pesa collects whole shillings. - 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. If you send the same one again, you get the original payment back and the customer is not charged twice. - Withdrawals: an
Idempotency-Keyheader is required. Retrying with the same key returns the same withdrawal.
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 identity verification. |
| PROVIDER_ERROR | 502 | We could not start the M-Pesa prompt. Retry shortly with the same reference. |
| 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. |
| 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. |
| NEW_MERCHANT_HOLD | 403 | New accounts can withdraw after the first 24 hours. |
| DAILY_LIMIT | 403 | The 24-hour withdrawal limit is reached. |
| ACCOUNT_DEBT | 403 | An amount is owed to Zetu Pay. Withdrawals pause until it is repaid from new payments. |
| TOO_MANY_ATTEMPTS / TOO_SOON | 429 | Wait a few minutes. |
| NOT_FOUND | 404 | No such resource on your account. |
| DIRECT_SETTLEMENT | 409 | (Staff action.) Payments settled straight to you cannot be held or reversed. |
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, email_verified and kyc_status. |
Identity verification (KYC)
KYC means "know your customer". Payment providers must know who they pay, so you verify once. You can collect payments and create payment links before you are verified. Verification unlocks withdrawals and connecting your own paybill, till or bank account.
How it works
- Verify your email. Required before you can submit.
- Enter your details. Personal account: full legal name, ID type (National ID or passport), ID number, date of birth (18+), KRA PIN, county, town and address. Business account: the same for the owner or director, plus business name, type, registration number, business KRA PIN, expected monthly volume and optionally a website.
- Upload documents (each under 2 MB): ID front, ID back (National ID only), a selfie, and for businesses the registration certificate and KRA PIN certificate. ID photos and the selfie must be JPG or PNG; certificates may also be PDF. Photos are shrunk in your browser before upload.
- Review and submit. A person on our team compares your details with your documents.
- Decision. You get an email and an in-app notification. Approved: withdrawals and own channels unlock. Not approved: the reason is explained, your details stay saved, and you fix and resubmit.
kyc_status | Meaning |
|---|---|
| NOT_STARTED | Nothing submitted. |
| SUBMITTED | Waiting for review. Details are locked while we review. |
| APPROVED | Verified. |
| REJECTED | Not approved. See rejection_note and resubmit. |
How we protect your data
- Your details and documents are encrypted before they are stored.
- Uploads are checked by their real file type, not only the file name.
- Only authorised staff can open documents, and every view is logged.
- We use the data to verify you and meet our payment obligations, and we do not share it with other merchants.
Endpoints
| Endpoint | Purpose |
|---|---|
GET /kyc | Status, saved details, which documents are uploaded and which are required. |
PUT /kyc/details | { "type": "PERSONAL", "details": { ... } }. You can save a few fields at a time. |
POST /kyc/documents | { "kind": "ID_FRONT", "data_base64": "..." }. Kinds: ID_FRONT, ID_BACK, SELFIE, KRA_CERT, BUSINESS_CERT. |
POST /kyc/submit | Sends everything for review. Fails with DOCUMENTS_MISSING if something is absent. |
Notifications and email
Zetu Pay tells you what matters in two places: the bell in your dashboard and email. We send email from no-reply@zetupay.co.ke.
| 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 no-reply@zetupay.co.ke to your contacts if our email lands in spam.
Payments (M-Pesa prompt)
| Field | Type | Description |
|---|---|---|
| amount | number, required | 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. |
| UNDER_REVIEW | Paid, but the funds are held while we check it (wallet channel only). Do not release goods yet. We will contact you. |
| REVERSED | After a review, the payment was reversed. The money is not payable to you. |
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": "PLATFORM", "kind": "TILL",
"shortcode": "5551234", "account_number": null, "status": "PENDING", "verified": false } ]
Connecting your own account
Needs an approved verification and a login session. Do it in Dashboard, Channels, or with POST /channels.
| Option | What you give us | Notes |
|---|---|---|
| Paybill or till, number only | kind PAYBILL or TILL, shortcode, optional account_number for a shared or SACCO paybill | No Daraja keys needed. Our team reviews it first (PENDING), then it goes live. |
| Paybill or till, your own keys | mode: "OWN_DARAJA", passkey, consumer_key, consumer_secret (and till_number if it differs) | Keys are encrypted and never shown again. We check the key and secret with Safaricom. A small test payment proves the rest. |
| Bank account | kind: "BANK", bank_id (from GET /banks), account_number | Customers pay the bank's M-Pesa paybill and the money lands in your bank account. Account numbers up to 12 characters are supported. |
You can have up to 5 channels. POST /channels/:id/disable switches one off. Always send a small test payment after a channel goes live.
Fee balance for direct channels
Payments settled straight to you cost a small fee, taken from a prepaid fee balance (wallet.service). If it is too low the payment is refused with SERVICE_BALANCE_LOW. Top up with POST /me/service-wallet/topup { "amount": 500, "phone": "0712345678" } (KES 50 to 50,000): you approve an M-Pesa prompt and the balance is credited.
Payment links
A payment link is a hosted page at https://zetupay.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. A payer can try at most 3 times per phone in 10 minutes. 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. |
| owed_to_platform | An amount you owe after a reversal. New payments repay it first, and withdrawals pause until it is zero. |
| service | Prepaid fee balance for direct channels. |
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. |
Default limits (shown in your dashboard): minimum KES 100, maximum KES 150,000 each, KES 500,000 per rolling 24 hours, flat fee KES 25, and staff approval from KES 100,000.
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. |
| payment.under_review | Paid, but held for a check (wallet channel). |
| payment.reversed | A held payment was reversed after review. |
{ "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 | KES 1 to 250,000 (M-Pesa limit) |
| API requests | 120 per minute per IP address |
| Sign-up and login | 10 per minute per IP address |
| Email code, reset, password change | 10 per 15 minutes |
| Public payment-link page | 20 per minute per IP address |
| Channels per account | 5 |
| KYC file | 2 MB each (JPG, PNG; PDF for certificates) |
On 429, wait and retry with backoff.
Testing
Test with small real amounts (KES 10) on your own phone. Where a test environment is enabled for your account, payments are not sent to M-Pesa and you finish one yourself with POST /sandbox/complete { "payment_id": "...", "success": true }. That endpoint does not exist on the live service.
Go-live checklist
- ☐ Email verified and identity verification approved.
- ☐ API key stored in a secret manager, not in code.
- ☐ 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,UNDER_REVIEWand timeouts in your UI. - ☐ 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 or password: we will never ask for them.