Skip to content

M-Pesa · Node.js

M-Pesa STK Push Callbacks and ResultCode 1037: Handle Them Right

Why M-Pesa STK callbacks go missing, which ResultCodes are final, and how to handle 1037 without failing paid orders or charging customers twice.

Published

Skip the boilerplate → Look up any M-Pesa ResultCode or Daraja error in the M-Pesa error code reference.

An M-Pesa STK push looks simple: send the request, wait for the callback, mark the order paid. In production, three things break that:

  • Callbacks go missing, so paid orders sit at “pending”.
  • Callbacks can be faked, because Daraja doesn’t sign them.
  • ResultCode 1037 looks like a failure but often isn’t, so code that marks it failed tells paying customers to pay again.

This guide explains what really happens, which codes are final, and gives you one small Node.js module that settles every STK push correctly. Every code block was run in a fresh project, and the test at the end passes. The same approach is built into the Node.js, Laravel and Django guides.

What a callback looks like

After the customer answers (or doesn’t), Safaricom POSTs to your CallBackURL. A successful payment carries a CallbackMetadata block with the amount and the receipt number:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 1 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20191219102115 },
          { "Name": "PhoneNumber", "Value": 254708374149 }
        ]
      }
    }
  }
}

A failed or cancelled payment has no CallbackMetadata at all:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 1032,
      "ResultDesc": "Request cancelled by user"
    }
  }
}

Code that reads CallbackMetadata.Item without checking crashes on every failed payment. Safaricom then sees an error from your server for each one.

Which ResultCodes are final

Sort every code into one of three groups. Only the first two let you decide anything.

Group ResultCodes What to do
Paid 0 Confirm with STK Push Query, check the amount, record MpesaReceiptNumber, fulfil once
Failed, final 1 (insufficient balance), 1032 (cancelled by user), 2001 (wrong PIN), 1019 (expired) Mark failed and offer “Try again”
Not known yet 1037 (phone unreachable), 1001 (another transaction in progress), 1025, 17, 26 (Safaricom-side errors), and any code you don’t recognise Keep pending. Query again later, and don’t send a second push for the same order yet

The third group is the one that matters. Treating unknown codes as “not known yet” instead of “failed” costs you a short wait. Getting it wrong costs you a customer who paid and was told they didn’t. Every code is explained in the M-Pesa error code reference.

What 1037 really means

1037 is “DS timeout: user cannot be reached”. Safaricom sent the PIN prompt but didn’t get an answer from the phone in time, because it was off, had no signal, or the customer didn’t respond quickly enough.

Developers report payments that completed minutes after a 1037 callback. If your code marks the order failed and the customer tries again, they can be charged twice. So for 1037:

  1. Keep the payment pending. Tell the customer you’re checking: “We couldn’t reach your phone. Check you have signal; we’re confirming your payment.”
  2. Query it with STK Push Query after about 30 seconds, then again with longer gaps.
  3. Block a second push for the same order until the query gives a final answer.
  4. If it’s still unknown after about ten minutes, stop polling and flag it for a person to check against your M-Pesa statement.

1001 (“unable to lock subscriber”) is similar. The customer’s phone already had another M-Pesa transaction or USSD session open. Wait and query before sending anything new.

Why callbacks go missing

When the callback never arrives, it’s almost always one of these:

  • The URL isn’t public HTTPS. Safaricom can’t reach localhost. Use a tunnel such as cloudflared or ngrok in development, and a real domain in production.
  • The URL contains “mpesa” or “safaricom”. Developers report that these are silently dropped. Use a neutral path like /webhooks/stk.
  • A redirect. Safaricom doesn’t follow http → https or trailing-slash redirects. Use the exact final URL.
  • Your server is slow or returns an error. Reply immediately with {"ResultCode": 0, "ResultDesc": "Accepted"} and do the work afterwards.
  • A firewall or proxy blocks it. Check your server’s access logs for the POST.

Whatever the cause, the fix is the same: don’t rely on the callback alone. Every push that is still pending after a minute or two should be queried.

Callbacks can be faked

Daraja callbacks aren’t signed, so anyone who learns your callback URL can post "ResultCode": 0 for any CheckoutRequestID. Two layers protect you:

  1. Confirm with STK Push Query before you mark anything paid. Only Safaricom’s answer counts.
  2. Optionally, put a long random token in the callback path, for example /webhooks/stk/7f3c9a…, and ignore requests to any other path. It stops casual probing, but it is not a substitute for the query.

The query response doesn’t include the receipt number, so take MpesaReceiptNumber from the callback, after the query confirms the payment. A forged callback can then never mark an order paid.

Settle every push exactly once

Callbacks can arrive twice, and your retry job can run at the same moment as a late callback. Make the database do the work: only change a payment that is still pending, in a single conditional update. The second writer then updates nothing.

UPDATE mpesa_payments
SET status = 'paid', receipt = $2, result_code = '0', updated_at = now()
WHERE checkout_request_id = $1 AND status = 'pending';
-- 1 row updated: you settled it, so fulfil the order.
-- 0 rows updated: someone else already did. Do nothing.

The module

This module puts the rules above in one place. It doesn’t depend on a framework or a database. You pass it two functions:

  • query(checkoutRequestId) calls STK Push Query. The Node.js guide’s stkQuery fits.
  • settle(checkoutRequestId, outcome, details) runs the conditional update above and returns true only if it changed the row.
// stk-callbacks.js
export const FINAL_FAILED = new Set(['1', '1032', '2001', '1019']);

/** paid | failed | unknown. Anything not clearly final is "unknown": ask again before deciding. */
export function classify(resultCode) {
  const code = resultCode === undefined || resultCode === null ? '' : String(resultCode);
  if (code === '0') return 'paid';
  if (FINAL_FAILED.has(code)) return 'failed';
  return 'unknown'; // 1037, 1001, 1025, 17, 26, no answer yet, and anything new
}

/** Reads a callback safely. Failed payments have no CallbackMetadata. Returns null if it isn't one. */
export function parseCallback(body) {
  const cb = body?.Body?.stkCallback;
  if (!cb || !cb.CheckoutRequestID) return null;
  const items = Array.isArray(cb.CallbackMetadata?.Item) ? cb.CallbackMetadata.Item : [];
  const meta = Object.fromEntries(items.map((item) => [item?.Name, item?.Value]));
  return {
    checkoutRequestId: String(cb.CheckoutRequestID),
    resultCode: String(cb.ResultCode ?? ''),
    receipt: meta.MpesaReceiptNumber ? String(meta.MpesaReceiptNumber) : null,
    amount: meta.Amount === undefined ? null : Number(meta.Amount),
  };
}

/** When to query an unresolved push, in seconds after it was sent. After the last, flag it for a person. */
export const QUERY_SCHEDULE = [30, 60, 120, 300, 600];

/** What to tell the customer while you wait or after a final answer. */
export function customerMessage(resultCode) {
  const messages = {
    0: 'Payment received. Thank you!',
    1: 'Your M-Pesa balance is too low for this payment.',
    1032: 'You cancelled the M-Pesa prompt. Tap "Try again" when you\'re ready.',
    2001: 'The M-Pesa PIN was incorrect. Please try again.',
    1019: 'The payment request expired. Please try again.',
    1037: "We couldn't reach your phone. Check you have signal; we're confirming your payment.",
    1001: 'You have another M-Pesa transaction in progress. Finish it and we\'ll confirm this one.',
  };
  return messages[String(resultCode)] ?? "We're confirming your payment with M-Pesa.";
}

/**
 * Asks Safaricom for the real result and settles the payment once.
 * Safe to call from callbacks and retry jobs at the same time.
 * Returns 'paid' | 'failed' | 'unknown' | 'already-settled'.
 */
export async function confirm(checkoutRequestId, { query, settle, receipt = null }) {
  let result;
  try {
    result = await query(checkoutRequestId);
  } catch {
    return 'unknown'; // network error, or a 500.001.1001 "still processing" answer: ask again later
  }
  const outcome = classify(result?.ResultCode);
  if (outcome === 'unknown') return 'unknown';
  const changed = await settle(checkoutRequestId, outcome, {
    resultCode: String(result.ResultCode),
    receipt: outcome === 'paid' ? receipt : null, // the query has no receipt; trust the callback's only now
  });
  return changed ? outcome : 'already-settled';
}

/**
 * Handles one callback body. Call it AFTER replying {"ResultCode": 0, "ResultDesc": "Accepted"}.
 * isOurs(checkoutRequestId) should check your database, so random IDs never reach Safaricom.
 */
export async function handleCallback(body, { isOurs, query, settle }) {
  const cb = parseCallback(body);
  if (!cb || !(await isOurs(cb.checkoutRequestId))) return 'ignored';
  // Whatever the callback claims, only the query decides. The receipt is kept for a confirmed success.
  return confirm(cb.checkoutRequestId, { query, settle, receipt: cb.resultCode === '0' ? cb.receipt : null });
}

Plug it into your callback route like this (Express shown; any framework works the same way):

// webhook.js
import express from 'express';
import { handleCallback } from './stk-callbacks.js';
import { stkQuery } from './mpesa.js'; // from the Node.js STK push guide
import { isOurs, settle } from './payments-db.js'; // your database: a lookup and the conditional UPDATE

export const app = express();
app.use(express.json());

app.post('/webhooks/stk', (req, res) => {
  res.json({ ResultCode: 0, ResultDesc: 'Accepted' }); // acknowledge first, always
  handleCallback(req.body, { isOurs, query: stkQuery, settle }).catch((err) => console.error('STK callback', err));
});

Resolve the pushes nobody answered

Run a job every minute or two. It queries every push that is still pending and is due on the QUERY_SCHEDULE, and flags the ones that stay unknown past the last step:

// resolve-pending.js — run every minute from cron, a queue or a scheduler
import { confirm, QUERY_SCHEDULE } from './stk-callbacks.js';

/** pending: [{ checkoutRequestId, sentAt: Date, checks: number }] from your database. */
export async function resolvePending(pending, { query, settle, recordCheck, flagForReview, now = new Date() }) {
  for (const p of pending) {
    const ageSeconds = (now - p.sentAt) / 1000;
    if (p.checks >= QUERY_SCHEDULE.length) {
      await flagForReview(p.checkoutRequestId); // still unknown after ~10 minutes: a person checks the statement
      continue;
    }
    if (ageSeconds < QUERY_SCHEDULE[p.checks]) continue; // not due yet
    await recordCheck(p.checkoutRequestId); // checks + 1
    await confirm(p.checkoutRequestId, { query, settle });
  }
}

For the query step, remember that 500.001.1001 on STK Push Query usually means “still processing”, so make your query function throw or return no ResultCode for it. confirm then reports unknown and the job asks again on schedule. (The same code on the STK push request itself means your credentials are wrong.)

Test it

This test uses Node’s built-in test runner and an in-memory store with the same “only if pending” rule as the SQL above. It covers:

  • a real success
  • a forged success that the query contradicts
  • a 1037 that completes later
  • duplicate callbacks
  • callbacks for IDs that aren’t yours
  • a failed callback with no metadata
  • “still processing” answers
// stk-callbacks.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, confirm, handleCallback, parseCallback } from './stk-callbacks.js';
import { resolvePending } from './resolve-pending.js';

function makeStore() {
  const rows = new Map([['ws_CO_1', { status: 'pending', receipt: null, resultCode: null, fulfilled: 0 }]]);
  return {
    rows,
    isOurs: async (id) => rows.has(id),
    settle: async (id, outcome, { resultCode, receipt }) => {
      const row = rows.get(id);
      if (!row || row.status !== 'pending') return false; // the conditional UPDATE: 0 rows changed
      Object.assign(row, { status: outcome, resultCode, receipt });
      if (outcome === 'paid') row.fulfilled += 1;
      return true;
    },
  };
}

const callback = (resultCode, receipt = 'NLJ7RT61SV') => ({
  Body: {
    stkCallback: {
      CheckoutRequestID: 'ws_CO_1', ResultCode: resultCode, ResultDesc: 'test',
      ...(resultCode === 0 && { CallbackMetadata: { Item: [{ Name: 'Amount', Value: 1 }, { Name: 'MpesaReceiptNumber', Value: receipt }] } }),
    },
  },
});
const answers = (code) => async () => ({ ResultCode: code });

test('classify: only clear codes are final', () => {
  assert.equal(classify(0), 'paid');
  assert.equal(classify('1032'), 'failed');
  for (const code of ['1037', '1001', '1025', '17', '26', '9999', undefined]) assert.equal(classify(code), 'unknown', String(code));
});

test('a real success is confirmed and keeps the receipt', async () => {
  const store = makeStore();
  assert.equal(await handleCallback(callback(0), { ...store, query: answers('0') }), 'paid');
  assert.deepEqual(store.rows.get('ws_CO_1'), { status: 'paid', receipt: 'NLJ7RT61SV', resultCode: '0', fulfilled: 1 });
});

test('a forged success is overruled by the query', async () => {
  const store = makeStore();
  assert.equal(await handleCallback(callback(0, 'FAKE'), { ...store, query: answers('1032') }), 'failed');
  assert.deepEqual(store.rows.get('ws_CO_1'), { status: 'failed', receipt: null, resultCode: '1032', fulfilled: 0 });
});

test('1037 stays pending, then the scheduled job settles it', async () => {
  const store = makeStore();
  assert.equal(await handleCallback(callback(1037), { ...store, query: answers('1037') }), 'unknown');
  assert.equal(store.rows.get('ws_CO_1').status, 'pending');

  const sentAt = new Date('2026-10-29T10:00:00Z');
  const pending = [{ checkoutRequestId: 'ws_CO_1', sentAt, checks: 0 }];
  const deps = { ...store, query: answers('0'), recordCheck: async () => { pending[0].checks += 1; }, flagForReview: async () => assert.fail('not yet') };
  await resolvePending(pending, { ...deps, now: new Date(sentAt.getTime() + 10_000) }); // too early: not queried
  assert.equal(store.rows.get('ws_CO_1').status, 'pending');
  await resolvePending(pending, { ...deps, now: new Date(sentAt.getTime() + 45_000) });
  assert.equal(store.rows.get('ws_CO_1').status, 'paid');
});

test('duplicate callbacks fulfil the order once', async () => {
  const store = makeStore();
  const deps = { ...store, query: answers('0') };
  const results = await Promise.all([handleCallback(callback(0), deps), handleCallback(callback(0), deps)]);
  assert.deepEqual(results.sort(), ['already-settled', 'paid']);
  assert.equal(store.rows.get('ws_CO_1').fulfilled, 1);
});

test('callbacks for IDs that aren\'t ours never reach Safaricom', async () => {
  const store = makeStore();
  let queried = false;
  const body = { Body: { stkCallback: { CheckoutRequestID: 'ws_CO_not_ours', ResultCode: 0 } } };
  assert.equal(await handleCallback(body, { ...store, query: async () => { queried = true; return { ResultCode: '0' }; } }), 'ignored');
  assert.equal(queried, false);
});

test('a failed callback without CallbackMetadata is handled', () => {
  assert.deepEqual(parseCallback(callback(1032)), { checkoutRequestId: 'ws_CO_1', resultCode: '1032', receipt: null, amount: null });
  assert.equal(parseCallback({}), null);
  assert.equal(parseCallback({ Body: {} }), null);
});

test('"still processing" and network errors leave it pending', async () => {
  const store = makeStore();
  const stillProcessing = async () => { throw new Error('500.001.1001 The transaction is being processed'); };
  assert.equal(await confirm('ws_CO_1', { ...store, query: stillProcessing }), 'unknown');
  assert.equal(store.rows.get('ws_CO_1').status, 'pending');
});

test('unknown pushes are flagged after the last scheduled check', async () => {
  const store = makeStore();
  const flagged = [];
  const pending = [{ checkoutRequestId: 'ws_CO_1', sentAt: new Date(0), checks: 5 }];
  await resolvePending(pending, { ...store, query: answers('1037'), recordCheck: async () => {}, flagForReview: async (id) => flagged.push(id), now: new Date(3_600_000) });
  assert.deepEqual(flagged, ['ws_CO_1']);
});

Run it with node --test. The project needs "type": "module" in package.json (npm pkg set type=module).

Checklist

  • Reply {"ResultCode": 0, "ResultDesc": "Accepted"} immediately, then do the work.
  • Never read CallbackMetadata without checking it exists.
  • Treat only 0, 1, 1032, 2001 and 1019 as final. Everything else is “not known yet”.
  • Confirm every result with STK Push Query, and take the receipt from the callback only after that.
  • Settle with a conditional update (WHERE status = 'pending'), so duplicates do nothing.
  • Query unresolved pushes on a schedule, and flag the ones still unknown after about ten minutes.
  • Don’t send a second push for an order while the first is unresolved.
  • Keep “mpesa” and “safaricom” out of the callback URL, and don’t redirect it.

Want this already wired into a full payment flow with MTN MoMo and Airtel Money too? The starter kits for Laravel, Django and Next.js include it.

Frequently asked questions

What does M-Pesa ResultCode 1037 mean?

1037 is "DS timeout: user cannot be reached". Safaricom could not get an answer from the phone in time, because it was off, had no signal or the customer did not respond. It does not mean the payment failed: developers report payments that completed minutes after a 1037, so query the status before deciding.

Why is my M-Pesa callback not received?

The usual causes are a callback URL that is not public HTTPS, a URL containing "mpesa" or "safaricom" (developers report these are dropped), a redirect from http to https, or a server that is slow or returns an error. Whatever the cause, never rely on the callback alone: query unresolved pushes with STK Push Query.

Is the M-Pesa STK callback signed?

No. Daraja callbacks carry no signature, so anyone who learns your callback URL can post a fake success. Use the callback to find the payment and read the receipt number, and confirm the result with STK Push Query before you fulfil the order.

Can I send a second STK push after a 1037?

Not until the first one is resolved. If the first push completes late and you have already sent a second, the customer can end up paying twice. Query the first push, and only offer "Try again" once it is final.

What does 500.001.1001 mean on STK Push Query?

On a query it usually means the transaction is still being processed, so ask again later. On the STK push request itself the same code usually means wrong credentials: the shortcode, passkey or Password don't match.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.