Signature headers
Two headers accompany every delivery:
The signature is computed over the byte string:
<timestamp> is the literal value of the X-VC-Timestamp header (digits only), the separator is a single literal dot character, and <body> is the exact bytes of the request body as received over the wire — no re-serialization, no whitespace adjustment.
The HMAC key is the subscription secret returned by POST /api/v1/partner/webhooks (the value starting with whsec_).
Verification algorithm
- Read the raw body bytes before any JSON parsing or middleware that might reformat them.
- Read the
X-VC-SignatureandX-VC-Timestampheaders. Reject if either is missing. - Strip the
sha256=prefix from the signature header; reject if not present. - Parse the timestamp as an integer (milliseconds since epoch). Reject if non-numeric.
- Reject if
abs(now_ms - timestamp_ms) > 300_000(5 minute replay window). - Compute
HMAC-SHA256(secret, f"{timestamp}." + body_bytes).hexdigest(). - Constant-time compare the computed digest against the received digest. Reject on mismatch.
Reference implementations
Python
Node.js
What the checks defend against
Secret storage
Store the subscription secret as you would any high-value credential:- Environment variable or secret manager. Never commit it to a code repository.
- One per subscription. If you have multiple subscription URLs (e.g., staging + production), each has its own independent secret.
- Rotate by deletion + recreation. v1 has no in-place secret rotation. To rotate: create a second subscription with the new URL/secret, verify traffic flows, delete the old one.
What to do if verification fails
Failed verification is always a hard reject — return a non-2xx status from your handler (recommended:401 Unauthorized so failures are obvious in monitoring). Do not log the body, do not enqueue for retry on your side, do not “process the event but flag it.” A forged or replayed webhook should be discarded with the same finality as an unauthenticated API call.
Persistent verification failures on legitimate traffic almost always trace to one of three causes:
- Wrong secret. The handler is configured with a different secret than the subscription it’s receiving. Re-check against
GET /api/v1/partner/webhooksto confirm the subscription ID matches. - Body mutation. A middleware layer parsed and re-serialized the JSON before your handler saw it. Reroute the raw bytes.
- Clock skew. Your server’s clock drifted more than 5 minutes from UTC. Run
ntpdorchronyd.