Webhooks
When something happens that you did not cause in the same request, PamoPay POSTs a signed JSON event to the endpoint registered for that mode — on the Webhooks page, or over the API with POST /v1/webhook_endpoints. One endpoint per mode, https only.
What you receive
POST https://your.server/pamopay
Content-Type: application/json
PamoPay-Signature: t=1758013442,v1=5257a869e7…
PamoPay-Event: ev_9f3b2c1d4e5f6a7b8c9d0e1f
PamoPay-Attempt: 1
User-Agent: PamoPay/1 (+https://docs.pamopay.co/webhooks)json
{
"id": "ev_9f3b2c1d4e5f6a7b8c9d0e1f",
"object": "event",
"type": "charge.succeeded",
"created": 1758013442,
"livemode": true,
"data": {
"charge": "ch_01HZX…",
"amount_minor": 4650000,
"net_minor": 4557000,
"fee_minor": 93000,
"currency": "TZS",
"rail": "mpesa",
"method": "push",
"customer_ref": "STU-2026-0412",
"till": null,
"bill": null
}
}Event types
type | When | data |
|---|---|---|
charge.succeeded | The payer paid and your balance moved. The event to act on. | charge, amount_minor, net_minor, fee_minor, currency, rail (mpesa…, tanqr for a bank-app code, or wallet), method (push, tanqr, or null when nobody was asked — a counter code, an in-app payment), customer_ref, payer ({name, phone, email} the buyer typed on a payment link that asked, or null), till (POS/in-app only), bill ({id, control_number, outstanding_minor} or null) |
charge.failed | The payer declined the prompt, or had no float. Nothing moved. | the charge.succeeded fields plus failure_code (PAYER_DECLINED, INSUFFICIENT_FUNDS, or the rail's own word) |
charge.expired | A charge nobody paid within its window. | charge, amount_minor, net_minor, fee_minor, currency, rail, customer_ref |
charge.refunded | Money given back reached the payer — at once for a wallet payment, when it has been paid out for M-Pesa. Never sent on the ask. | charge, refund, amount_minor, currency, route (wallet or rail) |
charge.refund_failed | A refund over M-Pesa could not be paid. The amount is back in your balance. | charge, refund, amount_minor, currency, route |
escrow.funded | A buyer has paid into an escrow you are the seller of. The signal to hand over the goods. | escrow, amount_minor, currency, title, deadline_at |
escrow.released | Both sides agreed; the hold is now your balance. | escrow, amount_minor, currency, title, released_by |
subscription.cycle_created | A subscription period's bill was issued. | subscription, plan, customer, period_start, period_end, amount_minor, currency, bill ({id, control_number, outstanding_minor}) |
subscription.payment_succeeded | Money landed against a period's bill. | subscription, plan, customer, period_start, amount_minor, currency, paid (whether it cleared the period — a part payment is ordinary), bill |
subscription.payment_failed | The grace you set ran out with the period still owed. Not a rail refusing — nothing debits anybody, so the only way this fails is that nobody paid. | subscription, plan, customer, period_start, outstanding_minor, currency, bill |
There is deliberately no charge.created (you made it and hold the reply), no subscription.created (the same reason) and no escrow.refunded (a refund is decided over your objection and you hear it from a person, not from a hook your system might act on). A payment against a bill is charge.succeeded with bill filled in — not a separate bill.paid. settlement.paid is declared and not emitted yet.
Test mode sends real events
A test charge that resolves — approved, declined, no float — produces the same event a live one would, delivered to the endpoint registered for test mode with livemode: false, within a minute of the payer "answering". The never-resolving trigger produces nothing, because there is nothing to announce.
Rehearsing without a payment
Two ways to make your endpoint receive a signed event right now, with nothing behind it:
- The dashboard. Developers → Send a test event: pick a type, edit the example body if you want, press send. What your server answered and how long it took is printed on the spot, and the event appears in What we sent marked as a rehearsal. A rehearsal your server refused is retried on the schedule below, exactly like a real one.
- The API.
POST /v1/test/webhooks/triggeron the sandbox, with a test key — see the sandbox page. The response is the whole record; nothing is stored or retried.
Both send only to the endpoint you registered. In GET /v1/events and on the dashboard a rehearsal carries synthetic: true, set once and never changed, so nothing downstream can mistake one for money.
Every delivery now records delivery.last_latency_ms — how long your server took to answer — beside the status. A server that answers in nine seconds is one that will soon be counted as a miss.
Verifying the signature
The signature is HMAC-SHA256 with your endpoint secret (whsec_…, from the Webhooks page) over the string "<t>.<raw body>".
- Read
PamoPay-Signature, split on,to gettandv1. - Compute
HMAC_SHA256(secret, t + "." + rawBody)over the raw request bytes, not a re-serialised object. - Compare to
v1in constant time. - Refuse if
tis more than five minutes from your clock — the timestamp is inside the signed string so a captured body cannot be replayed later. - Refuse with a
4xx—400or401— and do nothing else. Not a500: a 5xx is what a server that is down looks like, so we retry it, and a verification that throws is indistinguishable from an outage.
Your first live key waits on step 5. The Webhooks page has a Send a forged event button: the same test event, signed with a secret that is not yours. Until your test endpoint has answered one with a 4xx — and answered a real test event with a 2xx, and a test charge has succeeded — the dashboard will not make your first live key. An endpoint that accepts anything is one anybody can tell a payment succeeded. Once you have held a live key this is never asked again, and replacing a key never waits on it.
The secret is derived, not stored, so unlike an API key you can read it again on the Webhooks page whenever you need it. It changes only when you point the endpoint at a new URL.
js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: capture the raw body before any JSON parser touches it.
app.post("/pamopay", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.get("PamoPay-Signature"), process.env.PAMOPAY_WHSEC)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// Deduplicate on event.id, then act on event.type.
res.sendStatus(200);
});python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask: request.get_data() is the raw body; request.get_json() is not.
@app.post("/pamopay")
def hook():
if not verify(request.get_data(), request.headers.get("PamoPay-Signature", ""), WHSEC):
abort(401)
event = request.get_json()
# Deduplicate on event["id"], then act on event["type"].
return "", 200php
<?php
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
// Laravel: $request->getContent() is the raw body; $request->json() is not.
$raw = file_get_contents('php://input');
if (!verify($raw, $_SERVER['HTTP_PAMOPAY_SIGNATURE'] ?? '', getenv('PAMOPAY_WHSEC'))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Deduplicate on $event['id'], then act on $event['type'].
http_response_code(200);Delivery rules — design against these
- Answer any
2xxwithin 10 seconds. Anything else, including a timeout, is a failure and will be retried. Do your work after responding, or keep it fast. - At-least-once and unordered. A
2xxwe never read looks exactly like a timeout. Deduplicate onid(ev_…); never assumecharge.succeededarrives before you readprocessingfrom a poll. - The retry schedule is a fixed, published table, no jitter: attempts at
0, +1 min, +5 min, +30 min, +2 h, +6 h, +24 h— seven attempts over about 33 hours.PamoPay-Attemptsays which one this is. After seven the event isexhaustedand stays readable atGET /v1/events, where you can ask for it again. - A
404or401from you is retried like a500— both are usually a deploy in progress. - The
datapayload is frozen at the moment the event happened. A retry hours later describes the world as it was then. - Changing the URL rotates the secret (the old server may not be yours any more); saving an unchanged URL does not.
The Webhooks page draws each event's remaining attempts as seven marks and quotes what your server answered, which is the line to search your own logs for.