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 Developers screen, 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",
"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… or wallet), customer_ref, till (POS/in-app only), bill ({id, control_number, outstanding_minor} or null) |
charge.expired | A charge nobody paid within its window. | charge, amount_minor, net_minor, fee_minor, currency, rail, customer_ref |
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 |
There is deliberately no charge.created (you made it and hold the reply) 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. charge.failed and settlement.paid are declared and not emitted yet.
Verifying the signature
The signature is HMAC-SHA256 with your endpoint secret (whsec_…, from the Developers screen) 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.
The secret is derived, not stored, so unlike an API key you can read it again on the Developers screen 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 Developers screen 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.