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.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.
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.
{
"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.
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_changeonGET /v1/changestells 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
2xxas soon as you have the event; do the work afterwards. - Requests time out after 10 seconds.
X-ZevContent-Deliveryis a delivery id, and a retry reuses it. Treat handling as idempotent: key on that id, or ondata.idplusupdated_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