Skip to content

React to content changes with webhooks

Goal: have your site notify your frontend the moment content changes, and have the frontend revalidate itself in response.

  • A webhook registered on your site, firing on content changes.
  • A handler endpoint in your frontend that verifies each delivery and triggers revalidation.
  • The delivery history as your debugging tool: where to see every delivery’s status, payload, and retries.
  • Administrator access to your site. Webhooks are configured in the admin UI.
  • A frontend deployed at a publicly reachable HTTPS URL (any of the four frameworks; the receiver step has a tab for each). To test against a local dev server instead, expose it through a tunnel (cloudflared tunnel --url http://localhost:3000, or ngrok) and register the tunnel’s HTTPS URL as the webhook target.
  • Something that changes content to test with: an editor in the CMS, or API writes.
  1. Build the receiver first so the webhook has somewhere real to point. What the receiver does follows from how your frontend renders. Static output means the webhook triggers a rebuild (the build is the cache, and only a new build changes it). Server-rendered output means the webhook invalidates a cache (the next request re-fetches). Pick the tab that matches your setup, not just your framework.

    Whatever the receiver does, anyone who discovers its URL could POST to it, so put a secret in the URL you register and reject requests that don’t carry it. You control the registered URL, so the secret rides along as a query parameter.

    Server-rendered with ISR (Incremental Static Regeneration: Next.js re-renders cached pages on demand), so the receiver invalidates:

    app/api/content-changed/route.ts
    import { revalidatePath } from 'next/cache';
    export async function POST(request: Request) {
    const url = new URL(request.url);
    const token = url.searchParams.get('token');
    if (token !== process.env.REVALIDATION_SECRET) {
    return new Response('Unauthorized', { status: 401 });
    }
    const payload = await request.json();
    // The payload is JSON describing the change,
    // including who triggered it and when.
    console.log('content changed', payload);
    // Start broad: revalidate everything. Narrow
    // to specific paths or tags once you map
    // entities to routes.
    revalidatePath('/', 'layout');
    return Response.json({ revalidated: true });
    }

    Deploy this, and set REVALIDATION_SECRET in the frontend’s environment.

    Respond fast and respond 2xx: the site counts a delivery as successful only when your endpoint accepts the payload and responds successfully. Do heavy work after responding, not before.

  2. In your site’s admin UI, go to API > Webhooks and create a new webhook. On the Add webhook page, fill in:

    • Webhook label: a name for humans, for example frontend-revalidation.
    • Webhook URL: your handler, with the secret: https://your-frontend.example.com/api/content-changed?token=<your-secret>.
    • Triggers: which events fire it.

    Webhooks fire for three kinds of content: nodes (CMS content), media entities, and taxonomy terms, and for each kind you choose among the three trigger events: creation, update, and deletion. For frontend revalidation, select all three: a deleted entry needs its page revalidated just as much as an updated one. You can create multiple webhooks, so a search-index sync and your revalidation hook can each get exactly the triggers they need.

  3. Edit and save any entry of a type covered by your triggers (or send a PATCH via the API). Delivery is queued, not synchronous with the save, so it lands a moment later. Your handler then logs the payload, which is a full JSON:API document of the changed entity (the same shape as a GET on that entity) with the change metadata added under data.meta and a top-level timestamp:

    {
    "jsonapi": { "version": "1.1", "meta": { /* ... */ } },
    "data": {
    "type": "node--article",
    "id": "202a9de2-eecb-44ea-ace5-2ceed4321739",
    "attributes": { /* the entity's fields, as in JSON:API */ },
    "relationships": { /* the entity's relationships */ },
    "meta": {
    "operation": "update", // "create" | "update" | "delete"
    "user": "editor", // triggering account name ("" for API/cron saves with no session)
    "edit_url": "https://your-site.example/node/11/edit"
    }
    },
    "links": { "self": { /* ... */ } },
    "timestamp": 1783109672 // unix epoch, stamped when the delivery is sent
    }

    Each delivery also carries an Idempotency-Key header (a hash of the payload) so your handler can dedupe retries of the same change, and a User-Agent of Acquia CMS.

    Reload the affected frontend page: it re-renders with the fresh content.

  4. Know where the delivery history lives, before you need it

    Section titled “Know where the delivery history lives, before you need it”

    Every webhook the site triggers is recorded on the Webhooks History page (under API > Webhooks). For each delivery you can inspect:

    • Status: whether the delivery succeeded.
    • Payload: exactly what was sent, useful for developing the handler against real data.
    • Retry attempts: what happened after a failure.

    When a delivery fails, the site retries five times at 30-second intervals. That’s the complete schedule: after roughly two and a half minutes of failures, that delivery is not retried again. The history page is where you confirm whether one of the five retries got through or the delivery was lost for good.

Delivery shows failed in Webhooks History

your endpoint was unreachable, too slow, or returned a non-success status. Check the retry attempts on the same history entry first: with retries every 30 seconds, a transient outage often succeeds on a later attempt. If all five retries failed, fix the endpoint and re-save the entry to fire a fresh delivery; exhausted deliveries don’t resume on their own.

401 Unauthorized in your handler logs on every delivery

the token in the registered webhook URL doesn’t match REVALIDATION_SECRET in the frontend environment. Compare the URL on the webhook’s edit form against the deployed value; rotating the secret means updating both.

The webhook never fires at all

the change you made isn’t covered by the registered triggers. Each webhook fires only for the kinds (node, media, taxonomy term) and events (creation, update, deletion) selected on it; check the webhook’s trigger configuration, and confirm you edited an entry of a covered kind.

Delivery succeeded but the page still shows stale content

the receiver returned 2xx without actually refreshing anything: the secret check short-circuited, the revalidated path or cache key doesn’t cover the page, or (static Astro) the triggered pipeline failed after the trigger endpoint accepted it. Check the receiver logs for the payload line (or the pipeline run for a rebuild trigger), then verify the path or tag mapping your receiver applies, or the cache keys your Nitro route actually cleared.

Published changes don’t appear until you redeploy

the revalidation call isn’t reaching the app. Check your app’s log for a GET /api/revalidate after you publish. If nothing arrives, the problem is on the CMS side (confirm the On-demand Revalidation configuration on the Next.js site entry, and that the base URL points at the running app). If it arrives and returns 200, the problem is the tags: see the next entry. As a stopgap while you debug, a time-based window (next: { revalidate: 60 } in customFetch) caps staleness at a minute.

Revalidation returns 200, but a page is still stale

the tags don’t line up. The route invalidates exactly the names the CMS sends, and Next.js drops exactly the fetches carrying those names, so a listing tagged articles is untouched by a call naming node_list:article. Log the incoming tags parameter, then tag your fetches with the same strings. A path call has the matching failure mode: revalidatePath needs the path as your app routes it, which is not always the entry’s path on the CMS.

401 Unauthorized in your app’s logs for every revalidation call

the Revalidate secret on the Next.js site entry and REVALIDATION_SECRET in the app’s environment have drifted apart. Both halves change together, and the app needs a restart (or a redeploy) to pick up the new value.

404 This page could not be found for the revalidation call

the app isn’t serving /api/revalidate at the registered base URL. A common one: the production URL is registered while you’re testing against localhost:3000. Update the base URL on the Next.js site entry, or register a second Next.js site for local development.

An author’s preview from the CMS lands on a 404

a registered Next.js site also derives a preview URL at <base URL>/api/draft, and your app serves nothing there. Revalidation is unaffected. To let editors see unpublished entries rendered by your frontend, serve a secret-guarded preview route of your own, fetching with a client whose scope can read drafts.

Was this page helpful?