Skip to main content
Verification is optional. Without it your endpoint still works; with it you know a request really came from Contour. Each request carries:
v1 is HMAC-SHA256(secret, "<t>." + raw_body), hex encoded. To verify:
  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on ,, then each part on =, to get t and v1.
  3. Compute HMAC-SHA256 over t + "." + raw_body with your whsec_ secret.
  4. Compare with v1 using a constant-time comparison.
  5. Reject if t is more than 5 minutes from now (replay protection).
The most common mistake is verifying against a re-serialized JSON object. Your framework’s JSON.stringify(req.body) or json.dumps(request.json) will not reproduce our exact bytes. Always use the raw body.
Flask: use request.get_data() for the raw bytes; everything else is the same.

Check your implementation against this fixed example

Feed these exact values into your code; it must produce the signature shown.
The raw body is one line with no spaces. Copy it exactly. Skip the freshness check while testing with this vector, since the timestamp is in the past.

Rotating the secret

POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret and the old one stops working immediately. Update your handler’s configuration first, then rotate. Retried deliveries are re-signed with the current secret.