Verifying Webhooks
Validate incoming EzPays webhook signatures.
EzPays signs every outbound webhook so you can confirm it really came from us and that the body wasn't tampered with.
The signature header
Each delivery includes:
EzPays-Signature: t=1746450123,v1=4f0c8e9a3b...d2
EzPays-Event: payment_link.completed
EzPays-Delivery-Id: del_2g8f...
Content-Type: application/jsont— Unix epoch seconds at which we signed the payload.v1— hex-encoded HMAC-SHA256 of<t>.<raw body>keyed with your endpoint's signing secret.
Verification algorithm
- Read the raw request body before parsing JSON. Re-serializing changes whitespace and breaks the signature.
- Reject deliveries where
|now - t| > 300seconds (5-minute tolerance). - Compute
HMAC-SHA256(secret, t + "." + rawBody). - Compare against
v1using a constant-time comparator.
The signing secret begins with whsec_ and is shown only once when you create the endpoint.
Node.js (Express)
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
const SECRET = process.env.EZPAYS_WEBHOOK_SECRET!; // whsec_...
export function verifyEzpays(req: Request, res: Response, next: NextFunction) {
const header = req.header('EzPays-Signature') ?? '';
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return res.status(400).send('bad signature');
// 5-minute tolerance.
if (Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send('stale');
}
// `req.rawBody` must be the unparsed Buffer. With Express, configure:
// app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }));
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${(req as any).rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('signature mismatch');
}
next();
}Python (Flask)
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = b"whsec_..." # load from env
app = Flask(__name__)
@app.post("/webhooks/ezpays")
def receive():
sig = request.headers.get("EzPays-Signature", "")
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
abort(400)
if abs(time.time() - t) > 300:
abort(400)
raw = request.get_data() # bytes, before any parsing
expected = hmac.new(
SECRET, f"{t}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(400)
event = request.get_json()
# ... handle event["type"], event["data"] ...
return "", 204Returning a response
- Reply with
2xxwithin 10 seconds to acknowledge receipt. - Any non-2xx (or timeout) triggers retries with exponential backoff at
1m, 5m, 30m, 2h, 12h, 24h— up to 6 attempts over ~30 hours. - After all retries fail, the endpoint is marked disabled and an alert is surfaced in the dashboard.
Replay protection
The EzPays-Delivery-Id header is unique per delivery. If you need exactly-once processing, persist this ID alongside the event and skip duplicates.