To send your own SMS receipt for every M-Pesa payment, register a confirmation URL for your paybill or till with Safaricom's Daraja C2B API, acknowledge each confirmation callback straight away, then look up the customer and call the eSMS Africa SMS API with a branded receipt. The whole flow is about 80 lines of Node.js, shown below.
Last reviewed: 27 October 2026
M-Pesa already sends the payer its own confirmation, so why send another? Because Safaricom's message says who was paid, not what was paid for. Your receipt can name the invoice, the school fee term, the SACCO deposit or the order, give the new balance and come from your registered sender ID. For customers, it is proof that your system, not just M-Pesa, recorded the payment.
A note before we start: the field names and behaviour below follow Safaricom's published Daraja documentation and sample payloads at the time of writing. Daraja changes from time to time, especially around customer phone numbers, so check the current documentation on the Daraja portal before you go live.
How the C2B flow works
- You register a confirmation URL (and optionally a validation URL) for your shortcode.
- A customer pays your paybill or till from their phone.
- If you enabled external validation, Daraja first asks your validation URL whether to accept the payment. Most businesses leave validation off.
- When the payment completes, Daraja POSTs a JSON confirmation to your confirmation URL.
- Your server records the payment and sends the SMS receipt through eSMS Africa.
Step 1: register your callback URLs
Create an app on the Daraja portal to get a consumer key and secret, then request an access token and register your URLs. The sandbox lets you simulate payments before you move to production.
# 1. Get an access token (sandbox shown; production uses api.safaricom.co.ke)
curl -s -u "$CONSUMER_KEY:$CONSUMER_SECRET" \
"https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
# 2. Register your confirmation and validation URLs for the shortcode
curl -s -X POST "https://sandbox.safaricom.co.ke/mpesa/c2b/v2/registerurl" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ShortCode": "600000",
"ResponseType": "Completed",
"ConfirmationURL": "https://api.example.co.ke/payments/c2b/<secret>/confirmation",
"ValidationURL": "https://api.example.co.ke/payments/c2b/<secret>/validation"
}'
ResponseType tells M-Pesa what to do if your validation URL cannot be reached: Completed lets the payment through, Cancelled rejects it. Daraja documents restrictions on callback URLs (for example, they must be publicly reachable over HTTPS in production), so check the current list on the portal. Older integrations use a v1 register endpoint; v2 is shown here, so confirm which your app is enabled for.
Step 2: understand the confirmation payload
Safaricom's documentation shows a confirmation like this one:
{
"TransactionType": "Pay Bill",
"TransID": "RKL51ZDR4F",
"TransTime": "20231121121325",
"TransAmount": "5.00",
"BusinessShortCode": "600966",
"BillRefNumber": "Sample Transaction",
"InvoiceNumber": "",
"OrgAccountBalance": "25.00",
"ThirdPartyTransID": "",
"MSISDN": "2547 ***** 126",
"FirstName": "NICHOLAS",
"MiddleName": "",
"LastName": ""
}
The fields that matter for a receipt:
| Field | Use in your receipt |
|---|---|
TransID | The M-Pesa transaction code. Quote it in the SMS and use it as your idempotency key. |
TransAmount | The amount paid, as a string. |
BillRefNumber | The account number the customer typed for a paybill payment. This is how you find the customer. |
TransTime | Timestamp in YYYYMMDDHHmmss format. |
MSISDN | The payer's number, but see below. |
FirstName | The payer's first name, which may be empty. |
The MSISDN problem
You might expect to text the receipt to MSISDN. In the current documentation the confirmation shows a masked number (in the form 2547 ***** 126), and earlier versions returned a hashed value instead of the plain number. Either way, you cannot reliably send an SMS to it.
The fix is to send the receipt to a number you already hold for the customer:
- Paybill: match
BillRefNumberto the customer's account, admission number, member number or invoice in your own database, and use the phone number stored there. - Till (Buy Goods): there is no account number, so you can only send a receipt where you can link the payment to a known customer, for example an order that was waiting for exactly that amount.
- STK push (Lipa na M-Pesa Online): you started the payment with the customer's number, so you already know where to send the receipt.
If your Daraja app does receive a plain number, the sample code accepts it as a fallback, but only when it looks like a complete Kenyan mobile number.
Step 3: the Express handler
This handler responds to Daraja immediately, then records the payment, finds the customer and sends the receipt. Replace the db calls with your own data layer.
// server.js - Node 18+ (built-in fetch), Express 4
import express from 'express';
const app = express();
app.use(express.json());
const ESMS_KEY = process.env.ESMS_API_KEY; // esms_live_... or esms_test_...
const CALLBACK_SECRET = process.env.CALLBACK_SECRET; // random string in your URL path
// Confirmation URL registered with Daraja, e.g.
// https://api.example.co.ke/payments/c2b/<CALLBACK_SECRET>/confirmation
app.post('/payments/c2b/:secret/confirmation', (req, res) => {
if (req.params.secret !== CALLBACK_SECRET) return res.sendStatus(404);
// 1. Acknowledge at once. Do the slow work after responding.
res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
handleConfirmation(req.body).catch((err) =>
console.error('receipt failed', req.body?.TransID, err)
);
});
async function handleConfirmation(p) {
const { TransID, TransAmount, BillRefNumber, MSISDN, FirstName } = p;
// 2. Idempotency: Daraja can deliver the same payment more than once.
const isNew = await db.payments.insertIfAbsent({ transId: TransID, raw: p });
if (!isNew) return;
// 3. Find the customer in YOUR records, by the account number they typed.
const customer = await db.customers.findByAccount(BillRefNumber?.trim());
const to = customer?.phone || usableMsisdn(MSISDN);
if (!to) {
await db.payments.flag(TransID, 'no phone number for receipt');
return;
}
const name = customer?.firstName || titleCase(FirstName) || 'customer';
const amount = Number(TransAmount).toLocaleString('en-KE');
// 4. Send the branded receipt through eSMS Africa.
const resp = await fetch('https://sms.esmsafrica.io/api/messages/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${ESMS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to,
sender_id: 'DUKABORA',
text: `Duka Bora: Thank you ${name}. KES ${amount} received for ` +
`account ${BillRefNumber}. M-Pesa ref ${TransID}.`,
}),
});
if (!resp.ok) throw new Error(`eSMS ${resp.status}: ${await resp.text()}`);
await db.payments.markReceiptSent(TransID);
}
// Only use the callback MSISDN if it is a real, unmasked Kenyan number.
function usableMsisdn(msisdn) {
const digits = String(msisdn || '').replace(/\s/g, '');
return /^254(7|1)\d{8}$/.test(digits) ? `+${digits}` : null;
}
function titleCase(s) {
return s ? s.charAt(0) + s.slice(1).toLowerCase() : '';
}
app.listen(3000);
Why each step matters
- Respond first. Daraja expects a quick answer. A slow database or SMS call inside the request risks a timeout and a repeat callback.
- Deduplicate on TransID. Treat the transaction code as unique. If the same confirmation arrives twice, the second one is ignored and the customer gets one receipt.
- Secret path. Daraja callbacks are not signed, so anyone who discovers your URL could post fake payments to it. A long random path segment helps. For high-value flows, also confirm the transaction with Daraja's Transaction Status API, or reconcile against your M-Pesa statement, before you release goods.
- Flag what you cannot match. Customers mistype account numbers. Send unmatched payments to a review queue rather than dropping them.
Step 4: test without spending
Use an esms_test_ API key while you develop. Test keys accept the same requests and simulate sends without charge, so you can run the Daraja sandbox simulator end to end. When you switch to a live key, every receipt costs one standard Kenyan SMS, currently KES 1.08 ($0.0083) at the basic tier, provided the text stays within one segment of 160 GSM characters.
Writing a good receipt
- Lead with your brand name so the receipt is never mistaken for a scam text imitating M-Pesa.
- Include the amount, what it was for and the M-Pesa reference.
- Add the new balance where it helps, for example a school fee or loan balance.
- Keep offers out. A receipt with a promotion attached becomes marketing.
- Use a transactional sender ID so receipts arrive at any hour. See promotional vs transactional sender IDs.
Sample receipts by industry:
- School: "Mwangaza High: KES 10,000 received for Adm 4521. New balance KES 8,500. Ref SKD4X8P1Q2."
- SACCO: "Jenga SACCO: KES 5,000 deposit received for member 10234. Total deposits KES 148,500."
- Church: "Neema Chapel: Thank you, Mary. KES 2,000 received for TITHE. God bless you."
Monitoring delivery
Each eSMS message gets a delivery report (DLR), available by webhook or in the dashboard. Store the message ID next to the TransID so support staff can answer "I paid but got no receipt" in seconds. See receiving delivery reports for the webhook format.
Frequently asked questions
Does the Daraja confirmation include the customer's phone number?
Not in a form you can text reliably. The current documentation shows a masked MSISDN, and older versions returned a hashed value. Match the payment to your own customer records using the account number, or use the number from an STK push you started. Check the Daraja portal for the latest behaviour.
What should my confirmation URL return?
Return HTTP 200 quickly with a small JSON acknowledgement such as {"ResultCode": 0, "ResultDesc": "Accepted"}, then process the payment. Confirm the exact expected response in the Daraja documentation for your API version.
Can I send the receipt in Kiswahili?
Yes. Kiswahili fits the standard GSM alphabet, so a receipt such as "Tumepokea KES 2,000. Asante." uses the same 160 characters per segment as English. Store each customer's language preference and choose the template accordingly.
Is this the same as M-Pesa's own confirmation?
No. Safaricom still sends its confirmation to the payer. Your receipt is an extra message from your own sender ID that explains what the payment was for and confirms it reached your records.
Sources
- Safaricom Daraja developer portal (C2B API documentation)
- eSMS Africa HTTP API documentation
- KICTANet - Safaricom notice on promotional and transactional sender IDs (2020)
Building payments for a school, SACCO or shop? See the SMS API for Kenya and the fintech use cases, then create a free account to get your test API key.


