Guides
Secure your M-Pesa integration
Six rules that make it impossible for a payer with an intercepting proxy to undercut you, and why the amount must stay on your server.
The question everyone asks
Can a customer open my checkout in an intercepting proxy, for example Burp or mitmproxy, and decrease the amount before the request reaches M-Pesa?
In a correct integration, no.
The API key that calls /collect authenticates you, the merchant. The API key does not authenticate the payer. Only amount and phone come from the request body. paylod reads your shortcode, your passkey and your Daraja credentials from encrypted storage on the server. These values never go over the network. The settlement uses the amount that your server sent. paylod signs every webhook with HMAC, and every webhook reports the true settled amount.
Only one method lets a payer pay you less. That method gives the payer control of both the key and the amount. This occurs when you call /collect directly from a browser or a mobile app with an amount from the client. A payer can then change the request and pay less. paylod correctly reports the true, lower amount. But paylod cannot correct a merchant who gave control of the amount to the client.
Keep the amount on your server, and this attack is not possible.
The short version
| Do | Don't |
|---|---|
Keep mp_live_… and mp_test_… on the server only | Put the API key in a browser, a mobile app, or another client |
Take amount from your own order record | Send an amount from the client directly to /collect |
Verify x-webhook-signature before you read the body | Trust an unverified webhook |
Confirm that amount matches the order and that status === "success" before you fulfil | Fulfil an order on status alone |
Send one Idempotency-Key for each payment attempt | Use an order id or a product id as the key, or generate a new key on each call |
Expect a 429 and back off | Continue to send requests at the rate limit |
1. Keys are server-only secrets
Never put mp_live_… or mp_test_… into a browser, a mobile app, or another client. Keys stay only on your server. A key in a client is fully compromised, and you must think of such a key as public. Each person who holds the key can use your account.
2. Your server owns the amount
Your server is the only authority on the amount. Take the amount from your own order record or price record. Never send an amount from the client directly to /collect. If the client holds the key and also sets the amount, a payer can decrease the amount. This is the only method that lets a payer pay you less.
3. Verify, then check the amount, before you fulfil
In your webhook handler, verify the signature first. Then confirm the amount and the status before you fulfil the order. paylod.webhookHandler() verifies the signature for you. Thus your handler runs only on an authentic event.
import { Paylod } from "@paylod/node";
const paylod = new Paylod(process.env.PAYLOD_API_KEY!);
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;
// Never fulfil on status alone. Check the TRUE settled amount.
if (event.data.amount === order.amountKes) {
await fulfil(order); // paid in full
} else {
await flagForReview(order, event.data); // underpaid / mismatch
}
});The webhook always carries the true settled amount. Thus you see a partial payment as soon as you check the amount.
Store ack.paymentId with your order when you call collect(). Then use ack.paymentId for the lookup. The event carries paymentId, accountRef and the M-Pesa fields. The event does not return the metadata that you sent to collect(). Thus do not use the metadata in your fulfilment code.
Make your handler idempotent. paylod sends a delivery again until you return 2xx. A second copy of an event that you already handled is normal, and is not an error.
4. Treat callback and mint tokens as secrets
The callback URL and the mint token are secrets. Do not write them into a log. Do not send them to a client. Keep them on your server with the API key.
5. One idempotency key per payment attempt
Create one Idempotency-Key for each payment attempt. Do not create one key for each order, and never create one key for each product. A double-click and a job that runs again are duplicates of one attempt. Duplicates of one attempt then become a single charge, also under concurrency. If you use a key again with a different body, paylod returns 409.
A key is spent after you use it. Thus an order id or a product id is the wrong choice: a key that you use again replays the original payment, and does not make a new payment. A retry after a wrong M-Pesa PIN is a new charge, and needs a new key.
A request can stop while the call to the provider is in flight. That key then returns 409 indeterminate, and paylod never sends the call again on its own. The outcome of such a payment is unknown, and a timeout is not proof that the money did not move. Do not retry the same key. First read the payment status. Then retry with a new key if no payment occurred. For money, at-most-once is better than at-least-once. See Idempotency.
6. Expect rate limits
paylod applies a rate limit to /collect for each API key and for each phone number. Expect 429 Too Many Requests during a burst of requests, and back off. paylod refuses a 429 request before the request reaches Daraja. Thus a 429 does not spend the key, and a retry with the same Idempotency-Key after a short delay is safe.
Obey these six rules. Then a payer with an intercepting proxy cannot pay you less, and cannot charge you more. That payer cannot send the money to a different account. That payer also cannot make you fulfil an order that the payer did not pay in full. The platform is safe by design. The platform only needs the amount to stay on your server.