---
title: Create webhook
description: Register a webhook that receives the signed status events of a cluster, namespace, or collection scope.
doc_version: 0.1.0-preview
last_updated: 2026-09-14
---

# Create webhook

`POST /webhooks`

Register a webhook that receives the signed status events of a cluster, namespace, or collection scope.

**Permission:** Cluster key, or a scoped key with write on every collection that the webhook covers.

[API key types and authentication](/engine/reference/authentication.md).

The answer holds secret, once. Store it before you continue. Later reads show only signing.secret_fingerprint.

Create takes no Idempotency-Key, because its answer holds a secret. A retry with the same name returns already_exists. Rotate the secret to get a new one.

Engine sends no POST at create, so the receiver need not be up. Call Test webhook to check it. The webhook gets only the events that occur after its create. Replay fills the past.

A namespace key can create namespace and collection webhooks in its own namespace. A collection key can create collection webhooks for its own collections. Only a cluster key can create a cluster webhook or list more than one namespace.

The URL must use https, hold no user info or fragment, and resolve only to public addresses.

## Example request

```http
POST /webhooks HTTP/1.1
Host: engine.example.com
Authorization: Bearer <GRAPHON_ENGINE_API_KEY>
Content-Type: application/json

{
  "name": "acme-status",
  "scope": {
    "kind": "namespace",
    "namespaces": [
      "acme"
    ]
  },
  "url": "https://hooks.acme.example/graphon-events",
  "types": [
    "job.updated",
    "file.status_changed"
  ]
}
```

## Request body

### `enabled`

**boolean · optional**

Whether Engine queues and sends events. Default true. A webhook created with false has disabled_reason requested.

Default: `true`.

### `name`

**string · required**

Webhook name. Use 1–63 lowercase letters, digits, underscores, or hyphens. Start with a letter or digit. Unique in the cluster. It never changes, and each POST sends it in X-Graphon-Events-Webhook.

### `scope`

**object · required**

Which events the webhook gets and which keys manage it: a cluster, namespace, or collection scope. Every namespace and collection must exist and be inside the key’s scope.

#### cluster

##### `scope.kind`

**"cluster" · required**

Every event in the cluster. Only a cluster key can create or see this webhook.

#### namespace

##### `scope.kind`

**"namespace" · required**

Every event of the listed namespaces, including collections created later and namespace jobs.

##### `scope.namespaces`

**array<string> · required**

Nonempty list of namespace names or IDs. More than one needs a cluster key. A scoped key sees only its own namespace here.

#### collection

##### `scope.collections`

**array<string> · required**

Nonempty list of collection names or IDs in the namespace. The webhook gets their events.

##### `scope.kind`

**"collection" · required**

The events of the named collections in one namespace.

##### `scope.namespace`

**string · required · nullable**

Namespace name or ID that holds the collections. Null in a response when the namespace is being deleted.

### `types`

**array<string> · optional · nullable**

Event types to send. Null or omitted sends every type, also types added later. Otherwise a nonempty list of event types.

### `url`

**string · required**

Receiver URL. Use https, with no user info and no fragment. A query string and any port are allowed. Every address that the host resolves to must be public. Keys with read can see the URL, so do not put a credential in it.

## Responses

### 201 · Webhook created

The new webhook. secret appears only in this response.

Content type: `application/json`.

#### Response headers

##### `x-engine-request-id`

**string · required**

The Engine request ID. Retain it when investigating a failed or unexpected request.

##### `x-request-id`

**string · required**

The X-Request-Id you sent, echoed when it is a valid ID. Otherwise, the Engine request ID.

##### `Location`

**string · required**

The path of the new resource, by ids.

```json
{
  "id": "whk_01K6Q6N7D8E9F0G1H2J3K4M5N6",
  "name": "acme-status",
  "scope": {
    "kind": "namespace",
    "namespaces": [
      "acme"
    ]
  },
  "url": "https://hooks.acme.example/graphon-events",
  "types": [
    "job.updated",
    "file.status_changed"
  ],
  "enabled": true,
  "disabled_reason": null,
  "signing": {
    "secret_fingerprint": "sha256:9b2d44f0",
    "previous_secret_fingerprint": null,
    "previous_secret_expires_at": null
  },
  "delivery": {
    "pending_deliveries": 0,
    "last_succeeded_at": null,
    "last_failed_at": null
  },
  "secret": "whsec_<NEW_WEBHOOK_SECRET>",
  "created_at": "2026-10-06T10:00:00Z",
  "updated_at": "2026-10-06T10:00:00Z"
}
```

#### Response fields

##### `created_at`

**string (date-time) · required**

Time the resource was created, as an ISO 8601 UTC timestamp.

##### `delivery`

**object · required**

Delivery summary: pending events and the times of the last success and failure.

###### `delivery.last_failed_at`

**string (date-time) · required · nullable**

Time of the last attempt that did not succeed. Null before the first one.

###### `delivery.last_succeeded_at`

**string (date-time) · required · nullable**

Time of the last 2xx answer. Null before the first one.

###### `delivery.pending_deliveries`

**integer · required**

Events that are queued or waiting for a retry.

##### `disabled_reason`

**string · required · nullable**

Why the webhook is off: requested (a key set enabled false) or failing (Engine turned it off). Null while it is on.

- `requested`: A key set enabled to false.
- `failing`: Engine turned the webhook off: the receiver answered 410, or sent no 2xx for 72 hours.

##### `enabled`

**boolean · required**

Whether Engine queues and sends events.

##### `id`

**string · required**

Immutable webhook ID with the whk_ prefix.

##### `name`

**string · required**

Webhook name. It never changes. Each POST sends it in X-Graphon-Events-Webhook.

##### `scope`

**object · required**

The webhook’s scope. A namespace or collection key sees only its own namespace and collections in it.

###### cluster

###### `scope.kind`

**"cluster" · required**

Every event in the cluster. Only a cluster key can create or see this webhook.

###### namespace

###### `scope.kind`

**"namespace" · required**

Every event of the listed namespaces, including collections created later and namespace jobs.

###### `scope.namespaces`

**array<string> · required**

Nonempty list of namespace names or IDs. More than one needs a cluster key. A scoped key sees only its own namespace here.

###### collection

###### `scope.collections`

**array<string> · required**

Nonempty list of collection names or IDs in the namespace. The webhook gets their events.

###### `scope.kind`

**"collection" · required**

The events of the named collections in one namespace.

###### `scope.namespace`

**string · required · nullable**

Namespace name or ID that holds the collections. Null in a response when the namespace is being deleted.

##### `secret`

**string · optional · nullable**

The signing secret: whsec_ and the base64 of 32 random bytes. Only the answers to Create and Rotate hold it. Store it before you continue.

##### `signing`

**object · required**

Fingerprints that identify the signing secrets, and the end of a rotation overlap.

###### `signing.previous_secret_expires_at`

**string (date-time) · required · nullable**

When the previous secret stops signing. Null outside a rotation overlap.

###### `signing.previous_secret_fingerprint`

**string · required · nullable**

Fingerprint of the previous secret during a rotation overlap. Otherwise null.

###### `signing.secret_fingerprint`

**string · required**

sha256: and 8 hex characters that identify the current secret without showing it.

##### `types`

**array<string> · required · nullable**

Event types that the webhook gets. Null means every type, also types added later.

##### `updated_at`

**string (date-time) · required**

Time the resource last changed, as an ISO 8601 UTC timestamp.

##### `url`

**string · required**

Receiver URL that Engine POSTs to.

## Errors

[Error format and retry guidance](/engine/reference/errors.md).

### 400 · `invalid_request`

A field, path, parameter, or conditional request rule is invalid.

Correct the request. Unknown fields are rejected.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "A field, path, parameter, or conditional request rule is invalid.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 401 · `unauthenticated`

The API key is missing, invalid, or revoked.

Send an active key in Authorization: Bearer or X-API-Key.

```json
{
  "error": {
    "code": "unauthenticated",
    "message": "The API key is missing, invalid, or revoked.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 403 · `forbidden`

The key can see the resource but lacks permission for this operation.

Use a key with the required action or scope kind.

```json
{
  "error": {
    "code": "forbidden",
    "message": "The key can see the resource but lacks permission for this operation.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 404 · `not_found`

The resource is absent, deleted, or outside this key’s scope.

Check the identifier and the key’s namespace and collection scope.

```json
{
  "error": {
    "code": "not_found",
    "message": "The resource is absent, deleted, or outside this key’s scope.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 409 · `already_exists`

The requested resource name already exists.

Use a new name. Namespace and collection creation also accept exist_ok to return an existing resource.

```json
{
  "error": {
    "code": "already_exists",
    "message": "The requested resource name already exists.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 413 · `request_too_large`

The JSON body exceeds the route limit: 6,291,456 bytes on :answer and research, 4,194,304 bytes on :search and :read-spans, and 1,048,576 bytes elsewhere.

Reduce the JSON body. Use upload or a URI source for larger file content.

```json
{
  "error": {
    "code": "request_too_large",
    "message": "The JSON body exceeds the route limit: 6,291,456 bytes on :answer and research, 4,194,304 bytes on :search and :read-spans, and 1,048,576 bytes elsewhere.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

### 503 · `temporarily_unavailable`

A dependency, cluster readiness condition, or capacity limit prevents this operation.

Inspect retryable and details. Retry only when permitted, preserving the idempotency key for the same write.

```json
{
  "error": {
    "code": "temporarily_unavailable",
    "message": "A dependency, cluster readiness condition, or capacity limit prevents this operation.",
    "request_id": "req_example",
    "retryable": true,
    "details": {}
  }
}
```

### 500 · `internal_error`

An unexpected server failure prevented this request from completing.

Retain request_id for investigation. Follow retryable before retrying a write.

```json
{
  "error": {
    "code": "internal_error",
    "message": "An unexpected server failure prevented this request from completing.",
    "request_id": "req_example",
    "retryable": false,
    "details": {}
  }
}
```

## Other request languages

### cURL

```bash
curl -sS -X POST "$GRAPHON_ENGINE_URL/webhooks" \
  -H "Authorization: Bearer $GRAPHON_ENGINE_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-raw '{
  "name": "acme-status",
  "scope": {
    "kind": "namespace",
    "namespaces": [
      "acme"
    ]
  },
  "url": "https://hooks.acme.example/graphon-events",
  "types": [
    "job.updated",
    "file.status_changed"
  ]
}'
```

### Python

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    os.environ["GRAPHON_ENGINE_URL"] + "/webhooks",
    data=json.dumps({"name": "acme-status", "scope": {"kind": "namespace", "namespaces": ["acme"]}, "url": "https://hooks.acme.example/graphon-events", "types": ["job.updated", "file.status_changed"]}).encode("utf-8"),
    headers={
        "Authorization": "Bearer " + os.environ["GRAPHON_ENGINE_API_KEY"],
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.read().decode())
```

### JavaScript

```javascript
const response = await fetch(process.env.GRAPHON_ENGINE_URL + "/webhooks", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GRAPHON_ENGINE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"name":"acme-status","scope":{"kind":"namespace","namespaces":["acme"]},"url":"https://hooks.acme.example/graphon-events","types":["job.updated","file.status_changed"]}),
});
console.log(response.status, await response.text());
```

## Sitemap

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