---
title: Webhooks
description: Be told when content changes, instead of polling for it.
---

Register a URL and ZevContent POSTs to it when content changes, so a site can
rebuild the moment something publishes rather than on a timer.

Endpoints are managed in the dashboard under **Delivery → Webhooks**.

> **INFO: Polling still works, and is sometimes better**
>
> [`GET /v1/changes`](/api/pagination#knowing-when-to-refetch) answers "has
> anything changed?" in a few hundred bytes and needs no public URL, no
> signature check and no retry handling. If your consumer is a static build that
> runs on a schedule anyway, poll it and skip this page.
>
> Webhooks are for the case where the delay matters.

## Events

This is the complete list. Nothing else fires, and you should not have to
guess: `/v1/introspection` returns the same array under `webhooks.events`,
read from the constant the emitter validates against.

**Custom content**

| Event | |
|---|---|
| `content.entry.published` | An entry of one of your own types became visible for the first time. |
| `content.entry.updated` | A **live** entry changed. |
| `content.entry.unpublished` | It is a 404 now. The draft survives. |
| `content.entry.deleted` | The entry is gone. |

`data.type` carries the type slug and `data.url` the path to re-fetch, so you
can invalidate one page rather than a whole site.

> **WARNING: Events do not cascade through links**
>
> A change to an entry fires that entry's event and nothing for the entries
> that link to it. Renaming a category fires one `content.entry.updated`, not
> one per product in it, because a popular target would fan out without bound.
>
> A page that embeds linked entries therefore has two ways to stay fresh: key its
> cache on every id it embedded and drop it when any of them fires, or call
> `GET /v1/content/{type}/{entryId}/referenced-by` on the changed entry to find
> the pages to re-fetch.

**Structure**

| Event | |
|---|---|
| `content.type.updated` | A field was added, removed or relabelled. The response **shape** changed even though no entry did. |
| `content.type.deleted` | The type and everything in it is gone. |

**Media**

| Event | |
|---|---|
| `media.asset.updated` | Alt text, dimensions or the file itself changed. |
| `media.asset.deleted` | The file is gone. Entries still reference it and now resolve to `url: null`. |

These matter because no entry is touched when a picture changes. Without them
a cached page keeps serving the old alt text, or a hole where an image was.

Both carry `used_by`: every live page showing the picture, so you can purge
those pages and nothing else.

```json
{
  "event": "media.asset.updated",
  "data": {
    "id": "med_2c8RkXqGn4wVtYbMhJpDs",
    "file_name": "launch.png",
    "content_type": "image/png",
    "alt": "The launch team on stage",
    "width": 1600,
    "height": 900,
    "status": "ready",
    "used_by": [
      {
        "type": "blog_article",
        "id": "art_6cRpCYFMCnAqFyCMpM4LF",
        "slug": "how-we-deploy",
        "title": "How we deploy",
        "url": "/v1/blog/articles/how-we-deploy"
      },
      {
        "type": "entry",
        "id": "ent_7RWDczfdrJnrnMjf7DuCd",
        "slug": null,
        "title": null,
        "content_type": "service",
        "url": "/v1/content/service/ent_7RWDczfdrJnrnMjf7DuCd"
      }
    ],
    "used_by_total": 2
  }
}
```

`type` is one of `blog_article`, `blog_category`, `kb_article`,
`kb_collection`, `author` or `entry`. A picture counts as used when it is a
page's featured or share image, an image or gallery field, or an image inside
rich text. Only live pages are listed: drafts and scheduled articles have not
been served to anyone, so nobody has them cached. A collection has no page of
its own and comes with `url: null`; purge whatever you built from
`/v1/kb/tree`.

The list stops at 200. When `used_by_total` is larger than the list, the
picture is on more pages than it is worth naming, and a broad purge is the
right answer.

**Releases**

| Event | |
|---|---|
| `release.published` | A different release is live. **Every read can change at once.** |
| `release.updated` | A release was renamed. |
| `release.deleted` | A non-default release was removed. Live content is unaffected. |

**Blog and knowledge base**

| Event | |
|---|---|
| `blog.article.published` | An article became visible. |
| `blog.article.updated` | A **live** article changed. |
| `blog.article.unpublished` | It is a 404 now. |
| `blog.article.deleted` | The article is gone. |
| `kb.article.published` · `.updated` · `.unpublished` · `.deleted` | The same, for the knowledge base. |

Names are `module.subject.verb`, so you can match on the `blog.` prefix or
switch on the whole string.

> **INFO: What to invalidate**
>
> `content.entry.*` is one page. `content.type.*` is every page of that type.
> `media.asset.*` is every page showing that picture, and `used_by` names them.
> `release.published` is everything.

Two silences are deliberate:

- **Editing a draft sends nothing.** You cannot see the draft, so nothing about
  your copy changed and a rebuild would produce identical output.
- **Publishing with a future date sends nothing yet.** That is a schedule. The
  event fires when the date arrives. `next_scheduled_change` on
  `GET /v1/changes` tells you when that will be.

## The request

```http
POST https://your-site.com/hooks/zevcontent
Content-Type: application/json
X-ZevContent-Timestamp: 1788320123
X-ZevContent-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a…
X-ZevContent-Delivery: whd_2c8RkXqGn4wVtYbMhJpDs
```

```json
{
  "id": "whd_2c8RkXqGn4wVtYbMhJpDs",
  "event": "blog.article.published",
  "created_at": "2026-09-02T11:41:52.000Z",
  "data": {
    "id": "art_6cRpCYFMCnAqFyCMpM4LF",
    "slug": "how-we-deploy",
    "title": "How we deploy",
    "status": "published",
    "published_at": "2026-09-02T11:41:50.000Z",
    "updated_at": "2026-09-02T11:41:52.000Z",
    "url": "/v1/blog/articles/how-we-deploy"
  }
}
```

> **WARNING: The payload is a notification, not the content**
>
> It carries identity and dates, never the body. Fetch the article when you get
> one: that is the only version guaranteed to be current, and it keeps us from
> signing megabytes of rich text and storing a copy of it for every retry.

## Verifying the signature

The signature is HMAC-SHA256 over `<timestamp>.<body>`, keyed by the endpoint's
secret, and prefixed `sha256=`. **Verify it against the raw request body**, not
a re-serialised parse: re-encoding JSON does not reproduce the bytes we signed.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, headers, secret) {
  const timestamp = headers['x-zevcontent-timestamp'];
  const presented = headers['x-zevcontent-signature'];

  // Reject anything older than five minutes: without this, a captured
  // request stays valid forever, because a signature over a body alone
  // does not age. The timestamp is INSIDE the signed material, so it
  // cannot be changed without breaking the signature.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected =
    'sha256=' +
    createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

  // Constant time. A plain === leaks how many leading characters
  // matched, which is enough to reconstruct a signature byte by byte.
  const a = Buffer.from(expected);
  const b = Buffer.from(presented ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

The secret is shown **once**, when you create the endpoint.

## Delivery, retries and failure

- Delivery is **queued, not inline**. Publishing never waits on your server and
  never fails because your server is down.
- A non-2xx or a timeout is retried **five times**: after 10s, 1m, 5m, 15m and
  1h. Answer `2xx` as soon as you have the event; do the work afterwards.
- Requests time out after **10 seconds**.
- **`X-ZevContent-Delivery` is a delivery id, and a retry reuses it.** Treat
  handling as idempotent: key on that id, or on `data.id` plus `updated_at`.
- After **20 consecutive failures** an endpoint is disabled automatically and
  the dashboard says why. Re-enable it in one click; the failure count resets.

> **WARNING: At-least-once, not exactly-once**
>
> A delivery interrupted after your server answered but before we recorded it is
> retried. That is the honest trade: the alternative is dropping events on our
> crash, which is worse. Make your handler safe to run twice.

Delivery history is kept for **30 days** and visible per endpoint in the
dashboard, which is where "did that publish reach them?" gets answered.