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.
Poll
Webhook
You host
Nothing
An HTTPS endpoint
Good for
Scripts, a checkout with a spinner
Servers, 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 throwsPaylodTimeoutError. 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.
# There is no wait endpoint. Poll GET /status/:id until status leaves "pending".curl -s https://paylod.dev/functions/v1/status/$PAYMENT_ID \ -H "Authorization: Bearer mp_test_YOUR_API_KEY"# → { "id": "…", "status": "pending", "mpesaReceipt": null, "resultCode": null, … }# → { "id": "…", "status": "success", "mpesaReceipt": "UG1F3A1U7J", "resultCode": 0, … }# Back off between reads. Result code 4999 means the customer has not answered yet.
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); }});
# paylod calls YOUR endpoint. This is the delivery your handler must verify.POST /webhooks/paylod HTTP/1.1Content-Type: application/jsonx-webhook-signature: t=1782000000,v1=5d41402abc4b2a76b9719d911017c592x-webhook-id: evt_9c2f1a70x-webhook-event: payment.success{ "type": "payment.success", "created": 1782000000, "data": { "paymentId": "…", "mpesaReceipt": "UG1F3A1U7J", "amount": 1500, … } }# Verify: HMAC-SHA256 over t + "." + rawBody, keyed with the endpoint secret.# Compare against v1 in constant time. Reject a t older than 5 minutes.
use Paylod\Paylod;$paylod = new Paylod();// Read the RAW bytes. A body that you decoded and encoded again cannot verify.$raw = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null;// parseWebhook() verifies the signature, and throws if the signature is wrong.$event = $paylod->parseWebhook($raw, $signature);if ($event['type'] === 'payment.success') { fulfil($event['data']['paymentId'], $event['data']['mpesaReceipt']);} elseif ($event['data']['decoded']) { notify($event['data']['decoded']['customerMessage']);}
from flask import Flask, requestfrom paylod import Paylod, PaylodSignatureVerificationErrorapp = Flask(__name__)paylod = Paylod()@app.post("/webhooks/paylod")def paylod_webhook(): # Read the RAW bytes. A body that you decoded and encoded again cannot verify. try: event = paylod.parse_webhook_event( request.get_data(), request.headers.get("x-webhook-signature"), ) except PaylodSignatureVerificationError: return "", 400 if event.type == "payment.success": fulfil(event.data.payment_id, event.data.mpesa_receipt) elif event.data.decoded: notify(event.data.decoded.customer_message) return "", 200
import dev.paylod.Paylod;import dev.paylod.WebhookEvent;import dev.paylod.WebhookEventType;@RestControllerpublic class PaylodWebhookController { private final Paylod paylod = new Paylod(); // Take the body as a String, so the RAW bytes reach the verifier unchanged. @PostMapping("/webhooks/paylod") public ResponseEntity<Void> handle( @RequestBody String rawBody, @RequestHeader("x-webhook-signature") String signature) { WebhookEvent event = paylod.parseWebhook(rawBody, signature); if (event.getType() == WebhookEventType.PAYMENT_SUCCESS) { fulfil(event.getData().getPaymentId(), event.getData().getMpesaReceipt()); } else if (event.getData().getDecoded() != null) { notify(event.getData().getDecoded().getCustomerMessage()); } return ResponseEntity.ok().build(); }}
import dev.paylod.Paylodimport dev.paylod.WebhookEventTypeval paylod = Paylod()routing { post("/webhooks/paylod") { // Read the RAW text. A body that you decoded and encoded again cannot verify. val raw = call.receiveText() val signature = call.request.headers["x-webhook-signature"] val event = paylod.parseWebhook(raw, signature) if (event.type == WebhookEventType.PAYMENT_SUCCESS) { fulfil(event.data.paymentId, event.data.mpesaReceipt) } else if (event.data.decoded != null) { notify(event.data.decoded.customerMessage) } call.respond(HttpStatusCode.OK) }}
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 }});
# The settled amount rides on the event body. Compare it with YOUR order total.{ "type": "payment.success", "data": { "paymentId": "e69e5c00-8a01-44ed-b003-48e2e86a7c9e", "amount": 1500, ← the TRUE settled amount "mpesaReceipt": "UG1F3A1U7J", "accountRef": "INV-2041" }}# A customer can pay less than the order total. Never fulfil on "status" alone.
$event = $paylod->parseWebhook($raw, $signature);if ($event['type'] !== 'payment.success') { return;}// Look the order up by paymentId — the id you stored when you called collect().$order = $db->orders->findByPaymentId($event['data']['paymentId']);if (!$order) { return;}if ($event['data']['amount'] === $order->amountKes) { fulfil($order);} else { flagForReview($order, $event['data']); // underpaid}
event = paylod.parse_webhook_event(raw_body, signature)if event.type != "payment.success": return# Look the order up by payment_id — the id you stored when you called collect().order = db.orders.find_by_payment_id(event.data.payment_id)if order is None: returnif event.data.amount == order.amount_kes: fulfil(order)else: flag_for_review(order, event.data) # underpaid
WebhookEvent event = paylod.parseWebhook(rawBody, signature);if (event.getType() != WebhookEventType.PAYMENT_SUCCESS) { return;}// Look the order up by paymentId — the id you stored when you called collect().Order order = db.orders().findByPaymentId(event.getData().getPaymentId());if (order == null) { return;}// amount is nullable on the event. Test for null before you compare.Integer amount = event.getData().getAmount();if (amount != null && amount == order.amountKes()) { fulfil(order);} else { flagForReview(order, event.getData()); // underpaid}
val event = paylod.parseWebhook(raw, signature)if (event.type != WebhookEventType.PAYMENT_SUCCESS) return// Look the order up by paymentId — the id you stored when you called collect().val order = db.orders.findByPaymentId(event.data.paymentId) ?: returnif (event.data.amount == order.amountKes) { fulfil(order)} else { 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 });}
# A retry of a FAILED payment is a new attempt. Send a NEW Idempotency-Key.curl -X POST https://paylod.dev/functions/v1/collect \ -H "Authorization: Bearer mp_test_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: att_b7d3e914" \ -d '{ "amount": 1500, "phone": "254712345678" }'# Send the OLD key here, and paylod replays the failure. The customer pays nothing.
if (!$outcome->paid && $outcome->retryable) { $retry = $db->attempts->create(['orderId' => $order->id]); // new attempt → new key $paylod->collectAndWait([ 'amount' => $amount, 'phone' => $phone, 'idempotencyKey' => $retry->id, ]);}
if not outcome.paid and outcome.retryable: retry = db.attempts.create(order_id=order.id) # new attempt → new key paylod.collect_and_wait( amount=amount, phone=phone, idempotency_key=retry.id, )
if (!outcome.isPaid() && outcome.isRetryable()) { Attempt retry = db.attempts().create(order.id()); // new attempt → new key paylod.collectAndWait( CollectParams.builder(phone, amount) .idempotencyKey(retry.id()) .build());}
if (!outcome.paid && outcome.retryable) { val retry = db.attempts.create(orderId = order.id) // new attempt → new key paylod.collectAndWait( phone = phone, amount = amount, 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.