Docs
Dashboard

API reference

Webhooks

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.

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.publishedAn entry of one of your own types became visible for the first time.
content.entry.updatedA live entry changed.
content.entry.unpublishedIt is a 404 now. The draft survives.
content.entry.deletedThe 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.

Structure

Event
content.type.updatedA field was added, removed or relabelled. The response shape changed even though no entry did.
content.type.deletedThe type and everything in it is gone.

Media

Event
media.asset.updatedAlt text, dimensions or the file itself changed.
media.asset.deletedThe 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.

{
  "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.publishedA different release is live. Every read can change at once.
release.updatedA release was renamed.
release.deletedA non-default release was removed. Live content is unaffected.

Blog and knowledge base

Event
blog.article.publishedAn article became visible.
blog.article.updatedA live article changed.
blog.article.unpublishedIt is a 404 now.
blog.article.deletedThe article is gone.
kb.article.published · .updated · .unpublished · .deletedThe same, for the knowledge base.

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

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

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
{
  "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"
  }
}

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.

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.

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

Updated at, Sunday, September 20, 2026