# Secure 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

*Could a customer open my checkout in an intercepting proxy — Burp, mitmproxy — and lower the amount before it reaches M-Pesa?*

Under a correct integration, **no**.

The API key that calls `/collect` authenticates **you**, the merchant — not the payer. Only `amount` and `phone` come from the request body; your shortcode, passkey and Daraja credentials are pulled from encrypted storage server-side and never travel over the wire. Settlement is bound to the amount your server initiated, and every webhook is HMAC-signed and reports the **true settled amount**.

There is exactly one way to be undercut: hand the payer control of **both** the key and the amount — i.e. call `/collect` straight from a browser or mobile app with a client-supplied amount. Then a payer can edit the request and pay less. The platform will faithfully report the true, lower amount, but it cannot rescue a merchant who trusted the client with it.

Keep the amount on your server and the attack disappears.

## The short version

| Do | Don't |
| --- | --- |
| Keep `mp_live_…` / `mp_test_…` server-side only | Embed the API key in a browser, mobile app, or any client |
| Derive `amount` from your own order record | Forward a client-supplied amount verbatim to `/collect` |
| Verify `x-webhook-signature` before reading the body | Trust an unverified webhook |
| Confirm `amount` matches the order **and** `status === "success"` before fulfilling | Fulfil on `status` alone |
| Send one `Idempotency-Key` per logical order | Retry a logical order with a fresh key |
| Expect `429`s and back off | Hammer on rate limits |

## 1. Keys are server-only secrets

Never expose `mp_live_…` or `mp_test_…` in a browser, mobile app, or any client. Keys live only on your server. A client-embedded key is fully compromised — treat it as public. Anyone holding it can call your account.

## 2. Your server owns the amount

Your server is the sole authority on the amount. Derive it from your own order or price record; never pass a client-supplied amount straight through to `/collect`. If the client both holds the key and sets the amount, a payer can lower it — that is the one and only way to be undercut.

## 3. Verify, then check the amount, before fulfilling

On the webhook: verify the signature **first**, then confirm the amount and the status **before** fulfilling. `paylod.webhookHandler()` does the verification for you, so your handler only runs on an authentic event.

```ts title="fulfil.ts"
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, so any partial or underpayment is visible the moment you check it.

Store `ack.paymentId` against your order when you call `collect()`, and key the lookup on it. The event carries `paymentId`, `accountRef` and the M-Pesa fields. It does **not** echo back the `metadata` you sent to `collect()`, so do not build your fulfilment around that.

Make the handler **idempotent**. Deliveries are retried until you return `2xx`, and a repeat of an event you have already handled is normal rather than an error.

## 4. Treat callback and mint tokens as secrets

The callback URL and the mint token are secrets. Don't log them, don't expose them client-side — they belong on your server, alongside the API key.

## 5. One idempotency key per order

Send an `Idempotency-Key` per logical order so retries never double-charge. Reusing a key with a **different** body is rejected with `409` — a guard against accidental key reuse across orders.

## 6. Expect rate limits

`/collect` is rate-limited per API key and per phone number. Expect `429 Too Many Requests` under bursts and back off — retry with the same `Idempotency-Key` after a short delay.

---

Follow these six rules and a payer wielding an intercepting proxy cannot undercut you, over-charge you, redirect funds, or trick you into fulfilling an order that was not paid in full. The platform is safe by design — it just needs the amount to stay on your server.
