Content Approval Chains in Enterprise Headless Setups
An approval chain in a headless stack is a state machine — draft → review → approved → published — wired to a build pipeline that must never deploy unapproved content. The hard part isn’t the states; it’s synchronizing asynchronous CMS webhooks with immutable builds without race conditions, cache stampedes, or draft payloads leaking to production. This guide, part of Enterprise CMS Governance & Compliance, covers the four failure modes and the gateway-level controls that close them.
The editorial state machine the delivery layer must honor:
Why approval chains fail
The failures come from mismatches between the editorial state machine and the delivery layer, not from CMS misconfiguration:
- Synchronous webhook assumptions. Native webhooks fire the instant state mutates, but SSG/ISR pipelines need the payload validated before a build runs. Unvalidated triggers produce partial deployments, orphaned preview URLs, and CDN stampedes when concurrent state changes collide.
- Shared tokens across environments. One API key for both preview and production lets draft content resolve on production queries. Edge caches then hold those draft responses until TTL expiry, serving unapproved content to public traffic.
- Revision-agnostic invalidation. Purging whole content types or path prefixes instead of specific revision IDs forces full rebuilds, breaks incremental regeneration, and makes deploy latency unpredictable.
- UI-only RBAC. Roles enforced in the CMS UI don’t carry to the delivery API. Build scripts or serverless functions running during scheduled regeneration can fetch unpublished nodes whenever query filters aren’t enforced at the gateway.
Resolution
1. Isolate environments with scoped credentials
Issue distinct delivery keys per environment at the CMS gateway, each bound to explicit query scopes. The production token should:
- restrict to
status:publishedorpublished_at IS NOT NULL, - disable draft/preview queries entirely,
- allowlist build infrastructure IPs only.
// production-fetcher.ts
export async function fetchPublishedContent(endpoint: string, token: string, query: string) {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Content-State': 'published' // CMS gateway enforcement
},
body: JSON.stringify({ query })
});
if (!res.ok) throw new Error(`Delivery API Error: ${res.status}`);
const data = await res.json();
// Hard fail if any draft nodes leak through
if (data.errors?.some(e => e.extensions?.code === 'UNPUBLISHED_CONTENT')) {
throw new Error('Production token resolved unpublished content');
}
return data;
}
2. Put an idempotent router between CMS and CI/CD
A middleware layer (Cloudflare Workers, AWS Lambda, or Node/Express) intercepts, verifies, and deduplicates state transitions before any build runs — a circuit breaker between the CMS and your pipeline.
// webhook-router.ts (Node 20+ / Cloudflare Workers compatible)
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.CMS_WEBHOOK_SECRET!;
const PROCESSED_REQUESTS = new Map<string, number>(); // In-memory dedup cache (use Redis in prod)
export async function handleWebhook(req: Request): Promise<Response> {
const signature = req.headers.get('x-cms-signature') || '';
const requestId = req.headers.get('x-request-id') || crypto.randomUUID();
const payload = await req.text();
// 1. Verify HMAC signature
const hmac = createHmac('sha256', SECRET).update(payload).digest('hex');
const sigBuf = Buffer.from(signature);
const hmacBuf = Buffer.from(hmac);
// timingSafeEqual throws on length mismatch, so guard length first
if (sigBuf.length !== hmacBuf.length || !timingSafeEqual(sigBuf, hmacBuf)) {
return new Response('Invalid signature', { status: 401 });
}
// 2. Idempotency check
if (PROCESSED_REQUESTS.has(requestId)) {
return new Response('Already processed', { status: 200 });
}
PROCESSED_REQUESTS.set(requestId, Date.now());
setTimeout(() => PROCESSED_REQUESTS.delete(requestId), 300_000); // 5min TTL
// 3. State filtering
const body = JSON.parse(payload);
const validStates = ['approved', 'published'];
if (!validStates.includes(body.entry?.state)) {
return new Response('Ignored non-terminal state', { status: 200 });
}
// 4. Trigger deterministic build
await triggerBuildPipeline(body.entry.id, body.entry.revision);
return new Response('Queued', { status: 202 });
}
3. Validate payloads before triggering builds
Don’t pass raw CMS payloads to the build orchestrator. Validate against a strict schema to reject malformed relational references, unauthorized locale overrides, or missing required fields — catching them before expensive compilation. The shape below uses Zod; JSON Schema covers the equivalent constraints.
// schema-validator.ts
import { z } from 'zod';
const ContentPayloadSchema = z.object({
id: z.string().uuid(),
revision: z.string().min(1),
locale: z.enum(['en-US', 'fr-FR', 'de-DE']),
fields: z.object({
title: z.string().min(1).max(120),
slug: z.string().regex(/^[a-z0-9-]+$/),
publishDate: z.coerce.date(),
relatedAssets: z.array(z.string().uuid()).max(10)
})
});
export function validatePayload(raw: unknown): z.infer<typeof ContentPayloadSchema> {
const result = ContentPayloadSchema.safeParse(raw);
if (!result.success) {
console.error('Schema validation failed:', result.error.flatten());
throw new Error('Invalid content payload rejected');
}
return result.data;
}
4. Tag the cache by entry, not by slug
Bind invalidation to entry ids — slugs change, ids don’t — and record the revision in the audit trail rather than in cache keys, because the webhook for a new revision needs to purge whatever the previous revision cached. Give preview URLs signed tokens that expire after 24 hours or on publish. For ISR, use framework cache tags or CDN Surrogate-Key headers to purge only the affected entry.
// app/articles/[slug]/page.tsx
export async function generateStaticParams() {
// Fetch only published slugs during build
return fetchPublishedSlugs();
}
async function getArticle(entryId: string, locale: string) {
const res = await fetch(`${process.env.CMS_URL}/articles/${entryId}?locale=${locale}`, {
headers: { Authorization: `Bearer ${process.env.CMS_TOKEN_PRODUCTION}` },
// Tags name the entry, so the router can purge it whatever revision was cached.
next: { revalidate: 3600, tags: [`entry:${entryId}`, `locale:${locale}`] },
});
if (!res.ok) throw new Error(`Delivery API Error: ${res.status}`);
return res.json();
}
When the router confirms an approved state, fire a targeted revalidation through the framework’s on-demand ISR API instead of purging the whole CDN. See Next.js Incremental Static Regeneration for cache-tag propagation.
5. Mask draft fields at the API, not the client
Restrict resolution at the API layer. GraphQL directives or CMS-native permissions hide draft fields, internal metadata, and staging URLs from production delivery tokens.
directive @requireStatus(status: String!) on FIELD_DEFINITION
type Article {
id: ID!
title: String!
body: String!
internalNotes: String @requireStatus(status: "draft")
stagingUrl: String @requireStatus(status: "preview")
}
When a production token queries internalNotes, the resolver returns null or throws AccessDenied before serialization — preventing hydration mismatches and enforcing Enterprise CMS Governance & Compliance at the transport layer.
Debugging checklist
| Symptom | Root Cause | Resolution |
|---|---|---|
| Draft content appears in production | Shared delivery token or missing status filter |
Rotate production token, enforce gateway-level published scoping |
| Builds trigger on every minor edit | Webhook deduplication missing or X-Request-ID ignored |
Implement HMAC verification + revision hash caching in router |
| ISR cache serves stale approved content | Cache keys bound to slugs instead of entries | Tag by entry id and revalidate that tag from the router |
Build fails with undefined fields |
Missing schema validation before CI/CD trigger | Add zod/ajv validation gate in webhook router |
| Preview URLs leak to production | Missing expires parameter or CDN cache overlap |
Add 24h TTL to preview tokens, isolate preview CDN zone |
Verification Commands:
# Inspect CDN cache headers for revision binding
curl -I https://your-domain.com/article/slug -H "Cache-Control: no-cache"
# Validate webhook signature locally
echo -n '{"entry":{"state":"published"}}' | openssl dgst -sha256 -hmac "$CMS_WEBHOOK_SECRET"
# Audit production token query scope
curl -X POST https://api.cms.com/graphql \
-H "Authorization: Bearer $PROD_TOKEN" \
-d '{"query":"{ articles { id state } }"}'
Gotchas & Edge Cases
- Approval without a published state. Some CMSs have no separate approved state; publishing is the approval. In that case, restrict publish rights to approvers and let the router treat
publishedas the only terminal state. - Scheduled publishing. An entry approved today and scheduled for next week fires its publish webhook when the schedule runs. The router must accept it without a fresh approval, and the audit trail should link the publish to the earlier approval.
- Approvals on references. Approving a page does not approve the entries it references. Either require referenced entries to be approved first, or treat changes to shared entries as their own approval chain.
- In-memory deduplication. The example’s
Mapdoes not survive restarts or work across instances. Use Redis or another shared store in production, as the comment notes.
Worked Example
A pharmaceutical company needed medical-legal review for every public page. Its first setup triggered a build on every CMS publish, and on two occasions an editor with publish rights pushed content before review completed. The team restricted production tokens to published content, removed publish rights from editors, and put the router in front of the build pipeline so that only entries with a recorded medical-legal approval were revalidated. Over the next two quarters, every published change had a matching approval record, and the time from approval to live fell from a nightly build to under a minute because revalidation became targeted.
Rollout Checklist
- Map the approval states in the CMS and decide which state the delivery layer may read.
- Restrict production tokens to published content and publish rights to approvers.
- Route every state-change webhook through a verifying, deduplicating router.
- Validate payloads before triggering builds or revalidation.
- Tag caches by entry id and revalidate only the affected entries.
- Record every router decision in the audit trail.
Frequently Asked Questions
Should the CMS’s workflow feature or an external service hold the approval state?
Use the CMS’s workflow when it can express your stages and reviewers, because editors stay in one tool. Use an external service when approvals involve other systems or rules the CMS cannot model, and make the router consult it.
How do we handle urgent corrections?
Define an expedited path with a named approver on call rather than bypassing the chain. The audit trail should show the expedited approval like any other.
Can approvals apply per locale?
Yes, and in regulated industries they often must. Include the locale in the approval record and in the router’s check, so approving the English page does not publish the German one.
What if the router is down?
Publishes wait and the CMS retries delivery. Monitor delivery failures and replay missed events from the CMS’s delivery log once the router is back.