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

HeaderValue
Content-Typeapplication/json
X-Atlas-EventThe event name, for example entry.published.
X-Atlas-DeliveryThe delivery id. The same id is sent again on a retry.
X-Atlas-TimestampUnix time in seconds when the message was signed.
X-Atlas-Signaturesha256= followed by the hex HMAC.
body.jsonjson
{
  "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.

EventWhen
entry.published, entry.updatedAn entry went live or its live output changed.
entry.unpublished, entry.deletedAn entry left the public site.
entry.moved, entry.rolled_backA path changed, or an earlier revision became live. The old path is in paths too.
settings.updated, menu.updated, fragment.updated, strings.updatedSite-wide content changed. paths is ["*"].
theme.updated, theme.activated, theme.options.publishedThe theme or its options changed. paths is ["*"].
redirect.created, term.updated, media.replaced, import.completedOther 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.

verify.tstypescript
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)
}
verify.phpphp
$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;
}
terminalbash
# 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-Delivery to 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?

Suggest a change