# React to content changes with webhooks

:::note[Webhooks are outgoing only]
Source CMS webhooks are **outgoing only**: the [site](/start-here/glossary/#site) sends HTTP requests to URLs you register; it has no inbound webhook mechanism. When an external system needs to push data *into* the site, that's not a webhook job: use the [JSON:API](/start-here/glossary/#jsonapi); for how, see [writing content](/source-cms/content-api/writing-content/).
:::

**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

- A [webhook](/start-here/glossary/#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

- 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](/source-cms/content-api/writing-content/).

## Steps

<Steps>

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

   <Tabs syncKey="framework">

   <TabItem label="Next.js">

   Server-rendered with [ISR](/start-here/glossary/#isr) (Incremental Static Regeneration: Next.js re-renders cached pages on demand), so the receiver invalidates:

   ```ts
   // 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](/start-here/glossary/#environment).

   </TabItem>

   <TabItem label="Astro">

   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_dispatch` trigger to the deploy workflow from [deploy from your own CI/CD](/source-cms/deploy/external-ci/):

   ```yaml
   # .github/workflows/deploy-acquia.yml: add alongside the push trigger
   on:
     push:
       branches: [main]
     repository_dispatch:
       types: [content-changed]
   ```

   GitHub's dispatch API requires an `Authorization` header, 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_TOKEN` here is a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with `Contents: Read and write` on the repository, stored as the relay's environment variable:

   ```ts
   // relay: verify ?token=, then trigger the rebuild
   await 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](/start-here/glossary/#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.

   </TabItem>

   <TabItem label="Nuxt">

   Server-rendered with `routeRules` caching (as set up in the [direct-fetch guide](/source-cms/content-api/fetch-content/)), so the receiver empties Nitro's cache:

   ```ts
   // 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_SECRET` in the frontend's [environment](/start-here/glossary/#environment).

   </TabItem>

   <TabItem label="TanStack Start">

   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](/source-cms/content-api/fetch-content/)). A route file exporting only `server` handlers is a server route: none of it reaches the browser bundle, which is why it may import the server-only data module directly.

   ```ts
   // 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_SECRET` in the frontend's [environment](/start-here/glossary/#environment).

   </TabItem>

   </Tabs>

   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. ### Register the webhook on the site

   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](/start-here/glossary/#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. ### Trigger it and watch it land

   Edit and save any entry of a type covered by your triggers (or send a `PATCH` [via the API](/source-cms/content-api/writing-content/)). 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`:

   ```jsonc
   {
     "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

   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.

</Steps>

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

- [Webhook payload & delivery reference](/source-cms/reference/webhooks/): event types, payload schema, and delivery semantics as lookup material.
- [Writing content via the API](/source-cms/content-api/writing-content/): the other half of a two-way integration: JSON:API in, webhooks out.
- [Deploy a headless frontend](/source-cms/deploy/): put the app on Acquia hosting, then register the deployed URL on your production webhooks.
