Back to blog

The Complete Guide to Daraja Callbacks: From STK Push to Saving Payments in a Database

The part most tutorials skip, built from an empty folder: fire an STK Push, catch Safaricom's callback on your own server, validate it, store every payment in a real database, and reconcile the ones whose callback never arrives — every command and expected output included.

The Complete Guide to Daraja Callbacks: From STK Push to Saving Payments in a Database

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.

Laptop with program code on the screen — the whole loop lives in one small Node.js project

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:

  1. The conversation you start. Your server POSTs to {BASE_URL}/mpesa/stkpush/v1/processrequest with the amount, the customer's phone number and a CallBackURL. Safaricom answers within a second: ResponseCode: "0", a MerchantRequestID, a CheckoutRequestID and the friendly CustomerMessage. Read that as Safaricom accepted the request, not the customer paid.
  2. 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 CallBackURL you 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:

VariableMeaning
CONSUMER_KEY / CONSUMER_SECRETYour app's credentials; they buy the OAuth token
SHORTCODEThe business number the customer pays — 174379 in sandbox
PASSKEYCombined 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_URLSandbox now; https://api.safaricom.co.ke when you go live
CALLBACK_URLWhere Safaricom will POST the result — the whole subject of this guide
PORTLocal 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.

  1. Sign up free at ngrok.com and install it.
  2. Connect your authtoken (shown on their dashboard): ngrok config add-authtoken YOUR_TOKEN
  3. 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_id is 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.
  • status starts 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_raw keeps the exact JSON Safaricom sent. When a dispute or a bug appears months later, this column is the only witness that cannot misremember.
  • phone and amount are 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.

Server racks in a data center — your payments table is the system of record

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 the YYYYMMDDHHMMSS string 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 includes expires_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:

FieldWhat goes in
BusinessShortCodeThe number receiving the money (174379 in sandbox)
PasswordBase64 of SHORTCODE + PASSKEY + Timestamp — nothing else
TimestampThe EAT string built by the helper
TransactionTypeCustomerPayBillOnline for a Paybill, CustomerBuyGoodsOnline for a Till
PartyA / PhoneNumberThe customer, as 254712345678 — no +, no leading zero
PartyBThe receiving short code
CallBackURLYour ngrok address ending in /callback
AccountReferenceUp 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.

Payment apps on a smartphone — the prompt lands on the customer's phone, but the confirmation travels to your server

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 CheckoutRequestID values 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 === 0 is the only success. Everything else is a failure, and the code says why:
ResultCodeMeaning
0Success — payment completed, metadata present
1Insufficient funds in the customer's account
1032Request cancelled by the user
1037Timeout — 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:

  1. The ngrok inspector (http://localhost:4040) shows POST /callback answered with 200.
  2. Your server console prints Callback received: {"Body":{"stkCallback":...}}.
  3. 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_URL to https://api.safaricom.co.ke, SHORTCODE to your live number, PASSKEY to 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

SymptomLikely causeFix
Callback never arrivesngrok restarted and .env still holds the old URLCheck 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 errorWrong key/secret, or sandbox keys against the production BASE_URLMatch keys and BASE_URL — sandbox keys only work on sandbox.safaricom.co.ke
Phone number rejected at initiationWrong formatUse 254712345678 style — 254 plus the number, no +, no leading zero
Row stays pending although the phone showed the promptRoute path mismatch — Safaricom POSTed to /callback but Express listens on another pathThe CallBackURL must end in exactly the path your route defines; watch the 4040 inspector to see what arrived
Callback logs unknown CheckoutRequestIDThe database file was deleted between initiation and callback, or a proxy stripped the JSON bodyRe-initiate and confirm the pending row exists before testing the callback again

The official pages worth bookmarking

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:

7.7Ksubscribers
50+tutorials
Freeforever

You made it to the end.

Most people don't. Since you're here: we teach this stuff on YouTube, free, every week. 7.7K developers already watch. Pull up a chair.

Watch on @fortunedevacademy

READY TO START
A PROJECT?

Let's discuss your next project.

START A PROJECT