04Webhooks

Events, delivered.

Receive HTTP POST callbacks the moment a transaction reaches a terminal state — instead of polling GET /api/v1/transactions/:id in a loop.

Events

  • transaction.completed — deposit credited or withdrawal paid out
  • transaction.failed — terminal failure
  • transaction.cancelled — user cancelled M-Pesa STK

Headers we send

HeaderValue
Content-Typeapplication/json
User-AgentDeripay-Webhook/1.0
X-Deripay-Eventtransaction.completed | transaction.failed | transaction.cancelled
X-Deripay-Signaturet=<unix-secs>,v1=<hex-hmac>

Payload

Example for a completed deposit. The data object is the same shape returned by GET /api/v1/transactions/:id — see the API reference for the full field list (statusMessage, mpesa.checkoutRequestId, paymentAgent, createdAt, updatedAt are all included).

json
{
  "event": "transaction.completed",
  "transactionId": "deripay-tx-id",
  "developerId": "your-developer-id",
  "occurredAt": "2026-05-09T12:01:23.456Z",
  "data": {
    "id": "deripay-tx-id",
    "type": "DEPOSIT",
    "status": "completed",
    "rawStatus": "DEPOSIT_COMPLETED",
    "amounts": { "usd": 10, "kes": 1325, "rate": 132.5 },
    "mpesa": { "receiptNumber": "QXX...", "checkoutRequestId": "ws_CO_..." }
  }
}

occurredAt is always ISO-8601 UTC.

URL requirements

  • HTTPS in production. We'll reject http:// URLs at registration time outside of local development.
  • Public hostname only. URLs pointing at localhost, 127.x, RFC1918 ranges (10.x, 192.168.x, 172.16–31.x), link-local 169.254.x, or .local / .internal hostnames are blocked to prevent SSRF.
  • Must respond 2xx within 10 seconds. Anything else (timeout, 4xx, 5xx, connection error) is recorded as a failed delivery.

Where to find your secret

Each webhook has its own secret — generated when you create it. Open Webhooks in the portal, find the row, and copy the secret next to the URL. Store it on your server (e.g. DERIPAY_WEBHOOK_SECRET). Different webhooks have different secrets; if you register two endpoints, each one gets its own.

Signature verification

The X-Deripay-Signature header is t=<unix-secs>,v1=<hex-hmac>. Recompute HMAC-SHA256(secret, `$${t}.$${rawBody}`) and timing-safe-compare to v1. You must verify against the exact raw request body — re-stringifying parsed JSON will not produce the same bytes and the signature will fail.

Always reject signatures older than 5 minutes — the timestamp is part of what we sign for exactly this reason. Without the freshness check, a leaked request body + signature can be replayed indefinitely.

Node.js verification

javascript
import crypto from "node:crypto";

const TOLERANCE_SECS = 300; // 5 minutes — reject anything older

export function verifyDeripaySignature(rawBody, header, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Replay protection — refuse stale signatures
  const ageSecs = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(ageSecs) || ageSecs > TOLERANCE_SECS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  // Constant-time compare on equal-length hex buffers
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express handler — note express.raw({ type: "*/*" }) so req.body is a Buffer
app.post("/deripay/webhook", express.raw({ type: "*/*" }), (req, res) => {
  const ok = verifyDeripaySignature(
    req.body.toString("utf8"),
    req.headers["x-deripay-signature"],
    process.env.DERIPAY_WEBHOOK_SECRET,
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  // ... handle event idempotently — see "Idempotency" below ...
  res.sendStatus(200);
});

Python verification

python
import hashlib
import hmac
import time

TOLERANCE_SECS = 300  # 5 minutes

def verify_deripay_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
    except ValueError:
        return False
    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        return False

    # Replay protection
    try:
        age = abs(int(time.time()) - int(t))
    except ValueError:
        return False
    if age > TOLERANCE_SECS:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)

Idempotency

Network blips can cause the same event to be observed more than once by your handler. The same transactionId + event pair will only ever describe the same underlying state transition, so dedupe on (transactionId, event)in your DB and short-circuit if you've already processed it. Crediting twice on a duplicate delivery is the most common integration bug — guard against it from day one.

Reliability

  • Deliveries are fire-and-forget on terminal transition — we don't block the API response on your endpoint.
  • No retry queue yet. If your endpoint is down or times out, the delivery is recorded as failed and not retried automatically.
  • After 10 consecutive failures the webhook auto-disables. Re-enable it by deleting and recreating the row in the portal.
  • Every attempt — successful or not — is recorded in your Webhooks page with the response status code and a truncated body, so you can debug without poking around in our logs.

Polling fallback

Until retries land, treat webhooks as a fast path rather than the only source of truth. Anything you absolutely must reconcile (deposits crediting users, withdrawals being marked paid) should also have a slow-path job that polls GET /api/v1/transactions/:idfor any transaction that hasn't reached a terminal status after, say, 2 minutes. The webhook gives you instant UX; the poller gives you durability.