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.

ChannelWhere the money goesHow 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 youThe 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.
Holds and reversals. Zetu Pay can hold or reverse a payment that looks suspicious, but only on the wallet channel, because that is the only place we hold the money. A held payment shows as UNDER_REVIEW.

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.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"}'

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 identity verification.
PROVIDER_ERROR502We could not start the M-Pesa prompt. Retry shortly with the same reference.
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.
INSUFFICIENT_FUNDS402Wallet balance is below amount plus fee.
PAYOUT_PHONE_NOT_VERIFIED403Add a payout phone and wait for us to verify it.
NEW_MERCHANT_HOLD403New accounts can withdraw after the first 24 hours.
DAILY_LIMIT403The 24-hour withdrawal limit is reached.
ACCOUNT_DEBT403An amount is owed to Zetu Pay. Withdrawals pause until it is repaid from new payments.
TOO_MANY_ATTEMPTS / TOO_SOON429Wait a few minutes.
NOT_FOUND404No such resource on your account.
DIRECT_SETTLEMENT409(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.

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, 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

  1. Verify your email. Required before you can submit.
  2. 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.
  3. 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.
  4. Review and submit. A person on our team compares your details with your documents.
  5. 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_statusMeaning
NOT_STARTEDNothing submitted.
SUBMITTEDWaiting for review. Details are locked while we review.
APPROVEDVerified.
REJECTEDNot approved. See rejection_note and resubmit.

How we protect your data

Endpoints

EndpointPurpose
GET /kycStatus, 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/submitSends 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)

POST/payments
FieldTypeDescription
amountnumber, requiredKES, 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.
UNDER_REVIEWPaid, but the funds are held while we check it (wallet channel only). Do not release goods yet. We will contact you.
REVERSEDAfter 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.

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

OptionWhat you give usNotes
Paybill or till, number onlykind PAYBILL or TILL, shortcode, optional account_number for a shared or SACCO paybillNo Daraja keys needed. Our team reviews it first (PENDING), then it goes live.
Paybill or till, your own keysmode: "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 accountkind: "BANK", bank_id (from GET /banks), account_numberCustomers 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.

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.

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

Wallet and fees

GET/wallet
FieldMeaning
availableReady to withdraw.
heldPayments under review.
owed_to_platformAn amount you owe after a reversal. New payments repay it first, and withdrawals pause until it is zero.
servicePrepaid 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/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.

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.

EventWhen
payment.successThe customer paid. Fulfil the order.
payment.failedThe prompt failed or was cancelled.
payment.under_reviewPaid, but held for a check (wallet channel).
payment.reversedA 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); });

Delivery rules

Limits and rate limits

LimitValue
Single paymentKES 1 to 250,000 (M-Pesa limit)
API requests120 per minute per IP address
Sign-up and login10 per minute per IP address
Email code, reset, password change10 per 15 minutes
Public payment-link page20 per minute per IP address
Channels per account5
KYC file2 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

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.