If you sell online in Kenya, M-Pesa isn't a payment option — it's the payment option. For the Vedrastores marketplace I integrated Safaricom's Daraja API so buyers pay with the familiar “Enter M-Pesa PIN” prompt on their phone. The API itself is small. What makes an integration trustworthy is everything around it.
How STK Push works, in one paragraph
Your backend gets an OAuth token from Daraja, then sends an STK Push request with your shortcode, a password derived from your passkey, a timestamp, the amount, the customer's phone number and a callback URL. Safaricom pops a PIN prompt on the customer's phone. When they confirm, cancel or ignore it, Safaricom posts the result to your callback URL. Your app is told “request accepted” immediately — the actual outcome arrives later.
const timestamp = format(new Date(), "yyyyMMddHHmmss"); // e.g. 20261018143005
const password = Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");Rule 1: credentials never touch the client
The consumer key, consumer secret and passkey live only on the server — in my case as encrypted secrets read by cloud functions. The browser and the mobile app call your endpoint, never Safaricom's. Anything shipped in a JavaScript bundle or an app binary should be treated as public.
Rule 2: the callback is the source of truth
The customer's phone is the least reliable participant in a payment — it's the thing that loses signal. So an order is marked paid only when Safaricom's callback says so, and only after checking that the amount and the CheckoutRequestID match the payment you initiated. Store the M-Pesa receipt number against the order; support will thank you.
Rule 3: the customer still needs feedback
While you wait for the callback, the checkout screen polls your status endpoint every few seconds. That endpoint answers from your database first, and only if the payment is still pending does it ask Daraja's STK Push Query API. That way a delayed callback doesn't leave someone staring at a spinner after they've paid.
Rule 4: normalise phone numbers before you send them
Customers type 0712…, +254712…, 254 712… and 712…. Daraja wants 2547XXXXXXXX or 2541XXXXXXXX. Normalise once, validate, and show a friendly error before you ever call the API.
export function toMsisdn(input: string): string | null {
const digits = input.replace(/\D/g, "");
const local = digits.startsWith("254") ? digits.slice(3)
: digits.startsWith("0") ? digits.slice(1)
: digits;
const msisdn = `254${local}`;
return /^254(7|1)\d{8}$/.test(msisdn) ? msisdn : null;
}Rule 5: design for the unhappy paths
- Cancelled — the customer dismissed the prompt (
ResultCode1032). Let them retry with one tap. - Timed out — the phone couldn't be reached (1037). Suggest checking signal and trying again.
- Insufficient balance (1). Say so plainly; don't show a generic “payment failed”.
- No callback at all — fall back to the status query, and expire the attempt after a sensible window.
Rule 6: callbacks can arrive more than once
Make callback handling idempotent, keyed on CheckoutRequestID. If the payment is already recorded, acknowledge and do nothing. Never let a duplicate callback create a second order, credit a vendor twice or send two confirmation messages.
Sandbox is not production
- Sandbox uses Safaricom's test shortcode and auto-approves payments; production needs your own till or paybill and a Go-Live approval that can take several business days.
- Base URLs and passkeys differ between environments — keep them in configuration, not code.
- Your callback URL must be publicly reachable over HTTPS. Localhost won't work; use a deployed endpoint or a tunnel while testing.
- M-Pesa
- Payments
- Daraja API
- E-commerce

Written by Rasmiranjan Sahoo
Full-stack and React Native developer. I design and build websites, apps and business software end to end — and write about what I learn shipping them. More about me

