Guides
Handle the result
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 has typed their PIN. There are two ways to learn what happened, and both read the same settled record.
| Polling | Webhooks | |
|---|---|---|
| You host | Nothing | An HTTPS endpoint |
| Good for | Scripts, a checkout with a spinner | Servers, volume, back-office fulfilment |
Start with polling. Move to webhooks when you have a server that wants to be told.
Option A: poll
const outcome = await paylod.wait(ack.paymentId, { timeoutMs: 120_000 });
if (outcome.paid) await fulfil(outcome.receipt);
else await notify(outcome.message);wait() polls on a jittered backoff ramp until the payment settles. If the payment is still pending, paylod runs a live M-Pesa STK Query and settles it on the spot, so a poll never returns a stale answer.
paylod.status(paymentId) reads the record once, without polling.
A timeout throws PaylodTimeoutError rather than returning ok: false. An unanswered prompt is not a failed payment: the customer may still be typing their PIN, and it can still succeed. Do not mark the order failed. Let the webhook tell you.
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: it is populated on payment.failed and null on payment.success. Check it before you read it.
This works anywhere the Web Request/Response API does: Next.js, Hono, Remix, Workers, Bun, Deno. On Express, use paylod.webhook(handler) instead.
The signature is checked before your handler runs. A bad signature returns 400 and your handler never sees the event. If your handler throws, the route returns 500 and paylod retries the delivery, so make the handler idempotent.
On Express, mount this route before any global express.json(). Once a JSON parser has consumed the body the raw bytes are gone and no signature can be verified. The middleware detects this and fails loudly rather than skipping the check.
Check the amount before you fulfil
The webhook carries the true settled amount. Fulfilling on status alone is how merchants get underpaid.
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 against your order when you send the push, and key the lookup on it. The event does not echo back the metadata you passed to collect().
Failures are results, not outages
payment.failed is a normal outcome. event.data.decoded is already decoded for you:
1032the customer pressed Cancel. Offer a retry.1037the prompt never reached the handset, or was ignored.2001wrong M-Pesa PIN.1insufficient balance.
4999 is not a failure. It means the customer has not entered their PIN yet. Keep polling, and never re-collect: a second push fires a second prompt and can double-charge. Every code is in the error reference.
Verify it yourself
If you are not using the SDK, the signature is an HMAC-SHA256 over t + "." + rawBody, keyed by the endpoint's signing secret, with a 5-minute freshness window. Compute it over the raw bytes, never a re-serialised object. The scheme is specified in the webhooks reference.
Next
Test in sandbox forces each of these outcomes on demand.