---
title: Rotate webhook secret
description: Replace a webhook’s signing secret.
doc_version: 0.1.0-preview
last_updated: 2026-09-14
---

# Rotate webhook secret

`POST /webhooks/{webhook}:rotate-secret`

Replace a webhook’s signing secret. The previous secret keeps signing for an overlap.

**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 the new secret, once. The body is optional.

During the overlap each POST carries two signatures, the new one first. Deploy the new secret at any time before previous_secret_expires_at.

Use an overlap of 0 for a leaked secret. A rotation during an overlap ends the oldest secret at once.

After a 500, the rotation may have happened. Get the webhook and compare signing.secret_fingerprint before a retry.

## Example request

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

{
  "previous_secret_expires_in_seconds": 86400
}
```

## Path parameters

### `webhook`

**string · required**

Webhook ID with the whk_ prefix, or the webhook’s name.

## Request body

### `previous_secret_expires_in_seconds`

**integer · optional**

How long the previous secret keeps signing, from 0 to 604,800 seconds (7 days). Default 86,400 (24 hours). 0 ends the previous secret at once.

Default: `86400`.

## Responses

### 200 · Success

The webhook with its new secret, shown once. The previous secret signs until previous_secret_expires_at.

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.

```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:5c1e9a07",
    "previous_secret_fingerprint": "sha256:9b2d44f0",
    "previous_secret_expires_at": "2026-10-07T10:00:00Z"
  },
  "delivery": {
    "pending_deliveries": 0,
    "last_succeeded_at": "2026-10-06T10:05:13Z",
    "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": {}
  }
}
```

### 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/acme-status:rotate-secret" \
  -H "Authorization: Bearer $GRAPHON_ENGINE_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-raw '{
  "previous_secret_expires_in_seconds": 86400
}'
```

### Python

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

request = urllib.request.Request(
    os.environ["GRAPHON_ENGINE_URL"] + "/webhooks/acme-status:rotate-secret",
    data=json.dumps({"previous_secret_expires_in_seconds": 86400}).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/acme-status:rotate-secret", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GRAPHON_ENGINE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"previous_secret_expires_in_seconds":86400}),
});
console.log(response.status, await response.text());
```

## Sitemap

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