paylod
Handle the result

Guides

Handle the M-Pesa payment result

.md

Two ways to learn a payment settled — poll GET /status, or receive a signed webhook. How to choose, and how to verify.

collect() returns before the customer enters the M-Pesa PIN. Two methods tell you the result, and both methods read the same settled record.

PollWebhook
You hostNothingAn HTTPS endpoint
Good forScripts, a checkout with a spinnerServers, high volume, back-office fulfilment

Start with the poll method. Change to a webhook when you have a server that must receive the result.

Option A: poll

A timeout throws PaylodTimeoutError. A timeout does not return ok: false. An unanswered prompt is not a failed payment: the customer can still enter the M-Pesa PIN, and the payment can still succeed. Do not mark the order as failed. Let the webhook tell you the result.

const outcome = await paylod.wait(ack.paymentId, { timeoutMs: 120_000 });

if (outcome.paid) await fulfil(outcome.receipt);
else              await notify(outcome.message);

wait() polls with a random and increasing delay until the payment settles. If the payment is still pending, paylod makes a live M-Pesa STK Query and settles the payment immediately. Thus a poll never returns an old answer.

paylod.status(paymentId) reads the record one time, and does not poll.

Option B: receive a signed webhook

Register an endpoint on the application's Endpoints & Webhooks tab. The SDK verifies the signature for you.

import { Paylod } from "@paylod/node";

const paylod = new Paylod();

export const POST = paylod.webhookHandler(async (event) => {
  if (event.type === "payment.success") {
    await fulfil(event.data.paymentId, event.data.mpesaReceipt);
  } else if (event.data.decoded) {
    await notify(event.data.decoded.customerMessage);
  }
});

decoded is DecodedError | null. paylod fills decoded on payment.failed, and sets decoded to null on payment.success. Test decoded before you read it.

This handler operates on each platform that supplies the Web Request and Response API: Next.js, Hono, Remix, Workers, Bun and Deno. On Express, use paylod.webhook(handler).

On Express, mount this route before each global express.json(). A JSON parser consumes the body, and the raw bytes are then not available. Without the raw bytes, no software can verify the signature. The middleware finds this condition and throws an error. The middleware does not skip the check.

paylod checks the signature before your handler runs. An incorrect signature returns 400, and your handler does not receive the event. If your handler throws an error, the route returns 500. paylod then sends the event again. Thus you must make your handler idempotent.

Check the amount before you fulfil

The webhook carries the true settled amount. If you fulfil an order on the status field alone, a customer can pay you less than the order total.

export const POST = paylod.webhookHandler(async (event) => {
  if (event.type !== "payment.success") return;

  // Look the order up by paymentId — the id you stored when you called collect().
  const order = await db.orders.findByPaymentId(event.data.paymentId);
  if (!order) return;

  if (event.data.amount === order.amountKes) {
    await fulfil(order);
  } else {
    await flagForReview(order, event.data);   // underpaid
  }
});

Store ack.paymentId with your order when you send the STK push. Then use ack.paymentId for the lookup. The event does not return the metadata that you passed to collect().

Failures are results, not outages

payment.failed is a normal outcome. paylod decodes event.data.decoded for you:

  • 1032 — the customer pressed Cancel. Offer a retry.
  • 1037 — the prompt did not reach the handset, or the customer ignored the prompt.
  • 2001 — the customer entered a wrong M-Pesa PIN.
  • 1 — the customer has insufficient balance.

4999 is not a failure. 4999 means that the customer did not enter the M-Pesa PIN yet. Continue to poll. Never send a second collect() call. A second STK push makes a second prompt, and can charge the customer two times. The error reference lists every code.

Retry a failed payment

A payment can truly fail: the customer cancelled the prompt, entered a wrong M-Pesa PIN, or has insufficient balance. A second charge is then a new payment attempt, and the new attempt needs a new idempotency key. A retry with the key of the failed attempt does not charge the customer. Such a retry only replays the failure that you already have.

*One `409` is not a retry signal: the indeterminate `409`. An earlier request under that key can stop while the call to Daraja is in flight. paylod then refuses to send the call again, because the money can already have moved. A timeout is not proof that the money did not move. First read the payment status with `paylod.check(paymentId)` or `GET /status/:id`, or wait for the webhook. If the payment settled, fulfil the order. If no payment occurred, open a new attempt with a new* key. Never retry the spent key. See Idempotency for the full rules.

if (!outcome.paid && outcome.retryable) {
  const retry = await db.attempts.create({ orderId: order.id });   // new attempt → new key
  await paylod.collectAndWait({ amount, phone, idempotencyKey: retry.id });
}

Verify it yourself

If you do not use the SDK, compute the signature yourself. The signature is an HMAC-SHA256 over t + "." + rawBody, with the signing secret of the endpoint as the key. The freshness window is 5 minutes. Compute the signature over the raw bytes. Never compute the signature over an object that you serialised again. The webhooks reference specifies the scheme.

Next

Test in sandbox makes each of these outcomes on demand.