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 page covers the four failure modes and the gateway-level controls that close them.
The editorial state machine the delivery layer must honor:
stateDiagram-v2
[*] --> Draft
Draft --> Review: submit
Review --> Draft: changes requested
Review --> Approved: sign-off
Approved --> Published: build deploys
Published --> Draft: new revision
Published --> [*]
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. Key the cache on revision IDs, not slugs
Bind invalidation to revision IDs — slugs change, revisions don’t. Give preview URLs signed tokens that expire after 24 hours or on publish. For ISR, use CDN cache tags or Surrogate-Key headers to purge only the affected revision.
// pages/[slug].tsx (App Router compatible)
export async function generateStaticParams() {
// Fetch only published slugs during build
return fetchPublishedSlugs();
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const revision = await getLatestRevision(params.slug);
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_URL!),
// Cache key tied to revision, not slug
cache: 'force-cache',
next: {
revalidate: 3600,
tags: [`content-${revision.id}`, `locale-${revision.locale}`]
}
};
}
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 revisions | Switch to Surrogate-Key: content-{revision_id} invalidation |
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 } }"}'