Skip to content
Quickwave Technologies

Payments

M-Pesa STK Push Integration with ZetuPay (2026 Guide)

Add M-Pesa STK Push with one ZetuPay API call: cURL and Node.js code, signed webhooks, result codes, KES 10 testing, pricing, and how it compares with Daraja.

By Quickwave engineering teamUpdated 9 min read

To add M-Pesa STK Push to a Kenyan website or app, we recommend ZetuPay: one REST call from your server sends the payment prompt, and the money settles straight to your own paybill, till or bank account. You never register a Daraja app, handle a passkey or certificates, or host Safaricom’s callback. We built and run ZetuPay; this guide shows how we wire it into a checkout, with the direct Daraja route at the end.

Ready to start? Request an account at zetupay.co.ke, or WhatsApp us on 0798 871 229 and we will help you set it up.

What is M-Pesa STK Push?

M-Pesa STK Push is a Safaricom API that sends a payment prompt to a customer’s phone in Kenya: they see the amount and your account reference, enter their M-Pesa PIN, and your server is told the result. Safaricom’s official name for it is M-Pesa Express (also called Lipa na M-Pesa Online), and STK stands for SIM Toolkit.

A successful request only means the prompt went out; money has moved only when M-Pesa returns result code 0.

How do you integrate M-Pesa STK Push with ZetuPay?

These steps follow ZetuPay’s documentation:

  1. Open and verify your account. Send the Get Started form on zetupay.co.ke (each request is reviewed, and there is no setup fee), verify your business with the owner’s ID or passport, link your own paybill, till or bank account to a wallet, and add service tokens or a plan. API calls only work after verification.
  2. Copy your Secret Key (sk_live_...) from the Developers page into your server’s environment variables. Never put it in a browser page or mobile app.
  3. Add your HTTPS callback URL under Transaction Callback Endpoints on the same page.
  4. Send the prompt from your server to POST https://pay.zetupay.co.ke/api/v1/payment/stk-push with an Idempotency-Key per order.
  5. Save the paymentKey from the 202 response and show “Check your phone”. Status processing means the prompt is on the phone, not that it is paid.
  6. Verify the webhook and fulfil the order only when status is success and the amount and reference match.
  7. Poll the status endpoint to catch failures, because webhooks are only sent for successful payments.

Send the STK Push with cURL

curl -X POST https://pay.zetupay.co.ke/api/v1/payment/stk-push \
  -H "Authorization: Bearer $ZETUPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9874" \
  -d '{ "amount": 1500, "phoneNumber": "0712345678", "reference": "ORDER-9874" }'

The same call in Node.js

// Run on your server, never in the browser or a mobile app
const res = await fetch('https://pay.zetupay.co.ke/api/v1/payment/stk-push', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.ZETUPAY_SECRET_KEY,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-' + order.id, // same key on every retry
  },
  body: JSON.stringify({
    amount: 1500,
    phoneNumber: '0712345678',
    reference: 'ORDER-9874',
  }),
});
const { success, data, message } = await res.json();
if (!success) throw new Error(message);
// Save data.paymentKey with the order, then wait for the webhook.

amount is whole shillings from KES 1 to 250,000, and phoneNumber takes 07…, 01…, 254… or +254… numbers. Your reference (up to 100 characters) and an optional identifier come back in the webhook. The Idempotency-Key (up to 255 characters, kept for 24 hours) makes a retry return the original payment instead of sending a second prompt; use a new key, such as order-9874-2, when the customer asks for a fresh prompt.

The endpoint refuses a secret key sent from a browser (403) and limits prompts per phone number (429), so a leaked key cannot spam customers. A 402 means the wallet has no tokens or plan; a 502 means M-Pesa did not accept the prompt, so retry with the same key.

Verify the signed webhook

Each webhook carries an x-zetupay-signature header in the form t=1783869123,v1=5257a8…. v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your wallet’s live Secret Key, even when you send requests with a test key. Check it against the raw body before parsing JSON, compare in constant time, and reject timestamps older than 5 minutes:

const crypto = require('crypto');

app.post('/webhooks/zetupay', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('x-zetupay-signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', process.env.ZETUPAY_LIVE_SECRET_KEY) // the sk_live_ key
    .update(parts.t + '.' + req.body)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 minutes
  const valid = fresh && parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(401).send('Invalid signature');

  const txn = JSON.parse(req.body);
  res.status(200).send('OK'); // acknowledge fast
  if (txn.event) return;      // subscription events share this URL
  if (txn.status === 'success') {
    // checks amount and reference against the order, once per waveTransactionId
    markOrderPaid(txn.reference, txn.amount, txn.receiptNumber, txn.waveTransactionId);
  }
});

Anything other than a 2xx reply is retried, up to 6 attempts in total, so the same event can arrive twice: store waveTransactionId as a unique key.

How do you check an STK Push payment and read the result codes?

Cancelled, failed and ignored prompts send no webhook, so poll from your server every 5 seconds for up to 2 minutes, as ZetuPay’s docs suggest:

curl https://pay.zetupay.co.ke/api/v1/payment/stk-push/PAYMENT_KEY \
  -H "Authorization: Bearer $ZETUPAY_SECRET_KEY"

The response’s status is pending, processing, success, failed, cancelled or expired, with M-Pesa’s resultCode and resultDesc and, once paid, the receiptNumber. These codes from Safaricom’s M-Pesa Express result code table matter most. Only 0 is a success.

CodeSafaricom’s descriptionWhat happenedWhat to do
0The service request is processed successfully.The customer paid.Mark the order paid once and store the M-Pesa receipt number.
1The balance is insufficient for the transaction.Not enough money in the customer’s M-Pesa.Say so plainly and offer a retry.
1032Request cancelled by user.The customer cancelled the prompt.Let them send a fresh prompt.
1037DS timeout user cannot be reached.The phone was offline, busy or in another session.Ask them to check their phone and retry, or show your paybill or till details.
2001The initiator information is invalid.The customer entered a wrong M-Pesa PIN.Ask them to retry with the correct PIN.

ZetuPay also runs an M-Pesa reconciliation job every minute; on Daraja directly, that job is yours to build.

How do you test ZetuPay STK Push without a sandbox?

ZetuPay has no sandbox. Test keys (sk_test_...) and live keys both send real prompts and move real money; a test key only labels the payment "environment": "test". Test with small amounts:

  1. Pay KES 10 from your own phone and confirm the webhook passes your signature check.
  2. Cancel a prompt: no webhook arrives, the status becomes failed, and the order stays unpaid with a way to retry.
  3. Send one request twice with the same Idempotency-Key and confirm only one prompt arrives.

There is no separate go-live request: once your business is verified and these checks pass, take real orders.

Can you take M-Pesa on WooCommerce without writing code?

Yes. The free ZetuPay WooCommerce plugin adds M-Pesa to your store through ZetuPay’s hosted checkout:

  1. Download the plugin zip, upload it under Plugins > Add New Plugin > Upload Plugin, and activate it.
  2. In WooCommerce > Settings > Payments > ZetuPay, paste your public key, secret key and wallet ID. Always fill in the Live Secret Key, even in Test mode, because webhooks are checked against it.
  3. Copy the plugin’s callback URL into Transaction Callback Endpoints on the ZetuPay Developers page, and let POST requests to it through Wordfence, Cloudflare or your firewall.
  4. Place a KES 10 order and watch it move to Processing.

It works with classic and block checkout and WooCommerce HPOS, checks the amount against the order, and locks each order so it is only marked paid once.

What does ZetuPay cost, and can it send B2C payouts?

ZetuPay charges no percentage of your sales. You pay a flat fee per successful payment with prepaid service tokens, or a monthly, quarterly or annual plan with unlimited payments. Failed payments cost nothing and there is no setup fee; current prices are on ZetuPay’s pricing page. Because payments settle straight to your own paybill, till or bank, ZetuPay never holds your sales.

For money going out, top up a B2C payout balance with an M-Pesa prompt, then pay customers, staff or suppliers by dashboard or with POST /api/v1/payout/b2c (minimum KES 10). The fee is banded from KES 3 and added on top, so the recipient gets the exact amount: a KES 5,000 payout costs KES 40. Optional SMS receipts are KES 1 each.

ZetuPay vs a direct Daraja integration: which should you choose?

ComparedZetuPayDirect Daraja integration
To get startedZetuPay account and business verificationDaraja app, then go-live by an M-PESA Org portal Business Administrator or Manager
Credentials to manageA Secret Key per wallet (rotatable test and live keys)Consumer key and secret, passkey, and an OAuth token that expires every hour
Where the money landsYour own paybill, till or bank accountYour own paybill or till
Payment confirmationSigned HMAC-SHA256 webhook for successful payments (6 delivery attempts); poll for failuresUnsigned callback you host on HTTPS
Duplicate promptsBuilt-in Idempotency-Key (24 hours)You build the check
Status and reconciliationStatus endpoint, plus reconciliation every minuteQuery API; you build reconciliation
TestingNo sandbox: small real paymentsSandbox, then a go-live request
PriceFlat fee per successful payment or a plan; no percentage, no setup feeNo gateway fee; you pay for development and hosting
Also on the same accountHosted checkout, cards into your own Stripe account, B2C payouts, payment links, WooCommerce pluginEach extra API is a separate integration

As of October 2026, from ZetuPay’s docs and pricing page and Safaricom’s Daraja documentation. Whenever money lands in your own paybill or till, Safaricom’s tariff applies too: from 7 August 2026, Buy Goods collections up to KES 500 are free to the merchant and larger ones cost 0.55%, capped at KES 200, The Standard reported on 2 August 2026.

Choose ZetuPay to go live without the Daraja app and go-live process, keep your current paybill or till, and pay a flat fee instead of maintaining OAuth tokens, passkeys and Safaricom’s callback. Go direct to Daraja if a developer will own the integration long term. We use ZetuPay ourselves: Wave POS sends M-Pesa prompts at the till through it.

What if you integrate Daraja directly?

A direct integration gives you full control and no gateway fee, but you own every moving part. In short, from Safaricom’s Daraja documentation:

  1. Create a Daraja app with M-Pesa Express on the developer portal. The sandbox shortcode is 174379.
  2. Get an OAuth token with HTTP Basic auth. Tokens last 3,600 seconds and each new request invalidates the previous one (Authorization FAQ), so cache one centrally.
  3. Build the password as base64 of shortcode + passkey + timestamp (YYYYMMDDHHmmss), and send the same timestamp in Timestamp; a mismatch is one cause of error 500.001.1001.
  4. POST to /mpesa/stkpush/v1/processrequest with CustomerPayBillOnline (or CustomerBuyGoodsOnline with the till as PartyB), an AccountReference of up to 12 characters and your CallBackURL.
  5. Host the callback on public HTTPS. Safaricom’s callback URL rules (written for C2B) say testers such as ngrok are usually blocked and to avoid “M-Pesa”, “Safaricom” or “query” in the URL. The callback is unsigned, so add a secret token to the URL and accept only a CheckoutRequestID you sent.
  6. Reconcile late callbacks with the M-Pesa Express Query API.
  7. Go live from the portal’s Go Live tab, which needs an M-PESA Org portal Business Administrator or Business Manager. Safaricom then issues production keys and emails the passkey.

Daraja also sets a KES 1 minimum, KES 250,000 per transaction and KES 500,000 per customer per day.

How do you get started with ZetuPay?

Request an account on zetupay.co.ke, follow the docs, and make your first KES 10 payment. If you would like help, WhatsApp us on 0798 871 229: our M-Pesa integration team can connect ZetuPay to your website, app or WooCommerce store. Building something new? See our web development service.

FAQ

Frequently asked questions

What is ZetuPay?

ZetuPay is a Kenyan payment gateway, built by Quickwave, for M-Pesa STK Push, card payments and B2C payouts. It sends prompts through its own Daraja integration, settles to your own paybill, till or bank account, and charges a flat fee per payment or a plan instead of a percentage.

Should I use ZetuPay or integrate Daraja directly?

Use ZetuPay unless a developer will own a Daraja integration long term. ZetuPay replaces the Daraja app, passkey, token refresh and Safaricom’s callback with one API call and a signed webhook, and the money still settles to your own account.

Do I need a Daraja account to use ZetuPay?

No. ZetuPay sends prompts through its own Daraja integration, and you link the paybill, till or bank account you already have, so payments still settle to you.

Can I use a till number for STK Push?

Yes. With ZetuPay you link your Buy Goods till to your wallet and customers pay into it. On Daraja directly, set TransactionType to CustomerBuyGoodsOnline, PartyB to the till number and BusinessShortCode to the store or head-office number used at go-live.

Does ZetuPay have a sandbox for STK Push?

No. Test and live keys both send real prompts and move real money, so test with a small amount such as KES 10 from your own phone.

What does STK mean in M-Pesa STK Push?

SIM Toolkit. STK Push is the common name for the payment prompt M-Pesa pushes to the customer’s phone; the API’s official name is M-Pesa Express, also called Lipa na M-Pesa Online.

Keep exploring

Get in touch

Tell us what you are building.

Message us on WhatsApp with a few lines about your business and what you need. We will come back to you with next steps, a timeline and a clear quote.

WhatsApp 0798 871 229 (opens WhatsApp in a new tab)