> ## 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 events to an HTTPS endpoint you operate: a run's new
findings, a clean run, and a recurring assessment the platform paused. 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 first and tolerate
fields you do not recognize; `schema_version` increments when the shape
changes. Three event kinds exist today:

| `event` | Meaning |
| - | - |
| `run.findings_opened` | A run opened new findings |
| `run.all_clear` | A run completed with nothing to report; sent only to a destination with no severity threshold |
| `schedule.paused` | A recurring assessment stopped: three of its occurrences in a row failed. Sent to every enabled destination, whatever its severity threshold, because a pause is not a finding |

Every event carries `event`, `schema_version` (payload schema version),
`timestamp` (event time, ISO-8601 UTC) and `workspace_id`.

**Run events** (`run.findings_opened`, `run.all_clear`) add:

| Field | Meaning |
| - | - |
| `assessment_run_id`, `target_name` | Which 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` |
| `test` | Present and `true` only on a send-test probe. A real delivery never carries this field, so branch on it to keep probes out of your ticketing or paging path |

**The schedule event** (`schedule.paused`) has no `findings` array and no
`assessment_run_id`; it adds:

| Field | Meaning |
| - | - |
| `schedule_id` | The schedule that paused |
| `lens` | `security` or `safety` |
| `cadence` | `daily`, `weekly`, `fortnightly` or `monthly` |
| `target_names` | The targets it covered, by display name, in the order it ran them |
| `consecutive_failures` | How many occurrences in a row failed |
| `schedules_url` | Link to the Testing page, where the Schedules panel lives |

There is no resume: the way back is to delete the schedule and create a new
one.

## 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 `event` plus `assessment_run_id`, or
  plus `schedule_id` for the schedule 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.
* **To file issues rather than receive events**, connect
  [Linear](/integrations/linear) or [Jira](/integrations/jira): Enoki creates
  and closes the issues itself.
* Use the send-test action in workspace settings to fire a sample delivery
  at your receiver while you build the verifier. The probe carries the real
  `run.findings_opened` shape plus `"test": true`, and its sample finding is
  not a real one — the entry links to your findings list rather than to a
  finding detail page.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.