Verify webhook signatures

We sign every webhook with:
X-Webhook-Signature: t=<unix_ts>,v1=<base64sig>
  • Signature = a tamper-proof stamp made with your secret. If the body or timestamp changes, the stamp won’t match.
  • t (timestamp) = when we created the event (seconds since epoch).
  • v1 = the signature itself (base64 string).
Algorithm (v1): base64(HMAC_SHA256(secret, "${ts}.${raw_body}")) with a ±5 minute tolerance.
We sign the exact raw bytes you receive (no reformatting). Verify against the raw body, not a re-serialized JSON object.

What to validate (in order)

  1. Read raw body
    Use the raw bytes from the request. Don’t JSON stringify/pretty-print before verifying.
  2. Parse the header
    Expect t=<unix_ts>,v1=<base64sig>.
  3. Freshness (clock skew)
    Make sure the request is recent: abs(now - t) ≤ 300s (5 min).
    Why: prevents old/replayed requests from being accepted.
  4. Recompute the signature
    Rebuild it yourself with your secret:
    sig = base64(HMAC_SHA256(secret, ts + "." + raw_body)).
  5. Secure compare
    Compare your sig with the header’s v1 using a constant-time function (a “secure equals”).
    Why: avoids tiny timing differences that could leak info.
  6. Replay protection (nice to have)
    Keep a short-lived cache of X-Webhook-Id (or the (t,v1) pair). If you see the same one again within ~10 minutes, reject it.
    Why: blocks attackers from re-sending a previously valid request.
  7. Then parse JSON & ack fast
    s Once verified, parse JSON and return 2xx quickly. Do heavy work async; we retry on non-2xx.
You’ll also get headers
  • X-Webhook-Event: e.g. order.submitted, order.pending, order.settled, order.failed, order.refunded
  • X-Webhook-Id: unique request id (use for replay protection)
  • X-Webhook-Signature: the header above
Headers you’ll get
  • X-Webhook-Event: e.g. order.submitted, order.processing, order.settled, order.failed, order.refunded
  • X-Webhook-Id: unique id for idempotency
  • X-Webhook-Signature: the signature header above

Minimal receivers (drop-in)

Node (Express)

import express from "express";
import crypto from "crypto";

const app = express();
// capture raw body
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

function verifySignature(header, rawBody, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map(x => x.trim().split("=")));
  const ts = parseInt(parts.t, 10);
  if (!ts || Math.abs(Date.now()/1000 - ts) > 300) return false;
  const mac = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("base64");
  const v1 = parts.v1 || "";
  return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}

app.post("/webhooks/elementpay", (req, res) => {
  const ok = verifySignature(req.header("X-Webhook-Signature"), req.rawBody, process.env.WEBHOOK_SECRET);
  if (!ok) return res.status(401).json({ status: "error", message: "Invalid webhook signature", data: null });

  const event = req.header("X-Webhook-Event");
  const id = req.header("X-Webhook-Id");
  const payload = req.body;

  // TODO: check replay via cache on `id`
  // TODO: enqueue payload for async processing

  return res.status(200).json({ status: "success", message: "ok" });
});

app.listen(3000);

Python (FastAPI)

import time, hmac, hashlib, base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def verify_signature(header: str, raw: bytes, secret: str) -> bool:
  if not header: return False
  parts = dict(p.split("=",1) for p in header.split(",") if "=" in p)
  try:
    ts = int(parts.get("t","0"))
  except ValueError:
    return False
  if abs(int(time.time()) - ts) > 300:
    return False
  mac = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + raw, hashlib.sha256).digest()
  sig = base64.b64encode(mac).decode("utf-8")
  return hmac.compare_digest(sig, parts.get("v1",""))

@app.post("/webhooks/elementpay")
async def handle(request: Request):
  raw = await request.body()
  header = request.headers.get("X-Webhook-Signature")
  if not verify_signature(header, raw, secret=YOUR_SECRET):
    raise HTTPException(status_code=401, detail="Invalid webhook signature")

  event = request.headers.get("X-Webhook-Event")
  req_id = request.headers.get("X-Webhook-Id")
  payload = await request.json()

  # TODO: reject replays using req_id cache
  # TODO: enqueue payload for async processing

  return {"status": "success", "message": "ok"}

Sample payload (event body)

{
  "order_id": "ord_01J9TS1Q8ZQ7M3E6W9F3Z3YB2G",
  "invoice_id": "inv_123",
  "file_id": "file_456",
  "status": "settled",
  "reason": null,
  "amount_fiat": 1750,
  "currency": "KES",
  "amount_crypto": "12.34",
  "exchange_rate": 142.1234,
  "token": "USDC",
  "wallet_address": "0xA1b2...c3D4",
  "order_type": "OFFRAMP",
  "provider_id": "mpesa",
  "phone_number": "+2547XXXXXXX",
  "till_number": null,
  "paybill_number": null,
  "account_number": null,
  "creation_transaction_hash": "0xabc123...",
  "settlement_transaction_hash": "0xdef456...",
  "refund_transaction_hash": null,
  "transaction_hash": "0xdef456...",
  "mpesa_transaction_id": "OE123...",
  "mpesa_receipt_number": "QWE123...",
  "receiver_name": "John Doe",
  "transaction_time": "2025-08-15T12:34:56Z",
  "created_at": "2025-08-15T12:00:00Z",
  "updated_at": "2025-08-15T12:35:00Z"
}

Local testing

  • Best: use your own signer to hit your local receiver.
    • Node: compute v1 with crypto.createHmac("sha256", secret).update(ts.{ts}.).digest("base64").
    • Python: compute v1 with base64(hmac_sha256(secret, f"{ts}.{raw_body}")).
  • Or call our POST /webhooks/test to validate your signature logic (no secrets in the docs UI).

Common errors

ErrorWhyFix
Invalid webhook signatureComputed signature doesn’t matchUse raw bytes; ensure ts+"."+raw_body and base64 of HMAC-SHA256
Signature timestamp outside tolerance windowClock skew > 5 minSync server time; regenerate t
Malformed signature headerMissing t or v1Format: t=<unix_ts>,v1=<base64sig>
Works locally but fails in prodBody re-serializedDon’t JSON.stringify()/re-encode before verifying