Send M-Pesa payment confirmations on WhatsApp automatically
When a customer pays by M-Pesa, the Safaricom SMS tells them the money left their account. It does not tell them that your business received it and what happens next. A WhatsApp message from your own number, sent the moment the payment is confirmed, closes that gap and cuts down on "did you get my payment?" messages. This guide shows the flow in Node.js.
How the flow works
- Your system sends an STK Push to the customer's phone through Daraja.
- The customer enters their PIN.
- Safaricom posts the result to your callback URL.
- Your callback checks the result, then sends a WhatsApp message through Qwela.
What the callback contains
A successful payment arrives as JSON with Body.stkCallback.ResultCode equal to 0, plus a CallbackMetadata.Item list of name and value pairs, including Amount, MpesaReceiptNumber and PhoneNumber. A cancelled or failed payment has a non-zero result code, such as 1032 for a request cancelled by the user, and no metadata block.
The handler
import express from "express";
const app = express();
app.use(express.json());
const seen = new Set(); // use your database or Redis in production
async function sendWhatsApp(to, text) {
const res = await fetch("https://qwela.jpl365.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QWELA_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to, text }),
});
if (res.status !== 202) throw new Error(`Qwela ${res.status}`);
}
app.post("/mpesa/callback", (req, res) => {
// Acknowledge first. Safaricom only needs a quick answer.
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
const cb = req.body?.Body?.stkCallback;
if (!cb || cb.ResultCode !== 0) return; // failed or cancelled payment
if (seen.has(cb.CheckoutRequestID)) return; // duplicate callback
seen.add(cb.CheckoutRequestID);
// TODO: confirm CheckoutRequestID belongs to a payment YOU started (see below)
const items = Object.fromEntries(
cb.CallbackMetadata.Item.map((i) => [i.Name, i.Value])
);
const phone = String(items.PhoneNumber); // arrives as a number: 254712345678
const text =
`Payment received. KSh ${items.Amount} confirmed, M-Pesa ref ` +
`${items.MpesaReceiptNumber}. Thank you!`;
sendWhatsApp(phone, text).catch((e) =>
console.error("WhatsApp confirmation failed:", e.message)
);
});
app.listen(3000);
Three things that matter in production
- Only trust payments you started. A callback URL is public, so anyone can post to it. Look up
CheckoutRequestIDin your own payments table, which you filled when you sent the STK Push, and ignore anything you do not recognise. Never send a confirmation or release goods based on an unknown callback. - Do not block the callback. Answer Safaricom first and send the WhatsApp message afterwards, as above. A failed WhatsApp message must never cause a failed payment record.
- Deduplicate. Guard against the same callback being processed twice. The in-memory set above is only for illustration. Use a unique constraint on the checkout ID in your database.
What to put in the message
Keep it short and useful: the amount, the M-Pesa reference, and what happens next, such as "Your order will be delivered tomorrow". Do not include anything sensitive. A customer who just paid expects to hear from you, which is exactly the kind of message that works well. Do not message people who cancelled the prompt as a way to chase them.
If you do not run your own Daraja integration
Payment providers such as PayHero or a Daraja library give you a callback in a similar shape. The same idea applies: once your system marks a payment as confirmed, call the Qwela API. If you use Laravel, our PHP and Laravel guide has the equivalent queued job.
Before you send customer messages at scale, read how to avoid a WhatsApp number ban. Need it connected to your checkout? Talk to JPL365.