Delivery headers
Every delivery is aPOST with a JSON body and two headers:
The signed input is
f"{timestamp}.{raw_body}": the timestamp header value,
a literal ., then the exact bytes of the request body. Because the
timestamp is part of the signed input, it cannot be altered without
invalidating the signature.
Verify before you parse
- Read the raw request body as bytes. Do not re-serialize parsed JSON: key order and whitespace would differ and the signature would never match.
- Recompute
HMAC-SHA256(secret, f"{timestamp}.".encode() + raw_body)using the timestamp header value. - Compare the recomputed
sha256=<hex>string to theX-Enoki-Signatureheader in constant time (hmac.compare_digest). Never use==on the digest. - Reject the delivery if the signed timestamp is outside a freshness window (we recommend 5 minutes). This bounds replay of a captured request.
Delivery payload
The body is a flat JSON object. Branch on theevent field and tolerate
fields you do not recognize; schema_version increments when the shape
changes.
Good to know
- Retries mean possible duplicates. Failed deliveries (network errors,
5xx responses) are retried with backoff, so your receiver may see the same
event more than once. Deduplicate on
assessment_run_idpluseventif that matters to you. - Respond with a 2xx quickly. Any 2xx counts as delivered. A 4xx is treated as a permanent rejection and is not retried.
- Deliveries never follow redirects. Point the destination URL directly at your receiver.
- HTTPS only. Plain-HTTP destination URLs are rejected when you configure the webhook, and URLs resolving to private or internal addresses are rejected before every delivery.
- Slack destinations are not signed. This recipe applies to the generic webhook only; Slack incoming-webhook deliveries carry no signature headers.
- Use the send-test action in workspace settings to fire a sample delivery at your receiver while you build the verifier.