Zetu Pay developer documentation

Supported scope. Zetu Pay supports enabled M-Pesa collections (STK Push and Paybill/Till flows) and eligible wallet withdrawals to M-Pesa. Card payments and B2B transfers are not supported. Available operations depend on your account and enabled channels.

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.

ChannelWhere the money goesHow you get it
Zetu Pay collectionA 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 channelWhere 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.
Payment confirmation. An accepted request is not proof of payment. Fulfil an order only after checking the final transaction status through the documented API or a verified webhook. If a status is pending or unclear, check the existing transaction before attempting another payment.

How a payment flows

  1. Your server calls POST /payments (or a customer opens a payment link).
  2. We send an M-Pesa prompt to the customer's phone. The response is 202: the payment is PENDING.
  3. The customer enters their PIN. M-Pesa tells us the result.
  4. 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

  1. Create an account and verify your email.
  2. In the dashboard, open Developers and create an API key. Copy it now: it is shown once.
  3. Set your webhook URL (a public HTTPS address) and copy your signing secret.
  4. 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"}'

The 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

Requests and responses

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).

HTTPMeaning
200 / 201Done.
202Accepted and being processed. Wait for the webhook or poll.
400Invalid input. Fix the request.
401Missing or invalid credentials.
402Not enough funds or fee balance.
403Not allowed yet (for example verification is required).
404 / 409Not found / wrong state for this action.
429Too many requests. Slow down and retry.
5xxOur side or M-Pesa. Safe to retry with the same reference.

Idempotency

Errors

CodeHTTPWhat to do
VALIDATION400A field is missing or wrong. The message names it.
INVALID_PHONE400Use a Kenyan Safaricom number.
INVALID_URL400Webhook and redirect URLs must be public https addresses.
UNAUTHORIZED401Missing, wrong or revoked key.
FORBIDDEN403Account suspended, or this action needs a login session.
EMAIL_NOT_VERIFIED403Verify your email.
KYC_REQUIRED403Complete the verification steps shown in your dashboard.
PROVIDER_ERROR502The provider definitively rejected the prompt. Check the transaction, then use a new unique reference for a new attempt.
REFERENCE_REUSED409The same external reference was sent with different payment details. Use a new reference for a genuinely new payment.
SERVICE_BALANCE_LOW402Top up your fee balance to collect on your own channel.
CHANNEL_NOT_FOUND404The channel does not exist or is not active.
IDEMPOTENCY_KEY_REQUIRED400Send an Idempotency-Key header.
INVALID_AMOUNT400Outside the allowed range.
IDEMPOTENCY_KEY_REUSED409A withdrawal key was reused with different details. Use a new key for a new withdrawal.
INSUFFICIENT_FUNDS402Wallet balance is below amount plus fee.
PAYOUT_PHONE_NOT_VERIFIED403Add a payout phone and wait for us to verify it.
TOO_MANY_ATTEMPTS / TOO_SOON429Wait a few minutes.
NOT_FOUND404No 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.

EndpointPurpose
POST /auth/signupaccount_type (PERSONAL | BUSINESS), name, email, phone, password (10+ chars), accept_terms: true. Sends the verification code and returns a session token.
POST /auth/loginemail, password.
POST /me/email/verify · /me/email/resendConfirm 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/resetEmails a one-time reset link valid for 30 minutes. The reply is the same whether or not the email has an account.
GET /meYour 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)

POST/payments
FieldTypeDescription
amountnumber, requiredWhole KES, from 1 to 250,000.
phonestring, requiredThe customer's M-Pesa number.
external_referencestring, ≤64Your order or invoice number. Unique per account. Makes the call safe to retry.
channel_idstringzetupay (default) or a channel ID from GET /channels.
callback_urlhttps URLSend 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" } }
GET/payments/:id

:id is the payment id or your external_reference.

GET/transactions?page=1&per_page=20

Newest first, up to 100 per page. Includes payments, payment-link payments, paybill payments and fee top-ups (kind: "TOPUP").

Statuses

StatusMeaning
PENDINGPrompt sent. Waiting for the customer.
SUCCESSPaid. receipt holds the M-Pesa code. Safe to fulfil.
FAILEDCancelled, 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.

GET/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.

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.

POST/links
FieldDescription
titleRequired, shown to the payer.
descriptionOptional, up to 300 characters.
amountFixed price in KES. Leave out to let the payer choose. A fixed link ignores any amount sent by the payer.
min_amountSmallest amount when the payer chooses (default KES 10).
channel_idzetupay or one of your channels.
success_urlOptional 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.

Wallet and fees

GET/wallet
FieldMeaning
availableReady to withdraw.
heldPayments under review.
servicePrepaid fee balance for direct channels.
GET/me/fees

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/withdrawals
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.

StatusMeaning
PENDING_APPROVALLarge amount waiting for staff approval.
QUEUED / PROCESSINGOn its way.
SUCCESSSent. receipt is the M-Pesa code.
FAILEDThe payout was refused. The full amount and fee return to your wallet automatically.
NEEDS_REVIEWWe 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.

EventWhen
payment.successThe customer paid. Fulfil the order.
payment.failedThe 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); });

Delivery rules

Limits and rate limits

LimitValue
Single paymentApplicable amount limits depend on the enabled payment route and provider; confirm the limit shown for your account before submitting.
API and account endpointsRequests may be rate limited; observe HTTP 429 responses and retry with backoff.
Public payment-link pageRequests may be rate limited to protect the service.
Abuse-prevention limitsProtective 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

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.