Skip to main content
Every webhook delivery is signed with HMAC-SHA256 using the per-subscription secret returned at create time. Always verify the signature before trusting the body. An unverified webhook handler is a public unauthenticated endpoint that an attacker can spoof at will.

Signature headers

Two headers accompany every delivery: The signature is computed over the byte string:
Where <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

  1. Read the raw body bytes before any JSON parsing or middleware that might reformat them.
  2. Read the X-VC-Signature and X-VC-Timestamp headers. Reject if either is missing.
  3. Strip the sha256= prefix from the signature header; reject if not present.
  4. Parse the timestamp as an integer (milliseconds since epoch). Reject if non-numeric.
  5. Reject if abs(now_ms - timestamp_ms) > 300_000 (5 minute replay window).
  6. Compute HMAC-SHA256(secret, f"{timestamp}." + body_bytes).hexdigest().
  7. Constant-time compare the computed digest against the received digest. Reject on mismatch.
Only if all seven checks pass should you trust the body and process the event.

Reference implementations

Python

Node.js

In Node.js, your web framework must give you the raw body bytes, not a parsed JSON object. With Express, use express.raw({ type: 'application/json' }) for the webhook route specifically. With Next.js API routes, read req as a stream and reconstruct the buffer. JSON middleware that has already parsed and re-serialized the body will produce mismatched signatures.

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:
  1. Wrong secret. The handler is configured with a different secret than the subscription it’s receiving. Re-check against GET /api/v1/partner/webhooks to confirm the subscription ID matches.
  2. Body mutation. A middleware layer parsed and re-serialized the JSON before your handler saw it. Reroute the raw bytes.
  3. Clock skew. Your server’s clock drifted more than 5 minutes from UTC. Run ntpd or chronyd.