Download now
Preview: proposed, may change
What this page describes is not live yet. Build against it together with your YOU(th) contact, who tells you when it is.

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_ (or wh_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 t value of X-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.