> From the [Strife developer docs](https://strife.app/docs/reference/webhooks). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# 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. |

> [!WARNING]
> **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` |

> [!NOTE]
> **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

```http
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

```json
{
  "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](#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](https://strife.app/docs/how-to-guides/purge-cdn-cache.md) 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:

```js title="verify.js"
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 `502` so 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.
