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

typeWhendata
charge.succeededThe 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.expiredA charge nobody paid within its window.charge, amount_minor, net_minor, fee_minor, currency, rail, customer_ref
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

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>".

  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.

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 "", 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 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.

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