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/json
  • t — 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

  1. Read the raw request body before parsing JSON. Re-serializing changes whitespace and breaks the signature.
  2. Reject deliveries where |now - t| > 300 seconds (5-minute tolerance).
  3. Compute HMAC-SHA256(secret, t + "." + rawBody).
  4. Compare against v1 using 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 "", 204

Returning a response

  • Reply with 2xx within 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.