Reference
Webhooks
Webhooks send a signed HTTP POST to your endpoint when published content changes: which document, every URL it is served at, and the URLs it stopped serving. The contract, the signature, retries and the three events.
A webhook sends a signed POST to an endpoint you choose whenever something your visitors see changes: a page is published, a page is taken down, or a change affects the whole site. Each event names the document and every URL to refresh, so a receiver can purge a CDN, rebuild a static site or notify another system.
Webhooks belong to a team. A team can have several, each subscribed to its own set of events.
Setting up a webhook
In Studio, open Settings → API → Webhooks and add one:
| Field | What to enter |
|---|---|
Label | A name for the list, e.g. CDN purge. |
Provider | Custom endpoint for your own receiver. Netlify and Vercel build hooks and GitHub repository_dispatch are also available. |
Target | The public https:// URL of your receiver. |
Events | The events to send (see below). Site changed is never selected for you: it asks the receiver to refresh the whole site. |
Signing secret | A random secret you also give the receiver, e.g. the output of openssl rand -hex 32. Leave it empty to have Strife generate one. |
The secret is shown once. If you leave the field empty, Strife generates a secret and shows it once, right after you save: copy it to your receiver then. After that, Strife shows only its last four characters, and so does a secret you entered yourself. To change a secret later, edit the webhook and enter a new one.
Test sends a sample event to the target and shows the response. The sample's documentId is webhook-test, so a receiver can recognise it. The test sends the first event the webhook subscribes to; through the API, POST /teams/{teamId}/webhooks/{id}/test?eventType=site.changed picks another one.
Events
| Event | Sent when | status |
|---|---|---|
content.published | A document is published, from Studio, an AI assistant or a scheduled publish. Also when a published page is changed in place without a publish, e.g. moved under another parent or given a new slug. | published |
content.unpublished | A published document is archived or deleted. | archived or deleted |
site.changed | Something every page may depend on changed: a content type (template), the team's languages, or the content index deployed with strife push. | template, localization, locales or index |
Receivers written for earlier payloads get more events now. Archiving and deleting send content.unpublished, and publishing from an AI assistant or changing a published page in place sends content.published. A build hook subscribed to both events will run for these too.
When site.changed arrives
A change to a content type re-indexes every page of that type, and a purge that lands before the re-index finishes lets a CDN cache the old page again. So site.changed is held back:
- until there have been 30 seconds without another site change on the team, and
- until the content index your site reads has caught up.
A burst of changes, such as an editor adjusting a content type several times or one strife push, ends in one site.changed. Expect it about half a minute after the last change, longer while a large site re-indexes. It is never held for more than 30 minutes.
Only changes your site can see count. Renaming a content type, or changing its icon, hint, description, or search and filter fields in Studio, sends nothing. A content index you deploy yourself, outside strife push, is not seen either; refresh your cache after deploying one.
The request
POST /strife-webhook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Strife-Webhooks/1.0
X-Strife-Event: content.published
X-Strife-Delivery: 3f1c2b9e-6d0a-4c55-9f4e-0b8a1d2c3e4f
X-Strife-Signature: sha256=9ab2…
{"v":2,"eventType":"content.published","eventId":"3f1c2b9e-6d0a-4c55-9f4e-0b8a1d2c3e4f", …}| Header | Value |
|---|---|
X-Strife-Event | The event type, the same as eventType in the body. |
X-Strife-Delivery | The event's id. It stays the same on every retry: deduplicate on it. |
X-Strife-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with the signing secret. |
The body
{
"v": 2,
"eventType": "content.published",
"eventId": "3f1c2b9e-6d0a-4c55-9f4e-0b8a1d2c3e4f",
"occurredAt": "2026-10-08T09:12:03Z",
"teamId": "…",
"workspaceId": "…",
"userId": "…",
"documentId": "a1b2c3d4-…",
"collection": "Articles",
"locales": ["en", "sv"],
"status": "published",
"url": "/news/hello-again",
"urls": { "en": "/news/hello-again", "sv": "/nyheter/hej" },
"previousUrls": ["/news/hello"],
"scope": "document"
}| Field | Meaning |
|---|---|
v | The payload version, 2. Fields are only ever added within a version. |
eventType, eventId, occurredAt | The event, its id (the X-Strife-Delivery header) and when it happened (UTC). |
teamId, workspaceId, userId | Where it happened and who did it. userId is null for a scheduled publish. |
documentId, collection | The document and its content type's collection. Both are null on site.changed. |
locales | The languages a publish covered. Empty on other events. |
status | See Events. |
urls | Language → path where the document is served after the change. Empty on content.unpublished and site.changed, and for a content type without URLs. |
previousUrls | Paths that served the document before the change and no longer do: its old slugs, every path of an unpublished page, and the old paths of the pages below a page that was moved or renamed. |
scope | document: urls and previousUrls are complete. site: they may not be, so refresh everything. Always site on site.changed. On a document event, site means Strife could not read the URLs reliably at that moment, or the document was moved, renamed or taken down with more than 250 pages below it. |
url | The version 1 field, kept for older receivers: the default language's entry in urls, or on content.unpublished the first of previousUrls. |
Paths start with / and carry no host. Prefix them with your site's address for the language, so that /news/hello-again on https://example.com becomes https://example.com/news/hello-again. previousUrls has no language, so refresh each one on every host you serve.
urls and previousUrls name the document's own pages. Pages that only show it, such as a home page, a listing or a menu, aren't in them: see Purge your CDN cache when content changes for how to handle those.
Verifying the signature
Compute the HMAC over the raw body bytes, before parsing any JSON, and compare in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStrifeSignature(rawBody, header, secret) {
if (!header?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(header.slice('sha256='.length), 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Answer 401 to a request whose signature does not match.
Delivery and retries
- At least once. An event can arrive twice. Deduplicate on
X-Strife-Delivery. - 2xx means delivered. Answer quickly, within 30 seconds; a slower answer counts as a failure.
- 5xx, timeouts and network errors are retried. Up to 3 attempts, waiting 1–2 and then 2–3 seconds between them.
- 4xx is final. It is taken to mean the webhook is misconfigured, so the event is not retried. Keep 4xx for a bad signature or a request you will never accept; when a downstream call fails (a CDN purge, a build hook), answer
502so Strife tries again. - No guarantee after three attempts. Give anything you cache a lifetime too, so a lost event can't keep a page stale forever.
Events are sent in the background after the change is saved, so a slow or failing receiver never slows down editors.