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:
- Read the raw request body as bytes, before any JSON parsing.
- Split the header on
,, then each part on =, to get t and v1.
- Compute
HMAC-SHA256 over t + "." + raw_body with your whsec_ secret.
- Compare with
v1 using a constant-time comparison.
- 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.