How do I verify the webhook signature?
Short answer
Section titled “Short answer”Every delivery carries the header X-Webhook-Signature: sha256=<hexdigest>. The
value is an HMAC-SHA256 over the raw request body, keyed with your webhook’s
signing secret. To verify it, compute the same HMAC on your side and compare it in
constant time. If the values don’t match: discard the request — it did not come from
Notory (unmodified).
Prerequisites
Section titled “Prerequisites”Important: the HMAC is computed over the unmodified body bytes. So verify before JSON parsing, i.e., on the raw body — re-serialized JSON almost never has the same bytes.
Implementing verification
Section titled “Implementing verification”-
Store the secret. Save the secret shown at creation time in your receiver system’s secret store (environment variable, Vault — not in code).
-
Add the verification logic. In your endpoint, implement: read the raw body → compute HMAC-SHA256 with the secret → compare it in constant time against the
X-Webhook-Signatureheader (after thesha256=prefix) → reject with401on a mismatch. -
Verify with “Send test.” Trigger a test delivery in Notory (
pingevent). Only once your endpoint accepts the signature and returns 2xx should you start accepting production events.
Reference implementations:
import hashlib, hmac, os
SECRET = os.environ["NOTORY_WEBHOOK_SECRET"].encode()
@app.post("/hooks/notory")def notory_hook(): raw = request.get_data() # raw body (bytes!) header = request.headers.get("X-Webhook-Signature", "") expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(header, expected): # constant-time comparison return "invalid signature", 401 payload = json.loads(raw) … return "", 204import crypto from "node:crypto";import express from "express";
const app = express();const SECRET = process.env.NOTORY_WEBHOOK_SECRET;
// Important: capture the raw body before parsing JSON.app.post("/hooks/notory", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const given = req.get("X-Webhook-Signature") ?? ""; const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); if (!ok) return res.status(401).send("invalid signature");
const payload = JSON.parse(req.body); // … res.sendStatus(204);});For manual recomputation (e.g., when troubleshooting):
printf '%s' '{"event": "ping", "data": {"message": "test delivery"}}' \ | openssl dgst -sha256 -hmac "dein_signatur_secret"# → must match the header value after "sha256="What happens behind the scenes?
Section titled “What happens behind the scenes?”- Signature generation in Notory. Before sending, Notory serializes the envelope
{"event": …, "data": …}to JSON and computesHMAC_SHA256(secret, body)as a hex digest. The header is exactlyX-Webhook-Signature: sha256=<hexdigest>; in addition,X-Webhook-Eventnames the event. - What the check guarantees: authenticity (only someone who knows the secret can sign validly) and integrity (any change to the body invalidates the signature). It does not replace TLS — use HTTPS endpoints so confidentiality is also covered.
- No timestamp in the envelope. The payload contains no signed timestamp or delivery
ID — if you need strict replay protection, you’ll have to implement it yourself at the
application level (e.g., idempotency based on the
idof the object indata). - Compare in constant time. Use
hmac.compare_digest/crypto.timingSafeEqualinstead of==to rule out timing attacks on the comparison. - Secret rotation. There is no endpoint to reissue the secret: create the webhook again, switch the receiver to the new secret, delete the old webhook.