> ## Documentation Index
> Fetch the complete documentation index at: https://docs.enokilabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Verify signed webhook deliveries from Enoki: headers, HMAC-SHA256 recompute, and replay protection.

Enoki can deliver findings events to an HTTPS endpoint you operate. You
configure the destination in your workspace settings with two values: the
endpoint URL and a signing secret. Every delivery is signed with that secret,
and your receiver should verify the signature before it parses or trusts the
body.

## Delivery headers

Every delivery is a `POST` with a JSON body and two headers:

| Header              | Value                                                                                         |
| ------------------- | --------------------------------------------------------------------------------------------- |
| `X-Enoki-Signature` | `sha256=<hex>`, the HMAC-SHA256 of the signed input, hex-encoded                              |
| `X-Enoki-Timestamp` | UTC time the signature covers, formatted `YYYY-MM-DDTHH:MM:SSZ` (e.g. `2026-06-17T10:00:00Z`) |

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

1. 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.
2. Recompute `HMAC-SHA256(secret, f"{timestamp}.".encode() + raw_body)` using
   the timestamp header value.
3. Compare the recomputed `sha256=<hex>` string to the `X-Enoki-Signature`
   header in constant time (`hmac.compare_digest`). Never use `==` on the
   digest.
4. Reject the delivery if the signed timestamp is outside a freshness window
   (we recommend 5 minutes). This bounds replay of a captured request.

```python theme={null}
import hashlib
import hmac
import os
import time
from datetime import datetime, timezone

# Load the shared signing secret from your environment / secrets manager.
HMAC_KEY = os.environ["ENOKI_WEBHOOK_KEY"].encode()
FRESHNESS_WINDOW_SECONDS = 300  # reject deliveries older than 5 minutes

def verify(raw_body: bytes, signature_header: str, timestamp_header: str) -> bool:
    try:
        # 1. Reject stale / replayed deliveries using the signed timestamp.
        signed_at = datetime.strptime(timestamp_header, "%Y-%m-%dT%H:%M:%SZ")
        signed_at = signed_at.replace(tzinfo=timezone.utc)
        age = abs(time.time() - signed_at.timestamp())
        if age > FRESHNESS_WINDOW_SECONDS:
            return False

        # 2. Recompute the HMAC over the EXACT raw bytes + the signed timestamp.
        signed_payload = f"{timestamp_header}.".encode() + raw_body
        expected = "sha256=" + hmac.new(HMAC_KEY, signed_payload, hashlib.sha256).hexdigest()

        # 3. Constant-time compare. Never `==` on the digest.
        return hmac.compare_digest(expected, signature_header)
    except (ValueError, TypeError):
        # Malformed timestamp or signature header: unverified, not an error.
        # Without this, garbage headers would raise (strptime / compare_digest)
        # and turn into 500s in your receiver.
        return False
```

## Delivery payload

The body is a flat JSON object. Branch on the `event` field and tolerate
fields you do not recognize; `schema_version` increments when the shape
changes.

| Field                                              | Meaning                                                                                                                 |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `event`                                            | Event kind: `run.findings_opened` (new findings from a run) or `run.all_clear` (a run completed with nothing to report) |
| `schema_version`                                   | Payload schema version                                                                                                  |
| `timestamp`                                        | Event time, ISO-8601 UTC                                                                                                |
| `workspace_id`, `assessment_run_id`, `target_name` | Which workspace, run, and tested target the event belongs to                                                            |
| `run_url`                                          | Link to the runs view in the Enoki app                                                                                  |
| `finding_count`                                    | Number of entries in `findings`; `0` for `run.all_clear`                                                                |
| `findings`                                         | Array of findings with per-finding links; empty for `run.all_clear`                                                     |

## 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_id` plus `event` if
  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.
