The M-Pesa Daraja API is how your code asks Safaricom to charge a customer's phone. You send an STK push, the customer's handset prompts for their M-Pesa PIN, and a callback tells you whether the money moved. This guide wires that flow end to end (OAuth token, STK push, callback validation, and reconciliation) using a real worked example: topping up a Sautikit prepaid wallet.
TL;DR
Daraja STK push (Lipa Na M-Pesa Online) is a two-step, asynchronous flow: POST the push, then wait for a callback; never trust the synchronous response as proof of payment.
Every request needs a short-lived OAuth bearer token (/oauth/v1/generate), and the STK push needs a base64 password = Base64(Shortcode + Passkey + Timestamp).
Validate the callback's ResultCode (0 = success), dedupe on CheckoutRequestID, and only credit the customer after the callback confirms; this is where most integrations leak money.
The same pattern funds a Sautikit wallet over M-Pesa: STK push in, callback confirms, wallet credited in KES. No card, no FX.
Daraja is Safaricom's developer platform for M-Pesa. The endpoint you almost always want is Lipa Na M-Pesa Online, better known as STK push: your server initiates a charge, and the customer's phone pops a PIN prompt (the "SIM Toolkit" overlay, hence STK). The customer approves, and Safaricom debits their M-Pesa balance and credits your Paybill or Till.
There are four moving pieces:
Credentials: a Consumer Key + Consumer Secret (from the Daraja portal) that you exchange for a bearer token.
The STK push request: authenticated with that token, signed with a base64 password.
The synchronous acknowledgement: Safaricom accepts the request and returns a CheckoutRequestID. This is not confirmation of payment.
The asynchronous callback: Safaricom POSTs the final result to your CallBackURL seconds later. This is the source of truth.
The single most common Daraja bug is treating step 3 as if it were step 4. A 200 on the push means "I have queued the prompt", not "the customer paid".
Every Daraja call is bearer-authenticated. Exchange your key and secret for a token via HTTP Basic auth. Tokens are short-lived (about an hour), so fetch one per batch of work rather than per request, and refresh on 401.
const BASE = "https://sandbox.safaricom.co.ke"; // api.safaricom.co.ke in prodasync function getAccessToken() {const auth = Buffer.from( `${process.env.DARAJA_CONSUMER_KEY}:${process.env.DARAJA_CONSUMER_SECRET}`).toString("base64");const res = await fetch(`${BASE}/oauth/v1/generate?grant_type=client_credentials`, { headers: { Authorization: `Basic ${auth}` },});if (!res.ok) throw new Error(`OAuth failed: ${res.status}`);const { access_token } = await res.json();return access_token; // valid ~3600s}
The STK push body needs a Password and a matching Timestamp. The password is Base64(Shortcode + Passkey + Timestamp), where the timestamp is YYYYMMDDHHmmss in East Africa Time. The timestamp in the password and the top-level Timestamp field must be identical, or Safaricom rejects the request.
Seconds later, Safaricom POSTs the outcome to your CallBackURL. This is the payload you trust. Success is ResultCode: 0; anything else (customer cancelled, timeout, insufficient balance) is a non-zero code with a ResultDesc you should log.
import express from "express";const app = express();app.use(express.json());app.post("/daraja/callback", async (req, res) => {// ACK immediately; Safaricom retries if you are slow.res.json({ ResultCode: 0, ResultDesc: "Accepted" });const cb = req.body?.Body?.stkCallback;if (!cb) return;const { CheckoutRequestID, ResultCode, ResultDesc } = cb;if (ResultCode !== 0) { // 1032 = cancelled by user, 1037 = timeout, 1 = insufficient balance await markPaymentFailed(CheckoutRequestID, ResultCode, ResultDesc); return;}// Success: pull the confirmed amount and receipt out of the metadata array.const items = cb.CallbackMetadata?.Item ?? [];const get = (name) => items.find((i) => i.Name === name)?.Value;const amount = get("Amount"); // confirmed KESconst receipt = get("MpesaReceiptNumber"); // e.g. "NLJ7RT61SV"const phone = get("PhoneNumber");// Idempotent: dedupe on CheckoutRequestID before crediting anything.await creditOnce({ CheckoutRequestID, amount, receipt, phone });});app.listen(3000);
Idempotency. Safaricom can deliver the same callback more than once, and your own retries can replay it. Store CheckoutRequestID (and the MpesaReceiptNumber) with a unique constraint, and make crediting a no-op if you have seen it before. Never balance += amount without that guard.
Timeout handling. If no callback arrives, the payment is unknown, not failed. Use the STK Push Query endpoint (/mpesa/stkpushquery/v1/query) with the CheckoutRequestID to ask Safaricom for the final status before you decide. Do not silently cancel a charge that may have succeeded.
Treat the M-Pesa receipt number as the external transaction ID in your books. It is what the customer sees on their handset and what a support agent will quote back to you.
Everything above is exactly how you'd let a customer top up a balance in your own product, and it's how Sautikit funds its prepaid wallet. Sautikit is a programmable voice API billed in KES: you claim a number for KES 100/month (ex. VAT; KES 116 incl. VAT), and outbound calls cost KES 0.05/sec (KES 3.00/min), billed per second from the moment the call connects. Inbound is free. There's no card and no USD invoice; you top the wallet up over M-Pesa with the same STK push flow above.
From your side it's one request; Sautikit runs the Daraja handshake, listens for the callback, and credits the wallet in KES:
// Trigger an M-Pesa STK push that funds your Sautikit wallet.const res = await fetch("https://api.sautikit.com/v1/topups", {method: "POST",headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`, "Content-Type": "application/json",},body: JSON.stringify({ provider: "mpesa", amount: 1000, // KES phone: "+254700000000", // the handset to prompt}),});const topup = await res.json();// { id, status: "pending", ... }; the phone now shows the M-Pesa PIN prompt.// A wallet.top_up webhook fires once the Daraja callback confirms.
The value of letting the platform own the Daraja handshake is that the idempotency, callback validation, and reconciliation from steps 3–4 are already handled: the money only lands in your wallet once the callback confirms, keyed so a replayed callback never double-credits. You get a wallet.top_up webhook when it clears, and the balance is in shillings, ready to place calls.
Is the STK push response proof that the customer paid?
No. A ResponseCode of 0 on the push means the PIN prompt was queued to the customer's phone. Payment is confirmed only by the asynchronous callback to your CallBackURL (or by querying STK Push Query). Build your logic around the callback.
What are the common ResultCode values?
0 is success. 1032 means the customer cancelled the prompt, 1037 is a timeout (no PIN entered), and 1 is insufficient M-Pesa balance. Log ResultDesc for anything non-zero.
How do I avoid double-crediting a payment?
Dedupe on CheckoutRequestID and MpesaReceiptNumber with a unique constraint, and make the credit operation a no-op if you've already recorded that ID. Safaricom can redeliver callbacks, and your own retries can replay them.
Why is my STK push rejected with an invalid-password error?
The Password is Base64(Shortcode + Passkey + Timestamp) and the Timestamp in the password must exactly match the top-level Timestamp, both in East Africa Time (YYYYMMDDHHmmss). A mismatch (often from a UTC vs EAT bug) is the usual cause.
Can I use Daraja to charge cents?
STK push Amount is a whole number of KES. Handle sub-shilling logic in your own ledger before initiating the push.
Do I have to build all of this to accept M-Pesa in a voice product?
No. If your product is voice, Sautikit runs the Daraja flow for wallet top-ups so you don't maintain the OAuth, password, callback, and reconciliation code yourself: you call POST /v1/topups and get a wallet.top_up webhook when the money clears.
Building a full customer-experience flow around M-Pesa (payment reminders over SMS and WhatsApp, an agent desk, USSD menus) alongside voice? Helloduty is the multi-channel platform Sautikit is part of, so voice stays focused while the rest of the stack lives one step away.