---
title: Events webhooks
description: Signed incoming status events, webhook scopes, and the delivery contract.
doc_version: 0.1.0-preview
last_updated: 2026-09-14
---

# Events webhooks

Graphon Engine sends status events as signed HTTP POST requests to the webhooks that keys register. This page defines those requests and their delivery behavior.

## Webhooks and scopes

A key registers a webhook with [Create webhook](/engine/reference/create-webhook): a unique `name`, a scope, an HTTPS `url`, and optional `types`. The webhook gets each event in its scope whose type is in `types`. Null or omitted `types` selects every type, also types added later.

| Scope | Body | Gets the events of |
| --- | --- | --- |
| `cluster` | `{"kind": "cluster"}` | Every namespace and collection. Only a cluster key can create or see it. |
| `namespace` | `{"kind": "namespace", "namespaces": ["acme"]}` | Every collection in the listed namespaces, also collections created later, and the namespace jobs. More than one namespace needs a cluster key. |
| `collection` | `{"kind": "collection", "namespace": "acme", "collections": ["incident-logs"]}` | The named collections in one namespace. |

A new webhook gets only the events that occur after its create. [Replay webhook deliveries](/engine/reference/replay-webhook-deliveries) queues earlier events while Engine still holds them (30 days). `/info` reports the number of webhooks that the calling key can see, and the supported event types.

The URL must use `https`, hold no user info or fragment, and resolve only to public addresses. Engine checks the address again on every connection, follows no redirect, and never reads the response body.

Engine takes no webhooks from the cluster configuration. A configuration that sets `events.webhooks`, also to an empty list, fails `GET /ready` with `details.failed: ["events"]`.

## Request headers

| Header | Type | Contract |
| --- | --- | --- |
| `webhook-id` | string | The batch ID, with the `bat_` prefix. Every retry of the batch keeps it. A replay sends its events in a new batch with a new ID. |
| `webhook-timestamp` | string containing Unix seconds | The send time of this POST. Refuse a value more than 5 minutes from your clock. |
| `webhook-signature` | string | `v1,` and the base64 HMAC-SHA256 signature. During a secret rotation, two values separated by a space, the new secret first. |
| `X-Graphon-Events-Webhook` | string | The webhook’s name. One receiver URL can use it to pick the secret. |
| `User-Agent` | string | `Graphon-Webhooks/1`. |
| `Content-Type` | string | `application/json`. Verify the original body bytes before parsing. |

The secret is `whsec_` and the base64 of 32 random bytes. The HMAC key is the decoded bytes. The signed content is the exact UTF-8 bytes of `webhook-id + "." + webhook-timestamp + "."`, followed by the original HTTP body bytes. It covers the whole batch. Each signature is `v1,` and the base64 HMAC-SHA256 digest. During a rotation overlap, Engine signs with the new and the previous secret, newest first. Accept the POST when one signature matches.

## Event body

**POST to your webhook · JSON body**

```json
{
  "events": [
    {
      "id": "evt_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "type": "file.status_changed",
      "occurred_at": "2026-10-06T12:01:00Z",
      "cluster": "graphon-engine",
      "namespace": "acme",
      "collection": "incident-logs",
      "job_id": "job_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "file_id": "fil_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "research_id": null,
      "data": {
        "status": "ready",
        "previous_status": "indexing"
      }
    }
  ]
}
```

The body is `{"events": [...]}`: the webhook’s oldest queued events, up to 500 events or 1 MiB. One larger event goes alone. Each event has the fields below.

| Field | Type | Contract |
| --- | --- | --- |
| `id` | string · required | Stable evt_ ID for this status change. Use it to deduplicate. |
| `type` | string enum · required | job.updated, file.status_changed, file.stage_changed, collection.status_changed, research.updated, research.progress, or webhook.test. |
| `occurred_at` | UTC timestamp string · required | Time the resource status changed. |
| `cluster` | string · required | Cluster name reported by /info. |
| `namespace` | string or null · required | Namespace associated with the resource, or null when none applies. |
| `collection` | string or null · required | Collection associated with the resource, or null when none applies. |
| `job_id` | string or null · required | Related job_ ID, or null when the event has no related job. |
| `file_id` | string or null · required | Related fil_ ID, or null when the event has no related file. |
| `research_id` | string or null · required | Related rsr_ research run ID, or null for other event types. |
| `data` | object · required | Status transition payload. |
| `data.status` | string · required | New status for the resource identified by type. |
| `data.previous_status` | string or null · required | Status before the transition. Null on initial job, file, and research creation. A research.progress event has no status. A file.stage_changed event has previous_stage instead. |
| `data.error` | object · file.status_changed | Present when the status is failed: the failure `code` and `message`, as Get file reports them. |
| `data.stage`, `data.previous_stage` | string · file.stage_changed | The new stage of an indexing file, and the stage before it. previous_stage is null for a file that had no stage. |
| `data.error_code` | string · research.updated | The error code when the status is failed. |
| `data.stage`, `data.label`, `data.step`, `data.total_steps` | string or integer · research.progress | The new stage of a running research run. |
| `data.job_type` | string enum · optional | Operation type for a job event, such as index_file or apply_access_rule. |
| `data.webhook` | string · webhook.test | Name of the webhook under test. |

File states are `pending`, `indexing`, `ready`, `failed`, and `deleting`. File stages, in order, are `engine_queued`, `checking_source`, `core_queued`, `processing`, and `building_index`. Only an indexing file has a stage. Job and research states are `queued`, `running`, `succeeded`, `failed`, and `canceled`. Collection states are `empty`, `indexing`, `ready`, `degraded`, `failed`, and `deleting`. Creating an empty collection does not emit a collection event.

`webhook.test` comes only from [Test webhook](/engine/reference/test-webhook). It is a batch of one event whose namespace, collection, and IDs are null. It is not queued, never retried, and not in the delivery log.

**Test webhook · JSON body**

```json
{
  "events": [
    {
      "id": "evt_01J8Z3K4N5P6Q7R8S9T0V1W2X6",
      "type": "webhook.test",
      "occurred_at": "2026-10-06T12:00:00Z",
      "cluster": "graphon-engine",
      "namespace": null,
      "collection": null,
      "job_id": null,
      "file_id": null,
      "research_id": null,
      "data": {
        "webhook": "acme-status"
      }
    }
  ]
}
```

Within a major API version, new event types or status values can be added. Ignore an event type that you do not know, and answer `2xx`. Preserve unknown values without treating them as a known success state.

## Responses and delivery

| Your response | Engine action |
| --- | --- |
| 2xx | Every event in the batch succeeds. Engine sends the next batch. |
| 429, 5xx, a network error, or no answer in 30 seconds | Engine sends the same batch again after 1, 2, 4, 8, 16, 32, and 64 seconds, then every 5 minutes, each ±10%. A longer `Retry-After` replaces the wait, up to 24 hours. |
| 3xx | Every event in the batch fails. Engine follows no redirect. Then it sends the next batch. |
| 410 | Every event in the batch fails, and Engine turns the webhook off with `disabled_reason` `failing`. |
| Other 4xx | Every event in the batch fails. Then Engine sends the next batch. |

Your response applies to the whole batch. Engine allows 30 seconds per POST. A retry sends the same bytes with the same `webhook-id`. A batch retries until the receiver answers or the webhook turns off. A failed event is not sent again unless a key replays it.

Engine sends one batch at a time to each webhook, in order. A retry delays only the later events of that webhook. One webhook’s failures never block another.

After a failed attempt, a webhook older than 72 hours with no `2xx` in the last 72 hours turns off with `disabled_reason` `failing`. A `410` turns it off at once. Turning a webhook off ends its queued events `failed`. A key turns it on again with [Update webhook](/engine/reference/update-webhook), and the webhook then gets only new events.

## Delivery log and replay

| Level | What it is | Status |
| --- | --- | --- |
| Delivery | One event queued at one webhook. | `pending` (queued, or waiting for a retry), `succeeded`, or `failed`. |
| Attempt | One POST of the batch that holds the event. Every delivery in the batch shares it. It keeps the HTTP status, an error code, and the duration, never a response body. | `succeeded`, `retry`, or `failed`. |

Each webhook keeps 30 days of deliveries and attempts. A key with `read` on part of a wider webhook sees only the deliveries of its own namespace or collections. Replay queues past events again, by ID or by time range, when they are in the webhook’s scope and types and Engine still holds them. A replayed event keeps its `id` and its bytes, in a new batch. A replay on a disabled webhook returns `409 webhook_disabled`.

## Recovery

Use [List webhook deliveries](/engine/reference/list-webhook-deliveries) to find what a receiver missed, and [Replay webhook deliveries](/engine/reference/replay-webhook-deliveries) to send it again. Current resource state also stays available through the resource GET endpoints. Use [Get job](/engine/reference/get-job), [Get file](/engine/reference/get-file), [Get collection](/engine/reference/get-collection), or [Get research](/engine/reference/get-research) for a gap older than 30 days.

The [jobs and events guide](/engine/guides/jobs-and-events) includes receiver setup, verification code, and an acknowledgement workflow.
## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
