Skip to main content

Webhook Signature Verification

Every webhook carries an HMAC signature so you can prove it came from ZaroPay (and wasn't tampered with). Verify it before acting on any webhook.

The scheme

  • Algorithm: HMAC-SHA256, output as lowercase hex (Stripe-style).
  • Signing key: your endpoint's signing secret — the full whsec_… string, used verbatim as the UTF-8 key. Do not hex/base64-decode it; the whsec_ prefix is part of the key.
  • Signed message: `{t}.{body}` — the timestamp, a literal ., then the raw request body bytes exactly as received (no JSON re-serialization, no whitespace changes).
  • Header: x-zaropay-signature: t=<unixSeconds>,v1=<hex>
v1 = HMAC_SHA256(secret, "<t>.<rawBody>") → lowercase hex
header = "t=<t>,v1=<v1>"

Example header:

x-zaropay-signature: t=1719500000,v1=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b
Three rules that trip everyone up
  1. Use the raw body. Read the bytes/string before your framework parses JSON. If you verify against a re-serialized object, the signature will never match.
  2. Compare in constant time. Use a constant-time comparison (timingSafeEqual, hmac.compare_digest, hash_equals, …), never ==.
  3. The secret includes whsec_. Pass the whole string as the key.

Verify in your language

Each function takes the raw body, the x-zaropay-signature header value, and your whsec_… secret, and returns true only for an authentic, untampered request.

const crypto = require("crypto");

function verifyZaroPaySignature(rawBody, signatureHeader, secret) {
if (typeof rawBody !== "string" || !signatureHeader || !secret) return false;

const parts = {};
for (const kv of signatureHeader.split(",")) {
const i = kv.indexOf("=");
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
const { t, v1 } = parts;
if (!t || !v1) return false;

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

const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;

// Optional replay protection (ZaroPay does not enforce one):
// if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;

return crypto.timingSafeEqual(a, b);
}

Express receiver (note express.raw):

const express = require("express");
const app = express();

app.post("/webhooks/zaropay", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8"); // raw bytes → string
if (!verifyZaroPaySignature(raw, req.get("x-zaropay-signature"), process.env.ZAROPAY_WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(raw);
// dedupe on event.id, then handle event.event === "deposit.confirmed"
res.sendStatus(200);
});

Replay protection (optional)

ZaroPay does not enforce a freshness window — the t value is provided so you can add one if you want. If you do, reject deliveries where abs(now - t) exceeds your tolerance (e.g. 5 minutes). Each sample above shows the one-line check, commented out.

Testing your verifier

The signing is a plain HMAC, so you can generate a matching signature yourself to unit-test your receiver (Node example):

const crypto = require("crypto");
const secret = "whsec_test_secret";
const body = JSON.stringify({ id: "evt_1", event: "deposit.confirmed", data: {} });
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const header = `t=${t},v1=${v1}`;
// POST `body` to your endpoint with header `x-zaropay-signature: ${header}`

You can also trigger a real signed delivery from the dashboard, or via POST /v1/webhooks/endpoints/:id/test.

Checklist

  • Read the raw body before JSON parsing.
  • Parse t and v1 from x-zaropay-signature.
  • HMAC-SHA256 over `{t}.{rawBody}` with the full whsec_… secret.
  • Constant-time compare against v1.
  • Return 2xx only after verification passes.
  • Dedupe on the payload id.
  • (Optional) enforce a replay window on t.