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.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- 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.
Prerequisites
Section titled “Prerequisites”- 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.
-
Create the receiver in your frontend
Section titled “Create the receiver in your frontend”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_SECRETin the frontend’s environment.Static output, so the receiver is a rebuild trigger. The shape depends on where your builds run:
A hosted build hook (zero code). Some hosts expose a deploy hook as a plain URL that accepts an unauthenticated POST; if yours does, register that URL directly as the webhook URL. The unguessable token inside it plays the secret’s role, so the URL is a credential in effect: anyone holding it can trigger builds. Keep it out of the repository and reissue it if it leaks.
A GitHub Actions rebuild (one workflow trigger plus a relay). Add a
repository_dispatchtrigger to the deploy workflow from deploy from your own CI/CD:# .github/workflows/deploy-acquia.yml: add alongside the push triggeron:push:branches: [main]repository_dispatch:types: [content-changed]GitHub’s dispatch API requires an
Authorizationheader, and CMS webhooks send a plain POST (the secret rides in the URL only), so a minimal relay bridges the two. Any small server endpoint you already run works (shape it like the receivers above: a route handler that checks the URL secret first), then it calls GitHub.GITHUB_TOKENhere is a fine-grained personal access token withContents: Read and writeon the repository, stored as the relay’s environment variable:// relay: verify ?token=, then trigger the rebuildawait fetch('https://api.github.com/repos/OWNER/REPO/dispatches', {method: 'POST',headers: {Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,Accept: 'application/vnd.github+json',},body: JSON.stringify({ event_type: 'content-changed' }),});One note if your site builds on Pipelines: its runs start from git events, the CLI, or the Cloud Platform user interface; there is no public trigger URL, so webhook-driven rebuilds use one of the shapes above.
Rebuilds take minutes, not seconds; if editors publish in bursts, consider whether an SSR adapter with cache invalidation (the Nuxt tab’s pattern applies) fits better than rebuild-per-save.
Server-rendered with
routeRulescaching (as set up in the direct-fetch guide), so the receiver empties Nitro’s cache:server/api/content-changed.post.ts export default defineEventHandler(async (event) => {const token = getQuery(event).token;if (token !== process.env.REVALIDATION_SECRET) {throw createError({statusCode: 401,statusMessage: 'Unauthorized',});}const payload = await readBody(event);// The payload is JSON describing the change,// including who triggered it and when.console.log('content changed', payload);// Start broad: drop every cached entry. Narrow// to specific keys once you map entities to// routes.const cache = useStorage('cache');const keys = await cache.getKeys();await Promise.all(keys.map((key) => cache.removeItem(key)));return { revalidated: true };});Deploy this, and set
REVALIDATION_SECRETin the frontend’s environment.Server-rendered, and the framework has no cache of its own, so the receiver empties the one the app keeps (the module-scope entry from the direct-fetch guide). A route file exporting only
serverhandlers is a server route: none of it reaches the browser bundle, which is why it may import the server-only data module directly.src/routes/api/content-changed.ts import { createFileRoute } from '@tanstack/react-router';import { clearContentCache } from '../../lib/acquia.server';export const Route = createFileRoute('/api/content-changed')({server: {handlers: {POST: async ({ 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: drop every cached entry. Narrow// to specific keys once you map entities to// routes.clearContentCache();return Response.json({ revalidated: true });},},},});Deploy this, and set
REVALIDATION_SECRETin 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. -
Register the webhook on the site
Section titled “Register the webhook on the site”In your site’s admin UI, go to
API > Webhooksand create a new webhook. On the Add webhook page, fill in:Webhook label: a name for humans, for examplefrontend-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, anddeletion. 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. -
Trigger it and watch it land
Section titled “Trigger it and watch it land”Edit and save any entry of a type covered by your triggers (or send a
PATCHvia 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 aGETon that entity) with the change metadata added underdata.metaand a top-leveltimestamp:{"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-Keyheader (a hash of the payload) so your handler can dedupe retries of the same change, and aUser-AgentofAcquia CMS.Reload the affected frontend page: it re-renders with the fresh content.
-
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 Historypage (underAPI > 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.
When something goes wrong
Section titled “When something goes wrong”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.
Next steps
Section titled “Next steps”- Webhook payload & delivery reference: event types, payload schema, and delivery semantics as lookup material.
- Writing content via the API: the other half of a two-way integration: JSON:API in, webhooks out.
- Deploy a headless frontend: put the app on Acquia hosting, then register the deployed URL on your production webhooks.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)