Skip to content

04 — Webhooks and Cache Purge

Dev Guide — Savoy Signature Hotels
PRD refs: 08_API_Contracts.md, 09_Cache_and_Performance.md, 01_General_Architecture.md


The caching strategy uses Cloudflare Edge as the primary cache layer with infinite TTL. Content is invalidated by programmatic purge triggered by Umbraco publish webhooks. This guide covers the webhook contract, purge flow, dependency graph, and cache warmup.


Browser Cache

Static assets (JS/CSS/fonts/images), 1-year TTL

Cloudflare Edge

SSR HTML, infinite TTL (PRIMARY LAYER)

Next.js 'use cache'

Expensive computations only (nav tree, labels), 5-15 min TTL

Umbraco Internal

API/DB cache, auto-invalidated on publish

Key principle: Cloudflare is the single source of truth for cached HTML. Next.js does NOT cache page content. All page requests use cache: 'no-store' at the fetch level.


Content TypeHeader Value
SSR HTML pagespublic, max-age=0, s-maxage=31536000 + CDN-Cache-Control: max-age=31536000
Static assets (JS/CSS)public, max-age=31536000, immutable
Images (CDN)public, max-age=2592000 (30 days)
API responsesprivate, no-store
Fontspublic, max-age=31536000, immutable

POST /api/webhooks/umbraco
MechanismImplementation
HMAC SignatureX-Webhook-Signature header with SHA-256 of payload using REVALIDATE_SECRET
IP AllowlistRestrict to Umbraco Azure App Service IP
{
"event": "ContentPublished",
"timestamp": "2026-03-05T14:30:00Z",
"content": {
"id": "guid-here",
"contentType": "roomDetailPage",
"cultures": ["pt", "en"],
"urls": [
"/pt/alojamento/quarto-deluxe",
"/en/accommodation/deluxe-room"
]
},
"dependencies": [
"/pt/alojamento",
"/en/accommodation",
"/pt/",
"/en/"
]
}

Supported events:

EventTrigger
ContentPublishedContent node published
ContentUnpublishedContent node unpublished
MediaSavedMedia item saved/replaced
apps/web/src/app/api/webhooks/umbraco/route.ts
export async function POST(request: Request) {
// 1. Validate webhook signature
const signature = request.headers.get('X-Webhook-Signature');
const body = await request.text();
const expectedSignature = createHmac('sha256', process.env.REVALIDATE_SECRET!)
.update(body)
.digest('hex');
if (signature !== expectedSignature) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
// 2. Parse payload
const payload = JSON.parse(body);
// 3. Collect all URLs to purge (content + dependencies)
const urlsToPurge = [
...payload.content.urls,
...(payload.dependencies || []),
].map(path => `https://${getDomainForSite(payload)}${path}`);
// 4. Purge Cloudflare cache
await purgeCloudflareCache(urlsToPurge);
// 5. Warm up purged URLs
await warmupUrls(urlsToPurge);
return Response.json({ success: true, purged: urlsToPurge.length });
}

Terminal window
POST https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/purge_cache
Authorization: Bearer {CLOUDFLARE_API_TOKEN}
Content-Type: application/json
{
"files": [
"https://www.savoysignature.com/pt/alojamento/quarto-deluxe",
"https://www.savoysignature.com/en/accommodation/deluxe-room"
]
}

Next.js adds Cache-Tag header to SSR responses:

Cache-Tag: site:savoy-palace,module:heroSlider,page:room-detail

Purge by tag (useful for code deploys):

Terminal window
POST /zones/{zone}/purge_cache
{ "tags": ["module:heroSlider"] }

Available via custom Umbraco dashboard button with confirmation dialog.

Terminal window
POST /zones/{zone}/purge_cache
{ "purge_everything": true }
LimitValue
URLs per purge call30
Purge API calls per 24h1,000

Batch strategy: If more than 30 URLs, split into multiple calls. If exceeding daily limit, fall back to “Purge Everything”.


When a content node is published, the webhook must include all pages that reference or display that content.

Common dependencies:

Published ContentAffected Pages
Room Detail pageRoom Detail + Rooms List + Homepage (if featured)
Dining Detail pageDining Detail + Dining List
Shared Content (footer)ALL pages across ALL 8 sites
siteRoot (header/nav)ALL pages for that site
News articleNews Detail + News List
Special OfferOffer Detail + Offers List + Homepage (if featured)

Implementation: Build the dependency resolver in Umbraco as a custom webhook handler that:

  1. Identifies the published node
  2. Finds all Block List references to that node
  3. Finds parent list pages
  4. Checks if the node appears on homepage featured sections
  5. Adds all affected URLs to the webhook payload

After purging, warm up critical pages to ensure visitors hit a cached response.

async function warmupUrls(urls: string[]) {
const CONCURRENCY = 5;
const DELAY_MS = 200;
for (let i = 0; i < urls.length; i += CONCURRENCY) {
const batch = urls.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(url =>
fetch(url, { headers: { 'X-Warmup': 'true' } })
)
);
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
}

Rules:

  • Concurrency limit: 5 parallel requests
  • 200ms delay between batches
  • Send X-Warmup: true header (for logging/analytics exclusion)

Editor publishes content

Umbraco resolves dependency graph

Webhook POST to /api/webhooks/umbraco

Handler collects affected URLs

POST to Cloudflare purge API

(batched, max 30 URLs/call)

Warmup GET requests to purged URLs

Fresh HTML cached at edge

Targeted

Major release

CI/CD pipeline deploys new code

Release type?

Purge by Cache-Tag

(e.g., module:heroSlider)

Purge Everything

Warmup critical pages

(homepages, main sections)

Terminal window
# CI/CD purge by tag
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags":["module:heroSlider"]}'

Via Umbraco custom dashboard:

  1. Admin clicks “Purge All Cache” button
  2. Confirmation dialog: “This will purge ALL cached pages across ALL 8 sites. Continue?”
  3. Calls Cloudflare purge_everything: true
  4. Warmup runs for all 8 site homepages

Only for expensive computations that don’t change on every publish:

'use cache';
import { cacheLife } from 'next/cache';
export async function getNavigationTree(siteKey: string, locale: string) {
cacheLife('minutes'); // ~5 minutes
const client = new UmbracoClient();
return client.getNavigation(siteKey, locale);
}

When to use use cache:

  • Navigation tree (~5 min TTL)
  • Dictionary/labels (~15 min TTL)
  • Shared content lookups

When NOT to use use cache:

  • Page content (Cloudflare caches the full HTML)
  • API responses (use cache: 'no-store')

PatternRule
*savoysignature.com/*Cache Everything
*/api/*Bypass Cache
*umbraco*Bypass Cache
*.wycreative.com/*Standard (dev/stage)

MetricThresholdAlert Level
Cache Hit Rate< 80%Warning
Purge API calls/day> 800Warning
Warmup failures> 5%Warning
Webhook processing time> 10sWarning

PitfallSolution
Forgot dependency graphAlways include parent list pages and homepage when purging detail pages
Shared Content purge misses sitesShared Content changes MUST trigger purge across all 8 sites
Exceeded Cloudflare purge limitsBatch URLs (max 30/call) or use “Purge Everything” for large publishes
Warmup not runningWarmup is critical — without it, first visitor gets a cache miss (slow)
Caching API responses in Next.jsUse cache: 'no-store' for all Umbraco API fetches
Missing webhook signature validationAlways validate HMAC signature before processing