Event-Driven Cache Invalidation with Webhooks

Relying on max-age alone to expire stale content after an edit means readers see outdated pages until the freshness lifetime runs out — unacceptable for a CMS where an editor expects a correction to be visible within seconds. Tag-based cache invalidation solves the “which URLs are affected” problem; wiring it to a webhook solves the “when do we purge” problem, turning a content change into an immediate, targeted CDN purge instead of a wait for TTL expiry.

Prerequisite Concepts

Before building the webhook pipeline, be comfortable with:

Step-by-Step Resolution

Step 1 — Register the source webhook

Most headless CMSs and database change-data-capture (CDC) systems support outbound webhooks on content lifecycle events. Configure one to fire on publish, update, and delete, pointed at a serverless handler:

curl -X POST "https://api.cms.example.com/v1/webhooks" \
  -H "Authorization: Bearer ${CMS_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "url": "https://purge-handler.example.workers.dev/cms-webhook",
    "events": ["entry.publish", "entry.update", "entry.unpublish"],
    "secret": "'"${WEBHOOK_SIGNING_SECRET}"'"
  }'

A typical delivered payload on publish looks like:

{
  "event": "entry.publish",
  "eventId": "evt_9f2c7a1e",
  "occurredAt": "2026-07-06T14:32:05Z",
  "entity": {
    "id": "product-9472",
    "type": "product",
    "tags": ["product-9472", "category-footwear", "tenant-eu"]
  }
}

Step 2 — Verify the webhook signature

Never process an unauthenticated webhook body. Validate the HMAC signature the CMS sends alongside the payload before touching the purge API:

# Example inbound request as the CMS sends it
curl -X POST https://purge-handler.example.workers.dev/cms-webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: sha256=6f2c1e9b4a7d3f0c8e1a5b2d9c4f7e3a1b8d5c2f0e9a4b7d3c1f8e5a2b0d9c4f" \
  --data @payload.json

The handler recomputes the signature over the raw request body using the shared secret and compares it in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

function isValidSignature(rawBody, headerValue, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(headerValue || "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Reject any request that fails this check with 401 before it reaches the purge logic.

Step 3 — Map the event to cache tags and purge

Once verified, translate the entity into the tag set it was published with — ideally the CMS payload already lists the tags, as in the example above, so no separate lookup is needed. Call the CDN’s tag-purge API:

curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags": ["product-9472", "category-footwear"]}'
HTTP/2 200
Content-Type: application/json

{"result":{"id":"7c3e9a1b2f4d5e6a"},"success":true,"errors":[],"messages":[]}

On Fastly, the equivalent is a per-key surrogate purge:

curl -X POST "https://api.fastly.com/service/${SERVICE_ID}/purge/product-9472" \
  -H "Fastly-Key: ${FASTLY_API_TOKEN}" \
  -H "Fastly-Soft-Purge: 1"

Fastly-Soft-Purge: 1 marks the object stale rather than evicting it outright, letting stale-while-revalidate serve the outgoing version while a fresh copy is fetched in the background — useful when you want immediate purges without an origin traffic spike.

Step 4 — Apply idempotency, debouncing, and ordering

Webhook delivery is typically at-least-once, not exactly-once — the CMS may redeliver an event if your handler times out or returns a non-2xx status, even if the purge already succeeded. Guard against redundant purges by recording processed event IDs:

async function handleWebhookEvent(event, kv) {
  const dedupeKey = `processed:${event.eventId}`;
  if (await kv.get(dedupeKey)) {
    return { status: 200, body: "already processed" };
  }
  await kv.put(dedupeKey, "1", { expirationTtl: 3600 });
  return processPurge(event);
}

A burst of rapid edits to the same entity (an editor saving a draft repeatedly) can trigger a purge storm. Debounce by coalescing events for the same entity within a short window before issuing a single purge:

const PENDING_WINDOW_MS = 2000;

async function debouncedPurge(entityId, tags, queue) {
  const existing = await queue.get(entityId);
  if (existing) {
    clearTimeout(existing.timer);
  }
  const timer = setTimeout(() => {
    purgeTags(tags);
    queue.delete(entityId);
  }, PENDING_WINDOW_MS);
  queue.set(entityId, { timer, tags });
}

Finally, guard against out-of-order delivery. Compare each event’s occurredAt timestamp (or a monotonic version number, if the CMS provides one) against the last value processed for that entity, and discard anything older:

async function isStaleEvent(event, kv) {
  const lastKey = `last-seen:${event.entity.id}`;
  const lastSeen = await kv.get(lastKey);
  if (lastSeen && new Date(event.occurredAt) <= new Date(lastSeen)) {
    return true;
  }
  await kv.put(lastKey, event.occurredAt);
  return false;
}

Step 5 — Verify propagation

After the purge call returns success, confirm the CDN actually served fresh content on the next request:

# Before purge: cached copy with non-zero Age
curl -sI https://example.com/products/9472 | grep -iE '(age|cf-cache-status)'
# Age: 3421
# CF-Cache-Status: HIT

# Trigger the purge, then re-request
curl -sI https://example.com/products/9472 | grep -iE '(age|cf-cache-status)'
# Age: 0
# CF-Cache-Status: MISS

Age: 0 combined with CF-Cache-Status: MISS confirms the edge fetched a fresh response from origin rather than serving the previously cached copy. If you’re purging across multiple regions, repeat the check from more than one vantage point (a different --resolve target, or a multi-region curl service) since purge propagation is not instantaneous everywhere at once.

Expected Output / Verification

  • The purge API call returns a 200 with a request/purge ID you can correlate in the CDN’s own logs.
  • The next request for an affected URL shows Age: 0 and a MISS status instead of the prior HIT.
  • Duplicate webhook deliveries (verify by sending the same eventId twice) result in only one purge API call, confirmed by checking your idempotency store or purge audit log.
  • A burst of five rapid edits to the same entity within the debounce window produces exactly one purge call, not five.

Edge Cases

  • Non-2xx responses trigger CMS retries — if your handler crashes or times out after the purge succeeded but before returning a response, the CMS will redeliver the same event. This is precisely why the idempotency check in Step 4 must run before, not after, the purge call’s side effects are considered final.
  • Purge API rate limits — CDNs cap purge calls per minute. A high-velocity content pipeline (bulk imports, migrations) can exceed this; batch tags into a single purge call where the API supports multiple tags per request rather than issuing one call per changed entity.
  • Clock skew between CMS and handler — if the CMS server’s clock and your handler’s clock disagree, timestamp-based ordering checks in Step 4 can misfire. Prefer a CMS-provided monotonic version number over wall-clock timestamps when available.
  • Cross-region propagation lag — a purge call typically returns success once accepted, not once every edge PoP worldwide has applied it. For content with strict correctness requirements (pricing, availability), treat the purge as “in flight” for a few seconds rather than instantaneous, and verify from multiple regions before assuming global consistency.

Frequently Asked Questions

Why do I need idempotency handling if webhooks are only sent once?

Most webhook providers guarantee at-least-once delivery, not exactly-once. Network timeouts, non-2xx responses, or provider-side retries can redeliver the same event. Without deduplication by event ID, a single content change can trigger redundant purge API calls, which is wasteful and can hit CDN purge rate limits.

What happens if webhooks arrive out of order?

If an older event is processed after a newer one for the same entity, the cache can be purged based on stale data, or an unnecessary purge can evict an already-fresh entry. Compare each event’s timestamp or version number against the last processed value for that entity and discard anything older.

How long does purge propagation take across CDN edge nodes?

It varies by vendor and purge type. Cloudflare’s instant purge (single URL) typically propagates globally in under a second; tag-based purges can take slightly longer because each edge location performs an inverted-index lookup. Fastly’s soft purge and surrogate-key purges are typically sub-second but should still be verified with the curl checks in Step 5 rather than assumed.

Should the purge call happen synchronously in the webhook handler or be queued?

For low-to-moderate event volume, a synchronous purge inside the handler (as shown above) is simpler and easier to reason about. At high volume, or when debouncing across a window as in Step 4, queue the purge intent and process it asynchronously so the webhook response returns quickly and the CMS doesn’t treat a slow purge as a delivery failure.


Back to Tag-Based Cache Invalidation Patterns