Verify a delivery
Check each delivery's signature, in Node or Python.
Anyone can send a request to your receiver's address. A delivery from YOU(th) carries a signature only someone with your signing secret could make; check it before you act on a delivery.
How a delivery is signed
- Your signing secret is a string that starts with
wh_sec_live_(orwh_sec_test_for a test integration). YOU(th) shows it once, when your integration is set up. - The key is the whole secret, prefix included, as UTF-8 bytes. It is not base64, and is not decoded.
- The signed message is the
tvalue ofX-youth-signature, a dot, and the raw body exactly as it arrived:<t>.<body>. - The signature is the HMAC-SHA256 of that message, as lowercase hex, after
v1=. - Each delivery carries one signature. When your secret is replaced, YOU(th) agrees a switch-over time with you; around it, accept a signature made with either secret.
Verify it
Compute the signature over the raw body, before any JSON parsing, and compare it in constant time.
Refuse a delivery whose t is more than 300 seconds from your clock, so a recorded request cannot
be played again later.
import { createHmac, timingSafeEqual } from "node:crypto";/** Whether a delivery was signed with your secret within the last five minutes. */export function verifyDelivery( secret: string, signatureHeader: string, rawBody: Buffer, now = Math.floor(Date.now() / 1000),): boolean { const fields = new Map( signatureHeader.split(",").map((part) => { const at = part.indexOf("="); return [part.slice(0, at), part.slice(at + 1)] as const; }), ); const timestamp = fields.get("t") ?? ""; if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", secret) .update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody])) .digest(); const supplied = Buffer.from(fields.get("v1") ?? "", "hex"); return supplied.length === expected.length && timingSafeEqual(supplied, expected);}import hashlibimport hmacimport timefrom typing import Optionaldef verify_delivery( secret: str, signature_header: str, raw_body: bytes, now: Optional[int] = None,) -> bool: """Whether a delivery was signed with your secret within the last five minutes.""" fields = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part) timestamp = fields.get("t", "") if not timestamp.isdigit(): return False if abs((int(time.time()) if now is None else now) - int(timestamp)) > 300: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(fields.get("v1", ""), expected)Answer 401 to a delivery that fails, and do nothing else with it. That answer is not retried.
Try it
This request was signed with the fixture secret
wh_sec_test_Zm9vYmFyYmF6cXV4LWV4YW1wbGUtc2VjcmV0LXZhbHVl. Your check should accept it when your
clock is set to its t, and refuse it when one byte of the body changes.
POST /youth-webhook HTTP/1.1Host: partner.example.comX-youth-event-id: evt_e560fc965cf89b3d5a8c3ed27406806fX-youth-event-type: Checkup.completedX-youth-event-version: 2X-youth-timestamp: 2026-10-01T09:30:02ZX-youth-delivery-id: dlv_4354549c5be143f4b3e2ef0b30eccb02_1X-youth-delivery-attempt: 1X-youth-signature: t=1790847002,v1=cf7f342026cf7c2997b6af983998c8eb9c9e3c5deb2e84cc9adab60948caf378{"checkup_id":"64d866f3-0c88-485c-bfe6-3339df1d224a","event_id":"evt_e560fc965cf89b3d5a8c3ed27406806f","event_type":"Checkup.completed","event_version":"2","occurred_at":"2026-10-01T09:30:00.375000Z","patient_id":"cfefb422-ac79-4cb9-a849-e80f8a6f21cd","resource_url":"https://api.example.com/v1/fhir/checkups/64d866f3-0c88-485c-bfe6-3339df1d224a"}Read the raw body
Most web frameworks parse JSON before your code runs. Parsed and written again, the body is no longer the bytes YOU(th) signed, and the signature never matches. Read the body as raw bytes on your webhook route, verify it, then parse it.