Routing CMS Webhooks to Staging, Production and Preview Environments
Within Webhook-Triggered Rebuilds, this guide solves a configuration problem that grows with every environment you add: making sure publishes in the CMS’s staging environment rebuild the staging site, production publishes rebuild production, and preview deployments for feature branches never receive production events at all.
Headless CMS platforms have environments too. Contentful has environments and environment aliases, Sanity has datasets, Storyblok has spaces and branches, and Strapi or Directus usually run as separate instances per environment. Each frontend environment reads from one of them. When webhooks are configured by hand, per environment, drift is almost guaranteed: a webhook left pointing at a deleted preview URL, a staging webhook that rebuilds production, or an environment promotion that changes content without firing any webhook at all.
The Problem
A retailer uses Contentful with three environments: master behind the production site, staging behind the staging site, and short-lived environments for content model migrations. Webhooks were set up by different people over two years. An audit found five problems: two webhooks still pointed at preview deployments that no longer existed and failed on every publish; the staging environment’s webhook triggered the production build hook, so every test publish rebuilt production; the production site did not update when a migrated environment was promoted by switching the master alias, because alias changes fire no entry webhooks; and one webhook had no signature secret at all.
How Environment Routing Works
Two principles fix all of it. First, one webhook configuration per CMS space or project, pointing at one router, rather than one per frontend deployment. The router reads the environment from the payload, which every major CMS includes, and forwards the event to the right target using an explicit map kept in code. Second, treat environment changes that bypass entry webhooks, such as alias switches, dataset copies or database restores, as deploy-level events that trigger a full rebuild or cache flush of the affected site.
Preview deployments, for example one per pull request, do not need webhooks at all. They should render drafts on demand in draft mode, as described in draft state management, so they are never in the webhook map and can come and go without anyone touching the CMS configuration.
Implementation
The router is a single route handler with a map from CMS environment to target. Each target has its own verification secret and its own action: revalidation on an ISR deployment or a build hook for a static one.
// app/api/webhook-router/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
interface Target {
secretEnv: string; // name of the env var holding this environment's webhook secret
action: "revalidate" | "build";
url: string; // revalidation endpoint or build hook
tokenEnv?: string; // for authenticated revalidation endpoints
}
// Explicit and reviewed: which CMS environment feeds which deployment.
const ROUTES: Record<string, Target> = {
master: { secretEnv: "WEBHOOK_SECRET_MASTER", action: "revalidate", url: "https://www.example.com/api/revalidate", tokenEnv: "REVALIDATE_TOKEN_PROD" },
staging: { secretEnv: "WEBHOOK_SECRET_STAGING", action: "build", url: process.env.STAGING_BUILD_HOOK ?? "" },
};
function verify(raw: string, sig: string | null, secret: string | undefined): boolean {
if (!sig || !secret) return false;
const expected = createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: Request): Promise<Response> {
const raw = await req.text();
// Contentful puts the environment in the payload; read it before choosing the secret.
const env = (JSON.parse(raw) as { sys?: { environment?: { sys?: { id?: string } } } }).sys?.environment?.sys?.id ?? "";
const target = ROUTES[env];
if (!target) return Response.json({ ignored: `environment ${env} has no target` }, { status: 202 });
if (!verify(raw, req.headers.get("x-webhook-signature"), process.env[target.secretEnv])) {
return new Response("invalid signature", { status: 401 });
}
const res = await fetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(target.tokenEnv ? { Authorization: `Bearer ${process.env[target.tokenEnv]}` } : {}),
},
body: target.action === "revalidate" ? raw : "{}",
});
return Response.json({ env, forwarded: target.action, status: res.status }, { status: 202 });
}
Reading the environment before verifying the signature is safe here because the value only selects which secret to check against; nothing else happens until verification succeeds. Unknown environments, such as a temporary migration environment, are acknowledged and ignored, which keeps the CMS from retrying and flooding logs.
For alias switches and other silent operations, add a step to the pipeline that performs them. A migration script that switches the master alias to a new environment should, as its final step, call the production site’s full revalidation or build hook, and purge the CDN.
Testing the routing map
Because the map decides which site a publish touches, test it like business logic. Keep one stored webhook fixture per CMS environment, sign each with the matching test secret, and assert in a unit test that the router forwards production events only to production, staging events only to staging, unknown environments nowhere, and events signed with another environment’s secret nowhere at all. That last case matters: it proves that a leaked staging secret cannot trigger production work, which is the main reason for per-environment secrets.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Webhooks in the CMS | one per space or project, to the router | One place to audit and update. |
| Secrets | one per CMS environment | A staging leak cannot trigger production. |
| Route map | in code, reviewed | Environment wiring changes are visible in pull requests. |
| Unknown environments | acknowledge with 202, ignore | Migration environments do not cause retries or errors. |
| Preview deployments | no webhooks, draft mode on demand | Nothing to configure per branch. |
| Alias switches, restores | trigger full rebuild from the pipeline | These fire no entry webhooks. |
Gotchas & Edge Cases
- Aliases in payloads. Contentful payloads carry the environment id, not the alias. After switching
masterto a new environment such asmaster-2026-09, map that id to production too, or derive the target by resolving the alias through the management API. - Preview deployments with production credentials. A branch deployment configured with production delivery tokens and no draft mode shows production content, which is fine, but it must never register its own webhook against production, or every publish triggers dozens of builds.
- Environment-specific content ids. Entry ids are usually preserved when environments are cloned, which is convenient, but revision numbers can differ. Include the environment in any revision tracking, as in the idempotency guide.
- Webhook payload templates. Some CMSs let you customize the payload, and a template that drops the environment field breaks routing. Keep the default payload or include the environment explicitly.
Worked Example
The retailer replaced its seven hand-configured webhooks with one webhook per Contentful space pointing at the router, a map with two environments, and a final step in the migration script that rebuilds production after an alias switch. The first week surfaced three staging publishes that would previously have rebuilt production, now correctly routed to staging, and the next content model migration went live with a single full rebuild instead of the previous pattern of stale pages for hours after the alias switch.
Rollout Checklist
- List every webhook in every CMS space, with its target and secret status.
- Deploy the router with an explicit environment map and per-environment secrets.
- Replace per-deployment webhooks with one webhook per space pointing at the router.
- Remove webhooks for preview deployments and use draft mode instead.
- Add rebuild steps to pipelines that switch aliases, copy datasets or restore backups.
Frequently Asked Questions
Why not configure one webhook per frontend environment in the CMS?
It works for two environments and one person. It drifts as soon as environments multiply or people change, and the CMS settings page is a poor place to review routing. A router keeps the map in code, with history and review.
How do I route webhooks from self-hosted Strapi or Directus?
Each environment usually runs its own instance, so each instance’s webhook can point at the router with an environment query parameter or header of its own, plus its own secret. The map then keys on that parameter instead of a payload field.
Should staging content ever update production?
Only through an explicit promotion, such as an alias switch or a content migration, never through webhooks. The router’s map makes that rule enforceable: staging events simply have no production target.
How do we route webhooks for a second production site on the same space?
Add the second site as another target for the same environment, with its own revalidation endpoint and token, and forward each event to both. Keep the fan-out in the router so the CMS configuration stays a single webhook.
Does the router add latency to publishes?
A few milliseconds for verification and one extra HTTP hop to the target, which is negligible next to regeneration or build time.
What happens if the router is down?
The CMS retries deliveries for a while and then gives up. Monitor delivery failures, as described in monitoring webhook delivery, and keep a scheduled backstop that revalidates recently published entries, so an outage delays updates instead of losing them.