Skip to content

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 ​

typeWhendata
charge.succeededThe 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.failedThe 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.expiredA charge nobody paid within its window.charge, amount_minor, net_minor, fee_minor, currency, rail, customer_ref
charge.refundedMoney 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_failedA refund over M-Pesa could not be paid. The amount is back in your balance.charge, refund, amount_minor, currency, route
escrow.fundedA 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.releasedBoth sides agreed; the hold is now your balance.escrow, amount_minor, currency, title, released_by
subscription.cycle_createdA subscription period's bill was issued.subscription, plan, customer, period_start, period_end, amount_minor, currency, bill ({id, control_number, outstanding_minor})
subscription.payment_succeededMoney 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_failedThe 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/trigger on 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>".

  1. Read PamoPay-Signature, split on , to get t and v1.
  2. Compute HMAC_SHA256(secret, t + "." + rawBody) over the raw request bytes, not a re-serialised object.
  3. Compare to v1 in constant time.
  4. Refuse if t is more than five minutes from your clock — the timestamp is inside the signed string so a captured body cannot be replayed later.
  5. Refuse with a 4xx — 400 or 401 — and do nothing else. Not a 500: 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 "", 200
php
<?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 2xx within 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 2xx we never read looks exactly like a timeout. Deduplicate on id (ev_…); never assume charge.succeeded arrives before you read processing from 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-Attempt says which one this is. After seven the event is exhausted and stays readable at GET /v1/events, where you can ask for it again.
  • A 404 or 401 from you is retried like a 500 — both are usually a deploy in progress.
  • The data payload 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.

Every code, scope and route on this site is rendered from the API's own source.