Skip to content

Verifying signatures

Anyone can post to your endpoint. The signature is what proves an event came from Paydot, so verify it before you act on the body.

Each request carries:

Paydot-Event-Id: evt_9Nq1cQ2rTt6xKz0vB4mH7w
Paydot-Delivery-Id: dlv_2Vb8pL4kFj9sN3
Paydot-Timestamp: 1786298432
Paydot-Signature: v1=6f3a...c21b

Compute HMAC-SHA256 over <timestamp>.<raw request body> using your signing secret, then compare in constant time.

import crypto from "node:crypto";
app.post("/paydot",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.get("Paydot-Timestamp");
const signed = `${timestamp}.${req.body.toString("utf8")}`;
const expected = "v1=" + crypto
.createHmac("sha256", process.env.PAYDOT_WEBHOOK_SECRET)
.update(signed)
.digest("hex");
const offered = (req.get("Paydot-Signature") ?? "").split(",");
const ok = offered.some((candidate) => {
const a = Buffer.from(candidate.trim());
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(401);
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(401);
enqueue(JSON.parse(req.body.toString("utf8")));
res.sendStatus(202);
});

express.raw matters. With express.json the body is already parsed, and the signature cannot be reproduced.

Reject timestamps more than five minutes old, as every example above does. Without that check, a captured request stays valid forever. A signature alone does not tell you when it was issued.

Two things to get right if you do:

  • Keep your server clock synchronised, over NTP or your platform’s equivalent. The check compares our timestamp against your clock, so drift rejects perfectly valid events and the failure looks like a signature bug.
  • Never use a tolerance of zero. That disables the recency check entirely rather than tightening it.