Verify signatures
Anyone who knows your endpoint URL can send it a request, so check the signature of every request before you trust its payload. This page covers the request headers, how to verify the signature and how to rotate the signing secret. You get the secret when you set up an endpoint.
Request headers
Section titled “Request headers”| Header | Value |
|---|---|
content-type | application/json |
user-agent | yggy-webhooks/1 |
yggy-event-id | The envelope id. |
yggy-event-type | The event type. |
yggy-delivery-id | This delivery. A redelivery has a new delivery id and the same event id. |
yggy-signature | t=<unix seconds>,v1=<signature> |
Verify the signature
Section titled “Verify the signature”The signature is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret, including the whsec_ prefix. Check it before you trust the payload:
- Read the raw request body as bytes. Do not parse and re-serialize the JSON first.
- Take
tand everyv1value fromyggy-signature. While a secret is being rotated, the header carries twov1values. - Compute the HMAC and compare it in constant time with each
v1value. Accept when one matches. - Reject requests whose
tis more than 5 minutes away from your clock.
A valid request can be sent again within those 5 minutes. Deduplicate on the event id (yggy-event-id), as Retries and redelivery describes.
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) { const parts = header.split(",").map((part) => part.trim()); const t = parts.find((part) => part.startsWith("t="))?.slice(2); const signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3)); if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); return signatures.some((signature) => { const given = Buffer.from(signature, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); });}With Express, read the body raw: app.post("/webhooks/yggy", express.raw({ type: "application/json" }), handler) and pass req.body.toString("utf8"). The sample expects the raw body as a UTF-8 string.
Python:
import hashlibimport hmacimport time
def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = [part.strip() for part in header.split(",")] t = next((part[2:] for part in parts if part.startswith("t=")), None) signatures = [part[3:] for part in parts if part.startswith("v1=")] if t is None or not (t.isascii() and t.isdigit()) or not signatures: return False if abs(int(time.time()) - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)Rotate the secret
Section titled “Rotate the secret”Rotate secret creates a new secret and shows it once. The old secret keeps working for 24 hours, and during that time requests carry a signature for each secret, so you can switch without missing events.