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_9Nq1cQ2rTt6xKz0vB4mH7wPaydot-Delivery-Id: dlv_2Vb8pL4kFj9sN3Paydot-Timestamp: 1786298432Paydot-Signature: v1=6f3a...c21bCompute HMAC-SHA256 over <timestamp>.<raw request body> using your signing secret, then compare in
constant time.
Examples
Section titled “Examples”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.
import hashlib, hmac, os, timefrom flask import request, abort
@app.post("/paydot")def paydot_webhook(): timestamp = request.headers.get("Paydot-Timestamp", "") signed = f"{timestamp}.".encode() + request.get_data() expected = "v1=" + hmac.new( os.environ["PAYDOT_WEBHOOK_SECRET"].encode(), signed, hashlib.sha256 ).hexdigest()
offered = request.headers.get("Paydot-Signature", "").split(",") if not any(hmac.compare_digest(c.strip(), expected) for c in offered): abort(401) if abs(time.time() - int(timestamp)) > 300: abort(401)
enqueue(request.get_json()) return "", 202request.get_data() returns the raw body. Calling request.get_json() first does not consume it, but
build the signature from the raw bytes regardless.
<?php$body = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_PAYDOT_TIMESTAMP'] ?? '';$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('PAYDOT_WEBHOOK_SECRET'));
$ok = false;foreach (explode(',', $_SERVER['HTTP_PAYDOT_SIGNATURE'] ?? '') as $candidate) { if (hash_equals($expected, trim($candidate))) { $ok = true; }}if (!$ok || abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit;}
enqueue(json_decode($body, true));http_response_code(202);@PostMapping(value = "/paydot", consumes = "application/json")ResponseEntity<Void> paydot( @RequestHeader("Paydot-Timestamp") String timestamp, @RequestHeader("Paydot-Signature") String signatureHeader, @RequestBody byte[] body) throws Exception {
var mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(webhookSecret.getBytes(UTF_8), "HmacSHA256")); var signed = (timestamp + "." + new String(body, UTF_8)).getBytes(UTF_8); var expected = "v1=" + HexFormat.of().formatHex(mac.doFinal(signed));
var offered = signatureHeader.split(","); var ok = Arrays.stream(offered).anyMatch(candidate -> MessageDigest.isEqual(expected.getBytes(UTF_8), candidate.trim().getBytes(UTF_8))); if (!ok) return ResponseEntity.status(401).build();
var age = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)); if (age > 300) return ResponseEntity.status(401).build();
enqueue.accept(new String(body, UTF_8)); return ResponseEntity.status(202).build();}@RequestBody byte[] matters. Binding to a POJO lets Spring deserialize and re-encode the JSON, which
changes the bytes the signature was computed over.
Replay protection
Section titled “Replay protection”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.