Skip to content

Powered by Grav + Helios

Webhooks

Webhooks

Register outgoing HTTP webhooks that fire when API write events happen (page/media/user mutations, config updates, GPM installs, Grav upgrades). Each webhook has a URL, an event filter list, optional custom headers, a generated secret for HMAC signing, and a delivery log. Permissions: api.webhook...

Register outgoing HTTP webhooks that fire when API write events happen (page/media/user mutations, config updates, GPM installs, Grav upgrades). Each webhook has a URL, an event filter list, optional custom headers, a generated secret for HMAC signing, and a delivery log.

Permissions: api.webhooks.read / api.webhooks.write. Secrets are generated by the server and always redacted in API responses, except once in the response that creates the webhook.

Valid event names

* (all), page.created, page.updated, page.deleted, page.moved, page.translated, pages.reordered, media.uploaded, media.deleted, user.created, user.updated, user.deleted, config.updated, gpm.installed, gpm.removed, grav.upgraded.

Deliveries

Each delivery is a JSON POST of {"event": "...", "timestamp": "<ISO 8601>", "data": {...}, "webhook_id": "wh_..."}. Grav adds these headers to every delivery:

Header Value
X-Grav-Signature Hex HMAC-SHA256 of the raw request body, keyed with the webhook's secret
X-Grav-Event The webhook event name, e.g. page.updated
X-Grav-Delivery A unique delivery ID (dlv_...)

A webhook's custom headers can set other headers, including Content-Type and User-Agent, but can't replace these three: creating or updating a webhook with one of them returns 422. Only http and https URLs are accepted, and a URL that resolves to a private, loopback, link-local or reserved address is refused, both when saved and again at delivery time. Redirects are not followed.

A webhook that fails 5 deliveries in a row is disabled automatically, with a disabled_reason; a successful delivery resets the count. The last 50 deliveries of each webhook are kept.

List Webhooks

GET /webhooks
List all configured webhooks. Secrets are redacted (first 6 and last 4 characters kept, the middle masked).
JSON
{"data": [{"id": "wh_abc123", "url": "https://example.com/hooks/grav", "secret": "whsec_************************************************a1b2", "events": ["page.created", "page.updated"], "enabled": true, "headers": {}, "created": 1774008000, "failure_count": 0}]}

Response Codes

200 Webhooks returned.
401 Unauthorized.
403 Missing `api.webhooks.read` permission.

Create Webhook

POST /webhooks
Register a new webhook. The URL must be an absolute `http` or `https` URL that does not resolve to a private or reserved address. Events (if supplied) must come from the allowed list (see the chapter intro). The server generates the webhook id and its signing secret; the response is the only time the full secret is returned.

Parameters

Name Type Description
url required string Absolute HTTP(S) URL to POST payloads to.
events optional array Event filter, as a JSON array of event names. Use `["*"]` to receive every event, or list specific events. Defaults to `["*"]`. Anything other than an array of valid names is a 422.
headers optional object Custom headers to send with every delivery, as an object of header names to values. `X-Grav-Signature`, `X-Grav-Event` and `X-Grav-Delivery` (in any letter case) are refused with a 422, as are names or values containing line breaks.
enabled optional boolean Defaults to true.
JSON
{"url": "https://example.com/hooks/grav", "events": ["page.created", "page.updated"], "enabled": true}
JSON
{"data": {"id": "wh_abc123", "url": "https://example.com/hooks/grav", "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef", "events": ["page.created", "page.updated"], "enabled": true, "headers": {}, "created": 1774526400, "failure_count": 0}}

Response Codes

201 Webhook created; Location header points to the new webhook.
401 Unauthorized.
403 Missing `api.webhooks.write` permission.
422 Missing or invalid `url` (bad syntax, not http/https, or a private or reserved address), `events` that is not an array of valid event names, or invalid or reserved `headers`.

Get Webhook

GET /webhooks/{id}
Get a single webhook by id. The secret is redacted. The response carries an `ETag` header.

Parameters

Name Type Description
id required string Webhook id (path param).
JSON
{"data": {"id": "wh_abc123", "url": "https://example.com/hooks/grav", "secret": "whsec_************************************************a1b2", "events": ["page.created", "page.updated"], "enabled": true, "headers": {}, "created": 1774008000, "failure_count": 0}}

Response Codes

200 Webhook returned.
401 Unauthorized.
403 Missing `api.webhooks.read` permission.
404 Webhook not found.

Update Webhook

PATCH /webhooks/{id}
Partial update: only supplied fields are changed. `url`, `events` and `headers` are validated the same way as on create. The secret can't be changed. Re-enabling a webhook that was auto-disabled does not reset its `failure_count`; the next successful delivery does.

Parameters

Name Type Description
id required string Webhook id (path param).
url optional string New URL.
events optional array Replacement event filter (a JSON array of event names).
headers optional object Replacement custom headers. The three `X-Grav-*` headers Grav sets are refused with a 422.
enabled optional boolean Enable/disable the webhook without deleting it.
JSON
{"url": "https://example.com/hooks/grav-v2", "events": ["page.created", "page.updated", "page.deleted"]}
JSON
{"data": {"id": "wh_abc123", "url": "https://example.com/hooks/grav-v2", "secret": "whsec_************************************************a1b2", "events": ["page.created", "page.updated", "page.deleted"], "enabled": true, "headers": {}, "created": 1774008000, "failure_count": 0}}

Response Codes

200 Webhook updated.
401 Unauthorized.
403 Missing `api.webhooks.write` permission.
404 Webhook not found.
422 Invalid `url`, `events` that is not an array of valid event names, or invalid or reserved `headers`.

Delete Webhook

DELETE /webhooks/{id}
Delete a webhook. Delivery history is also removed.

Parameters

Name Type Description
id required string Webhook id (path param).

Response Codes

204 Webhook deleted.
401 Unauthorized.
403 Missing `api.webhooks.write` permission.
404 Webhook not found.

Delivery Log

GET /webhooks/{id}/deliveries
Paginated delivery log for a single webhook, newest first. Each record holds the delivery id, event, URL, the request headers and body that were sent, the response status and the first 1000 characters of the response body, the duration, a success flag, an `error` for transport failures, and the `created` Unix timestamp. Only the last 50 deliveries are kept.

Parameters

Name Type Description
id required string Webhook id (path param).
page optional integer Page number (default 1).
per_page optional integer Items per page (default `plugins.api.pagination.default_per_page`, 20).
JSON
{"data": [{"id": "dlv_9f2c4e1a7b3d5f60", "event": "page.updated", "url": "https://example.com/hooks/grav", "request_headers": {"Content-Type": "application/json", "User-Agent": "Grav-Webhook/1.0", "X-Grav-Signature": "5d41402abc4b2a76b9719d911017c592...", "X-Grav-Event": "page.updated", "X-Grav-Delivery": "dlv_9f2c4e1a7b3d5f60"}, "request_body": {"event": "page.updated", "timestamp": "2026-09-21T12:00:00+00:00", "data": {"page": {"route": "/blog/hello", "title": "Hello", "slug": "hello"}}, "webhook_id": "wh_abc123"}, "created": 1790000000, "status_code": 200, "response_body": "ok", "duration_ms": 142, "success": true}], "meta": {"pagination": {"page": 1, "per_page": 20, "total": 1, "total_pages": 1}}, "links": {"self": "/api/v1/webhooks/wh_abc123/deliveries?page=1&per_page=20"}}

Response Codes

200 Deliveries returned.
401 Unauthorized.
403 Missing `api.webhooks.read` permission.
404 Webhook not found.

Test Webhook

POST /webhooks/{id}/test
Send a test delivery (event `test`, with `data.message` set to `This is a test webhook delivery.`) to the webhook URL and return the delivery record immediately. It is signed and logged like a real delivery, and a failed test counts toward the 5-failure auto-disable like one. Use this to check that the endpoint is reachable and verifies the signature without waiting for a real event. Returns 502, with the same delivery record in the body, if the receiver answers with a non-2xx status or can't be reached.

Parameters

Name Type Description
id required string Webhook id (path param).
JSON
{"data": {"id": "dlv_1a2b3c4d5e6f7a8b", "event": "test", "url": "https://example.com/hooks/grav", "request_headers": {"Content-Type": "application/json", "User-Agent": "Grav-Webhook/1.0", "X-Grav-Signature": "5d41402abc4b2a76b9719d911017c592...", "X-Grav-Event": "test", "X-Grav-Delivery": "dlv_1a2b3c4d5e6f7a8b"}, "request_body": {"event": "test", "timestamp": "2026-09-21T12:00:00+00:00", "data": {"message": "This is a test webhook delivery."}, "webhook_id": "wh_abc123"}, "created": 1790000000, "status_code": 200, "response_body": "ok", "duration_ms": 118, "success": true}}

Response Codes

200 Test delivered successfully.
401 Unauthorized.
403 Missing `api.webhooks.write` permission.
404 Webhook not found.
502 Test delivery failed; the body holds the delivery record with `success: false`.