> From the [Strife developer docs](https://strife.app/docs/how-to-guides/purge-cdn-cache). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# Purge your CDN cache when content changes

Keep a CDN or caching proxy in front of a Strife site fresh: a cache lifetime as the safety net, and a webhook receiver that purges exactly the pages that changed. Works with any CDN; Bunny is built in.

A CDN in front of your site makes pages fast, until an editor publishes and visitors keep getting the old page. Three layers keep it fresh. Each one helps on its own.

1. **A cache lifetime** caps how stale a page can get, even if a purge is lost.
2. **A webhook receiver** purges the pages an event names, seconds after the change.
3. **Shorter lifetimes, or cache tags, for pages that only show other content.** Home pages, listings and menus aren't named in any event.

## 1. Give every page a lifetime

Send a `Cache-Control` header that lets the CDN keep a page for a few minutes and keep serving it while it fetches a new one:

```
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
```

* `s-maxage=300`: the CDN may serve its copy for 5 minutes, whatever happens to a purge.
* `max-age=0`: browsers always ask again, so a purge reaches visitors at once.
* `stale-while-revalidate`: after those 5 minutes the CDN answers from its copy while it fetches a fresh one, so visitors never wait for your server.

Set the lifetime per route. Pages that list or link to other content (a home page, a news listing) benefit from a shorter one, because no event names them.

## 2. Purge on webhooks

Add a webhook with the **Custom endpoint** provider, your receiver's URL, a signing secret you choose, and the events **Content published**, **Content unpublished** and **Site changed** (see [Webhooks](https://strife.app/docs/reference/webhooks.md)).

The receiver:

1. **Verifies the signature** over the raw body, and answers `401` if it doesn't match.
2. **Skips a delivery it has already handled** (`X-Strife-Delivery`). Events can arrive twice.
3. **Purges.**
   * `scope` is `site`: purge the whole site.
   * Otherwise: purge every path in `urls` on its language's host, and every path in `previousUrls` on every host. Old slugs and pages that were taken down are in `previousUrls`.
4. **Answers `502` if the CDN call fails**, so Strife tries again. A `4xx` would end the retries.

This one runs as a Cloudflare Worker and purges by URL. Only `purgeUrls` and `purgeEverything` are specific to Cloudflare; swap them for your CDN's API.

```js title="strife-purge.js"
// Your site's address per language. A single-host site maps every language to it.
const HOSTS = { en: 'https://example.com', sv: 'https://example.com/sv' };

export default {
  async fetch(request, env) {
    const raw = await request.text(); // verify the RAW body, before parsing
    if (!(await verify(raw, request.headers.get('X-Strife-Signature'), env.STRIFE_WEBHOOK_SECRET))) {
      return new Response('bad signature', { status: 401 });
    }

    const delivery = request.headers.get('X-Strife-Delivery');
    if (await env.SEEN.get(delivery)) return new Response('duplicate');

    const event = JSON.parse(raw);
    try {
      if (event.scope === 'site') {
        await purgeEverything(env);
      } else {
        const urls = [
          ...Object.entries(event.urls ?? {}).map(([locale, path]) => (HOSTS[locale] ?? HOSTS.en) + path),
          ...(event.previousUrls ?? []).flatMap((path) => Object.values(HOSTS).map((host) => host + path)),
        ];
        await purgeUrls([...new Set(urls)], env);
      }
    } catch {
      return new Response('purge failed', { status: 502 }); // Strife retries a 5xx
    }

    await env.SEEN.put(delivery, '1', { expirationTtl: 86400 });
    return new Response('ok');
  },
};

async function verify(raw, header, secret) {
  if (!header?.startsWith('sha256=')) return false;
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(raw));
  const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  const given = header.slice('sha256='.length);
  // Constant-time compare.
  let diff = expected.length ^ given.length;
  for (let i = 0; i < Math.min(expected.length, given.length); i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
  return diff === 0;
}

async function purgeUrls(files, env) {
  for (let i = 0; i < files.length; i += 30) { // Cloudflare takes up to 30 URLs per call on most plans
    await cloudflare({ files: files.slice(i, i + 30) }, env);
  }
}

const purgeEverything = (env) => cloudflare({ purge_everything: true }, env);

async function cloudflare(body, env) {
  const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${env.CF_ZONE}/purge_cache`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${env.CF_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`purge ${response.status}`);
}
```

> [!NOTE]
> **Using Bunny?** Strife can purge a Bunny pull zone itself, with no receiver of your own. Turn on the Bunny integration in the team's integration settings, with an API key from your Bunny account. Strife purges the same URLs a receiver would, on the site addresses configured for the team, and purges the whole site on `site.changed`.

## 3. Pages that only show other content

`urls` and `previousUrls` name a document's own pages. A news listing, a home page that shows the latest articles, or a menu on every page also changes when an article is published, but no event names them. Either:

* **Give those routes a short lifetime** (step 1). That is the simplest, and often enough.
* **Purge by tag**, on a CDN that supports it (Cloudflare `Cache-Tag`, Fastly `Surrogate-Key`, Bunny `CDN-Tag`). Have your renderer tag each response with the documents and collections it read, e.g. `doc:<documentId>` and `col:<collection>`. Then purge `doc:<documentId>` and `col:<collection>` from the event, as well as its URLs.
