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 outtransaction.failed— terminal failuretransaction.cancelled— user cancelled M-Pesa STK
Headers we send
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | Deripay-Webhook/1.0 |
| X-Deripay-Event | transaction.completed | transaction.failed | transaction.cancelled |
| X-Deripay-Signature | t=<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).
{
"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-local169.254.x, or.local/.internalhostnames are blocked to prevent SSRF. - Must respond
2xxwithin 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
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
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.