You fire the STK Push, Safaricom answers "Success. Request accepted for processing", and it is tempting to declare the payment done. That message does not mean the customer paid. It means Safaricom accepted your request and pushed a PIN prompt to their phone. What actually happened — paid, cancelled, insufficient funds, or ignored until the prompt expired — arrives separately, as an HTTP POST from Safaricom's servers to a URL you chose when initiating. That POST is the callback.
This is the part of Daraja where most first integrations fall over. The push works, the demo looks great, and then nothing in your system ever records that money moved — because the code that receives the result was never written. This guide builds the entire loop from an empty folder: initiate the push, expose your machine so Safaricom can reach it, receive the callback, validate it, save the payment in a real database, and reconcile the transactions whose callback never arrives.
The stack is deliberately boring so every step runs on an ordinary laptop: Node.js with Express for the server, SQLite through better-sqlite3 for storage, ngrok to put localhost on the internet. Nothing is assumed — every command, file and expected output is written out. By the end you will have a server that turns raw M-Pesa push results into rows you can query, plus the habits that keep it alive in production.

What actually happens when you fire an STK Push
Two HTTP conversations happen for every M-Pesa Express payment, and confusing them is the root of almost every broken integration:
- The conversation you start. Your server POSTs to
{BASE_URL}/mpesa/stkpush/v1/processrequestwith the amount, the customer's phone number and aCallBackURL. Safaricom answers within a second:ResponseCode: "0", aMerchantRequestID, aCheckoutRequestIDand the friendlyCustomerMessage. Read that as Safaricom accepted the request, not the customer paid. - The conversation Safaricom starts. The customer's phone pops the PIN prompt. Whatever happens next — approval, cancellation, insufficient funds, or the prompt expiring untouched — Safaricom's servers POST the outcome as JSON to the
CallBackURLyou supplied. That POST is the callback.
The two IDs in the first response are not decoration. MerchantRequestID is Safaricom's own tracking number. CheckoutRequestID is the join key for everything you build: it appears in the initiation response and again inside the callback, so it is how a row in your database and a result falling out of the sky get matched to each other. Save it the moment the initiation succeeds — the rest of this guide does exactly that.
What you need before you start
- Node.js 18 or newer — check with
node -v; if it errors or reports something older, install from nodejs.org first. - A Daraja sandbox app with its Consumer Key and Consumer Secret, from developer.safaricom.co.ke under My Apps. If you have never created one, the complete go-live guide walks the portal from account creation onward.
- A free ngrok account — Step 2 uses it to give Safaricom a URL that can actually reach your laptop.
- About twenty minutes and two terminal windows.
Safaricom publishes fixed test values for the sandbox: the public short code 174379 and its matching sandbox passkey, both listed on the portal's Test Credentials page. Use those while learning. Production values — your own short code and a production passkey — only exist after your business number has been through go-live, which is a separate process covered in the same guide.
Step 1: Create the project
Open a terminal in the folder where you keep your code and run:
mkdir daraja-callbacks
cd daraja-callbacks
npm init -y
npm install express better-sqlite3 axios dotenv
npm prints a summary and creates package.json. When it settles, the project should look like this:
daraja-callbacks/
├── node_modules/
├── package.json
├── .env ← secrets and the callback URL (you create it next)
├── db.js ← the SQLite table (Step 3)
└── server.js ← token, STK Push, callback handler (Steps 4–7)
Create the .env file and fill in your own key and secret:
CONSUMER_KEY=your_sandbox_consumer_key
CONSUMER_SECRET=your_sandbox_consumer_secret
SHORTCODE=174379
PASSKEY=bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
BASE_URL=https://sandbox.safaricom.co.ke
CALLBACK_URL=https://your-tunnel.ngrok-free.app/callback
PORT=3000
What each line does:
| Variable | Meaning |
|---|---|
CONSUMER_KEY / CONSUMER_SECRET | Your app's credentials; they buy the OAuth token |
SHORTCODE | The business number the customer pays — 174379 in sandbox |
PASSKEY | Combined with the short code and timestamp to build the request password; the sandbox one is public, the production one arrives by email after go-live |
BASE_URL | Sandbox now; https://api.safaricom.co.ke when you go live |
CALLBACK_URL | Where Safaricom will POST the result — the whole subject of this guide |
PORT | Local port Express listens on |
Leave CALLBACK_URL at the placeholder for now — Step 2 replaces it with your real tunnel address.
Step 2: Put your laptop on the internet with ngrok
Safaricom's servers live on the internet; your machine sits behind a router. The callback is a POST to you, so the URL in CallBackURL must be publicly reachable at the moment the customer enters their PIN. localhost cannot be reached from Safaricom's network — this is the single most common reason beginners never see a callback.
ngrok fixes it by opening a tunnel: a public HTTPS URL that forwards to a port on your machine.
- Sign up free at ngrok.com and install it.
- Connect your authtoken (shown on their dashboard):
ngrok config add-authtoken YOUR_TOKEN - Start the tunnel:
ngrok http 3000
Expected output:
Session Status online
Account your name (Plan: Free)
Forwarding https://<random-subdomain>.ngrok-free.app -> http://localhost:3000
Copy the https://...ngrok-free.app address, paste it into .env as the CALLBACK_URL with /callback on the end, and leave ngrok running:
CALLBACK_URL=https://<random-subdomain>.ngrok-free.app/callback
Two facts about the free plan worth knowing: the subdomain changes every time ngrok restarts, so re-paste the URL into .env after each restart (and restart your server); and ngrok keeps an inspector at http://localhost:4040 showing every request that came through the tunnel — when a callback seems missing, that page tells you whether Safaricom ever knocked.
Checkpoint: ngrok reports online and .env holds the full tunnel address ending in /callback.
Step 3: Create the payments table
The callback is just JSON in a POST body — if you do not write it somewhere within that request, it is gone. The database is the memory. Create db.js:
const Database = require("better-sqlite3");
const db = new Database("payments.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checkout_request_id TEXT UNIQUE NOT NULL,
merchant_request_id TEXT,
phone TEXT,
amount REAL,
status TEXT NOT NULL DEFAULT 'pending',
result_code INTEGER,
result_desc TEXT,
mpesa_receipt TEXT,
transaction_date TEXT,
callback_raw TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT
)
`);
module.exports = db;
Design decisions, column by column:
checkout_request_idis UNIQUE. Safaricom may deliver the same callback more than once, and a unique key means a replay updates the same row instead of creating a phantom second payment. That property is called idempotency, and it is the difference between a payments table you can trust and one you cannot.statusstarts at'pending'the moment you initiate, then flips to'success'or'failed'when the callback lands. A payment stuck at'pending'for too long is your signal to go looking for it (Step 9).callback_rawkeeps the exact JSON Safaricom sent. When a dispute or a bug appears months later, this column is the only witness that cannot misremember.phoneandamountare filled by you at initiation time. Never treat your own values and Safaricom's as interchangeable — compare them (more on that before production).
Run it once so the database file and table exist:
node -e "require('./db')"
ls -lh payments.db
Expected: payments.db appears in the folder listing.

Checkpoint: payments.db exists, and running the require line a second time does not error — CREATE TABLE IF NOT EXISTS makes it safe to re-run.
Step 4: Two helpers — timestamp and access token
Create server.js and start it with the imports, the environment, and two helpers every route will use:
require("dotenv").config();
const express = require("express");
const axios = require("axios");
const db = require("./db");
const app = express();
app.use(express.json());
const {
CONSUMER_KEY, CONSUMER_SECRET, SHORTCODE, PASSKEY,
BASE_URL, CALLBACK_URL, PORT,
} = process.env;
// Safaricom validates the timestamp against its own East Africa Time clock (UTC+3)
function timestamp() {
const p = (n) => String(n).padStart(2, "0");
const eat = new Date(Date.now() + 3 * 60 * 60 * 1000);
return (
eat.getUTCFullYear() + p(eat.getUTCMonth() + 1) + p(eat.getUTCDate()) +
p(eat.getUTCHours()) + p(eat.getUTCMinutes()) + p(eat.getUTCSeconds())
);
}
async function getToken() {
const auth = Buffer.from(`${CONSUMER_KEY}:${CONSUMER_SECRET}`).toString("base64");
const res = await axios.get(`${BASE_URL}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${auth}` },
});
return res.data.access_token;
}
Why the helpers look the way they do:
timestamp()builds theYYYYMMDDHHMMSSstring Daraja checks against its own East Africa clock. Deriving it from UTC plus three hours works on any server anywhere in the world — a machine set to a different local timezone is a classic source of clock-drift rejections.getToken()exchanges your key and secret for a Bearer token. The response includesexpires_in: "3599"— roughly an hour — so in production you would cache the token and refresh only when it ages out, not fetch a new one per request.
Step 5: Initiate the STK Push
Append the initiation route to server.js:
app.post("/stk/push", async (req, res) => {
try {
const phone = String(req.body.phone); // 254 followed by the number, e.g. 254712345678
const amount = req.body.amount; // whole shillings, minimum 1
const ts = timestamp();
const password = Buffer.from(`${SHORTCODE}${PASSKEY}${ts}`).toString("base64");
const token = await getToken();
const payload = {
BusinessShortCode: SHORTCODE,
Password: password,
Timestamp: ts,
TransactionType: "CustomerPayBillOnline",
Amount: amount,
PartyA: phone, // who pays
PartyB: SHORTCODE, // who gets paid
PhoneNumber: phone,
CallBackURL: CALLBACK_URL,
AccountReference: "Test Shop", // what the customer sees on the prompt
TransactionDesc: "Order payment",
};
const mpesa = await axios.post(`${BASE_URL}/mpesa/stkpush/v1/processrequest`, payload, {
headers: { Authorization: `Bearer ${token}` },
});
const data = mpesa.data;
if (data.ResponseCode !== "0") {
return res.status(400).json({ error: data.ResponseDescription || data.errorMessage });
}
db.prepare(`
INSERT INTO payments (checkout_request_id, merchant_request_id, phone, amount, status)
VALUES (?, ?, ?, ?, 'pending')
`).run(data.CheckoutRequestID, data.MerchantRequestID, phone, amount);
res.json({
checkoutRequestId: data.CheckoutRequestID,
customerMessage: data.CustomerMessage,
});
} catch (err) {
const detail = err.response ? err.response.data : err.message;
res.status(500).json({ error: detail });
}
});
The fields Safaricom actually inspects:
| Field | What goes in |
|---|---|
BusinessShortCode | The number receiving the money (174379 in sandbox) |
Password | Base64 of SHORTCODE + PASSKEY + Timestamp — nothing else |
Timestamp | The EAT string built by the helper |
TransactionType | CustomerPayBillOnline for a Paybill, CustomerBuyGoodsOnline for a Till |
PartyA / PhoneNumber | The customer, as 254712345678 — no +, no leading zero |
PartyB | The receiving short code |
CallBackURL | Your ngrok address ending in /callback |
AccountReference | Up to 12 characters, shown on the customer's prompt |
A successful initiation returns this shape:
{
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_DMZ_12321_23423476",
"ResponseCode": "0",
"ResponseDescription": "Success. Request accepted for processing",
"CustomerMessage": "Success. Request accepted for processing"
}
Two details beginners trip on: ResponseCode is the string "0", not the number 0, which is why the comparison uses !== "0"; and nothing in this response proves a shilling moved. The row your route just inserted into payments.db is a pending row — it becomes a completed payment only when the endpoint in Step 6 hears otherwise.

Step 6: Receive the callback
Now the part this guide exists for. When the transaction resolves, Safaricom POSTs JSON to your CallBackURL. A success carries a CallbackMetadata block:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_DMZ_12321_23423476",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 1 },
{ "Name": "MpesaReceiptNumber", "Value": "T7F6GHQ2X9" },
{ "Name": "Balance" },
{ "Name": "TransactionDate", "Value": "20191122061903" },
{ "Name": "PhoneNumber", "Value": 254708374149 }
]
}
}
}
}
(Example values — your receipt numbers and dates will differ.) A failure has no metadata at all:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_DMZ_12321_23423476",
"ResultCode": 1032,
"ResultDesc": "Request cancelled by user"
}
}
}
Note the quirks before writing the handler: metadata items arrive as a list of {Name, Value} pairs, Balance sometimes carries no Value at all, PhoneNumber arrives as a number rather than a string, and TransactionDate is YYYYMMDDHHMMSS in East Africa Time — 20191122061903 is 22 November 2019, 06:19:03 EAT.
Append the callback route:
app.post("/callback", (req, res) => {
console.log("Callback received:", JSON.stringify(req.body));
const cb = req.body.Body && req.body.Body.stkCallback;
if (!cb || cb.CheckoutRequestID === undefined) {
return res.status(400).json({ ResultCode: 1, ResultDesc: "Malformed callback" });
}
const { CheckoutRequestID, ResultCode, ResultDesc } = cb;
// The only real validation available: did WE initiate this push?
const row = db.prepare("SELECT id, amount FROM payments WHERE checkout_request_id = ?").get(CheckoutRequestID);
if (!row) {
console.warn("Callback for unknown CheckoutRequestID:", CheckoutRequestID);
return res.json({ ResultCode: 0, ResultDesc: "Accepted" });
}
let status = "failed";
let receipt = null;
let txDate = null;
if (ResultCode === 0) {
status = "success";
const items = (cb.CallbackMetadata && cb.CallbackMetadata.Item) || [];
const meta = {};
for (const item of items) meta[item.Name] = item.Value;
receipt = meta.MpesaReceiptNumber || null;
txDate = meta.TransactionDate ? String(meta.TransactionDate) : null;
// sanity check: does the paid amount match the initiated amount?
if (meta.Amount && Number(meta.Amount) !== Number(row.amount)) {
console.warn(`Amount mismatch: initiated ${row.amount}, paid ${meta.Amount}`);
}
}
db.prepare(`
UPDATE payments
SET status = ?, result_code = ?, result_desc = ?, mpesa_receipt = ?,
transaction_date = ?, callback_raw = ?, updated_at = datetime('now')
WHERE checkout_request_id = ?
`).run(status, ResultCode, ResultDesc, receipt, txDate, JSON.stringify(cb), CheckoutRequestID);
// Acknowledge so Safaricom considers the delivery complete
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
});
Walk through the decisions, because each one earns its place:
- Validate by lookup, not by trusting the payload. Daraja callbacks carry no signature, so there is no cryptographic way to prove a POST really came from Safaricom. The defence is that
CheckoutRequestIDvalues are unguessable strings that you created — if the ID is not in your table, you never started that transaction, and the handler logs and ignores it. - Always answer
200, even for unknown IDs. The ack body is not strictly policed; the status code is what tells Safaricom the delivery landed. - The handler is idempotent by construction. A replayed callback simply updates the same row again, because everything hangs off the unique
checkout_request_id. ResultCode === 0is the only success. Everything else is a failure, and the code says why:
| ResultCode | Meaning |
|---|---|
0 | Success — payment completed, metadata present |
1 | Insufficient funds in the customer's account |
1032 | Request cancelled by the user |
1037 | Timeout — the prompt expired without a PIN, or the phone was unreachable |
These are the codes you will meet day to day; others exist, which is exactly why result_code is stored raw instead of being flattened into a boolean.
Step 7: Finish the server and start it
One debug route and the listener close out server.js:
app.get("/payments", (req, res) => {
const rows = db.prepare("SELECT * FROM payments ORDER BY id DESC").all();
res.json(rows);
});
app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));
/payments exists purely so you can watch rows change state without installing a database CLI; it is a tutorial convenience, not an API — remove it or protect it before anything real listens on this port. Start the server:
node server.js
Expected:
Listening on http://localhost:3000
Checkpoint: the process stays up with no stack trace. If it crashes on startup, the usual culprit is .env — a missing value or a typo in a variable name.
Step 8: The full test drive
Terminal one: ngrok still running. Terminal two: node server.js still running. A third terminal fires the payment:
curl -X POST http://localhost:3000/stk/push \
-H "Content-Type: application/json" \
-d '{"phone":"254708374149","amount":1}'
254708374149 is Safaricom's sandbox test number — in the sandbox you never type a real PIN; Safaricom completes the transaction on its own and fires the callback within moments.
Expected response:
{
"checkoutRequestId": "ws_CO_DMZ_12321_23423476",
"customerMessage": "Success. Request accepted for processing"
}
Within seconds, three things happen:
- The ngrok inspector (
http://localhost:4040) showsPOST /callbackanswered with200. - Your server console prints
Callback received: {"Body":{"stkCallback":...}}. - The database row grows up. Check it:
curl http://localhost:3000/payments
Expected (trimmed to the interesting fields — example values):
[
{
"id": 1,
"checkout_request_id": "ws_CO_DMZ_12321_23423476",
"phone": "254708374149",
"amount": 1,
"status": "success",
"result_code": 0,
"mpesa_receipt": "T7F6GHQ2X9",
"transaction_date": "20191122061903"
}
]
Checkpoint: the row went from pending to success with a receipt number, without you touching the database by hand. That state change — one row, two moments in time, joined by checkout_request_id — is the entire contract between Daraja and your code.
Step 9: When the callback never arrives — STK Query
Callbacks get lost for boring reasons: ngrok restarted and the URL changed, the server was down for a deploy, or the tunnel hiccuped at the exact wrong moment. A pending row is not a failed row — you owe it a second look, and the tool for that is STK Query, which asks Safaricom directly about a CheckoutRequestID:
app.get("/stk/status/:checkoutRequestId", async (req, res) => {
try {
const ts = timestamp();
const password = Buffer.from(`${SHORTCODE}${PASSKEY}${ts}`).toString("base64");
const token = await getToken();
const q = await axios.post(`${BASE_URL}/mpesa/stkpushquery/v1/query`, {
BusinessShortCode: SHORTCODE,
Password: password,
Timestamp: ts,
CheckoutRequestID: req.params.checkoutRequestId,
}, { headers: { Authorization: `Bearer ${token}` } });
res.json(q.data);
} catch (err) {
const detail = err.response ? err.response.data : err.message;
res.status(500).json({ error: detail });
}
});
Restart the server and query the transaction from Step 8:
curl http://localhost:3000/stk/status/ws_CO_DMZ_12321_23423476
The response carries the same family of codes a callback would have brought — ResultCode: 0 with a description when the payment completed, 1032 or 1037 when it did not.
In production this route becomes a reconciliation job: a scheduled script that selects payments stuck at status = 'pending' for more than a couple of minutes, queries each one, and updates the rows exactly as the callback would have. Run it every minute or so and a lost callback becomes a minor delay instead of a support ticket.
Before you point this at real money
- Swap the environment:
BASE_URLtohttps://api.safaricom.co.ke,SHORTCODEto your live number,PASSKEYto the production passkey that arrives by email after the Go Live review — the go-live guide documents that delivery. - Retire ngrok. Free tunnel URLs rotate on every restart; production needs a fixed HTTPS endpoint on port 443 with no redirects. The sandbox-to-production checklist lists the rules production enforces on callback endpoints.
- Cache the token.
expires_in: "3599"means roughly an hour of life; refreshing it per request wastes a round trip on every push. - Ack first, work second. The callback handler should update the row and respond; emails, receipts and fulfilment belong in a queue that runs afterwards. Safaricom should never be waiting on your SMTP server.
- Verify before you fulfil. For anything valuable, treat a "paid" callback as a claim, not a fact: the amount check in Step 6 catches mismatches, and a quick STK Query confirms completion before an order ships.
- Protect or remove
/payments. As written it exposes every transaction to anyone who finds the URL. - Keep
callback_raw. Storage is cheap; reconstructing what Safaricom actually said months later is not.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Callback never arrives | ngrok restarted and .env still holds the old URL | Check the ngrok inspector for any POST /callback; if the URL changed, re-paste it into .env and restart the server |
| Initiation returns 401 or an invalid-credentials error | Wrong key/secret, or sandbox keys against the production BASE_URL | Match keys and BASE_URL — sandbox keys only work on sandbox.safaricom.co.ke |
| Phone number rejected at initiation | Wrong format | Use 254712345678 style — 254 plus the number, no +, no leading zero |
Row stays pending although the phone showed the prompt | Route path mismatch — Safaricom POSTed to /callback but Express listens on another path | The CallBackURL must end in exactly the path your route defines; watch the 4040 inspector to see what arrived |
Callback logs unknown CheckoutRequestID | The database file was deleted between initiation and callback, or a proxy stripped the JSON body | Re-initiate and confirm the pending row exists before testing the callback again |
The official pages worth bookmarking
- Daraja portal — apps, test credentials and the Go Live form.
- The APIs listing — the M-Pesa Express (STK Push) and query documentation live here.
- The M-PESA Business Hub — where the short code itself is applied for and managed.
- [email protected] — for API-side questions your logs cannot answer.
From here
The loop you just built — push, callback, database, reconciliation — is the same pattern every serious M-Pesa integration runs on, whether the storage is SQLite or Postgres and the server is Express or something heavier. Where you take it next depends on what sits in front of it:
- Building a checkout page? The React + FortPesa walkthrough covers the front end that fires the push.
- Rather not manage raw callbacks at all? FortPesa's Node.js guide shows the same flow through signed webhooks, where verification is cryptographic instead of lookup-based.
- Ready for production credentials? The complete go-live guide takes a business number from application to live keys.
- Before deploying, run the sandbox-to-production checklist against this server — it catches the redirect and allowlist traps that only appear on real traffic.