Webhooks and purge
How your site learns that content changed, and how to prove the message came from ATLAS.
2 min read
How it works
When an editor publishes, unpublishes, moves, rolls back or deletes an entry, or changes something site-wide such as a menu, the theme or the settings, ATLAS queues one signed POST for every active webhook of the brand. Your site checks the signature, drops its cached copy of the affected pages and the next visitor gets fresh content, about a second after the publish.
Add a webhook under Settings, Publishing, Hooks. Give it the full HTTPS address of your endpoint and a secret. The URL must use port 443 and resolve to a public address. Each delivery shows up in the Delivery list, where a failed one can be retried.
The request
| Header | Value |
|---|---|
Content-Type | application/json |
X-Atlas-Event | The event name, for example entry.published. |
X-Atlas-Delivery | The delivery id. The same id is sent again on a retry. |
X-Atlas-Timestamp | Unix time in seconds when the message was signed. |
X-Atlas-Signature | sha256= followed by the hex HMAC. |
{
"event": "entry.published",
"brand": "atlascms",
"entry": { "id": "01m518vvbf8txh40wcvwhvtzyd", "type": "page", "locale": "en", "path": "/faq" },
"paths": ["/faq"],
"at": "2026-10-21T14:42:20+00:00"
}paths lists the public paths to refresh. A change that affects the whole site (settings.updated, menu.updated, theme.activated and similar) sends "paths": ["*"] and no entry. A manual purge from Tools, Cache sends the paths you chose.
| Event | When |
|---|---|
entry.published, entry.updated | An entry went live or its live output changed. |
entry.unpublished, entry.deleted | An entry left the public site. |
entry.moved, entry.rolled_back | A path changed, or an earlier revision became live. The old path is in paths too. |
settings.updated, menu.updated, fragment.updated, strings.updated | Site-wide content changed. paths is ["*"]. |
theme.updated, theme.activated, theme.options.published | The theme or its options changed. paths is ["*"]. |
redirect.created, term.updated, media.replaced, import.completed | Other changes to what the public site shows. |
Verify the signature
The signature is HMAC-SHA256 of the string {timestamp}.{raw body} with your webhook secret, written as hex. Always compute it over the raw body bytes you received, before any JSON parsing, compare in constant time, and refuse a timestamp that is more than five minutes old.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function isFromAtlas(rawBody: string, headers: Headers, secret: string): boolean {
const timestamp = headers.get('x-atlas-timestamp') ?? ''
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(age) || age > 300) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest()
const given = Buffer.from((headers.get('x-atlas-signature') ?? '').replace(/^sha256=/, ''), 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}$timestamp = $_SERVER['HTTP_X_ATLAS_TIMESTAMP'] ?? '';
$given = str_replace('sha256=', '', $_SERVER['HTTP_X_ATLAS_SIGNATURE'] ?? '');
$rawBody = file_get_contents('php://input');
$fresh = abs(time() - (int) $timestamp) <= 300;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
if (! $fresh || ! hash_equals($expected, $given)) {
http_response_code(401);
exit;
}# Reproduce a signature by hand while you debug
printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"Parse after you verify
Reading the body as JSON and serialising it again can change spaces and key order, and the signature will no longer match. Verify the raw string first.
Answer fast, expect repeats
- Return any 2xx within a few seconds. Do the cache work after you answer if it is slow.
- Any other answer, or a timeout, counts as a failure. ATLAS tries 5 times in total, waiting 10 seconds, 1 minute, 5 minutes and 15 minutes between tries. After the fifth failure the delivery is marked failed and admins are notified.
- Delivery is at least once. Make your handler safe to run twice, and use
X-Atlas-Deliveryto spot a repeat. - Use a different secret for every site. A secret decides which brand's cache a signed message may purge.
With the Nuxt layer
@atlas/nuxt ships the endpoint for you. Point the webhook at https://your-site.example/api/__atlas/purge and set the same secret in NUXT_ATLAS_PURGE_SECRET. The route verifies the signature and the five minute window, then drops the cached CMS data of that brand and answers { "brand", "purged" }. A bad or stale signature is a 401. A multi-brand site gives each host its own purgeSecret; see the layer guide.
Was this page helpful?