Skip to content

Guides

Refresh the website when content changes

Create a webhook, receive and verify its signed messages with or without the SDK, and refresh only the pages, menus and sitemaps that changed.

How-to guide11 min read

What a webhook is for

A webhook is an address on your website that the CMS calls when content changes. Your website then refreshes the pages that changed, so visitors see new content within seconds instead of waiting for a cache to expire.

Each message is a signed POST request with a short JSON body. It names what changed, such as the page /products/hiking-boots in region de, so the website can refresh just that page.

You do not need a webhook for content to appear. Websites set up as in Connect a website refresh their cache every 60 seconds anyway. A webhook makes it immediate.

Your plan sets how many webhooks the organisation can have: 2 on Starter, 20 on Growth, and as many as you need on Enterprise. The Webhooks page shows how many are in use, and New webhook is switched off with the reason once the plan is full. One webhook can listen for every event in a website, so most websites need only one.

Create a webhook

You need the Developer, Administrator or Owner role. Others can see the screen but not change it.

  1. Open Configuration › Webhooks and select New webhook.
  2. In Name, enter something that says where it goes, such as Website.
  3. In Address, enter the full https address of your handler, such as https://www.example.com/api/cms-webhook. Plain http is refused, except for localhost.
  4. In Environment, choose Every environment or one environment, such as Production. The webhook only hears about changes in what you choose. Use separate webhooks for a staging site and a live site.
  5. Under Events, tick what the website should hear about. Page published, Page unpublished and Menu changed are ticked for you. For a website, also tick Page deleted and Release published.
  6. Select Create webhook.
  7. Copy the secret from the next dialog, which starts with whsec_, and store it in your website's settings as CMS_WEBHOOK_SECRET. Then select Done.

Important

The secret is shown once. It cannot be seen again later. If you lose it, delete the webhook and create a new one.

A webhook cannot be edited. To change its address, events or environment, delete it and create it again. It then gets a new secret.

The events you can choose

Page published
Webhook event
content.published
Sent when
A page or entry goes live, or a page goes back to an earlier version
Page unpublished
Webhook event
content.unpublished
Sent when
A page or entry is taken offline
Page deleted
Webhook event
content.deleted
Sent when
A page or entry is moved to the bin
Release published
Webhook event
release.published
Sent when
A release has run
Menu changed
Webhook event
navigation.changed
Sent when
A menu is created, changed or deleted

Page rolled back, Page promoted and Redirect published are also on the screen. A rollback, and a page promoted into the webhook's environment, such as from Staging to Production, are sent as content.published, so the website refreshes either way. Nothing is sent for a redirect at the moment. See Events and webhooks for every event and what it carries.

Receive it with Next.js

With the SDK, the handler is two lines:

app/api/cms-webhook/route.tsTypeScript
import { createRevalidateHandler } from '@bifrost-orbit-cms/sdk/next'export const POST = createRevalidateHandler({ secret: process.env.CMS_WEBHOOK_SECRET! })

It checks the signature, refuses messages more than five minutes old, and refreshes only the cache tags for what changed. It answers 401 if the check fails and 200 with the refreshed tags otherwise. A test message refreshes nothing.

Tag refresh only works if the client is created with cache: { tags: true }. Caching and refreshing pages explains every cache, which tags each event refreshes, how to test it on your computer and what to check when a page stays out of date.

Receive it without the SDK

Any language can receive webhooks. Each request looks like this:

HTTP
POST /api/cms-webhook HTTP/1.1Host: www.example.comContent-Type: application/jsonx-orbit-timestamp: 1792229400412x-orbit-signature: 5d1f0c6b0e7a…{"event":"content.published","sentAt":"2026-10-17T09:30:00.412Z","data":{"contentId":"6650f1c2a9e4b3d2c1f0ea07","url":"/products/hiking-boots","name":"Hiking boots","locale":"de-DE","region":"germany","version":7}}

Each content event also says its contentType. When a page is published or promoted at a new address, data also has previousUrl, the address it had before, whether or not a redirect was made. Menus and links elsewhere point at the new address from then on, so refresh more than the one page; the SDK's handler refreshes everything.

Publishing a shared section sends a content event with contentType: "sharedSection" and no url. Any page may show the section, so refresh everything; the SDK's handler does from version 0.7.0.

x-orbit-timestamp
Holds
When the message was sent, in milliseconds since 1 January 1970 UTC, as text
x-orbit-signature
Holds
The signature, as 64 lowercase hexadecimal characters with no prefix

To check a message:

  1. Read the raw request body as text, before any JSON parsing. Parsing and re-encoding changes the bytes and breaks the signature.
  2. Build the signed string: the timestamp header, a full stop, then the raw body. For example 1792229400412.{"event":"content.published",…}.
  3. Compute HMAC-SHA256 of that string, keyed with the webhook secret (the whole value, including whsec_), and write it as lowercase hex.
  4. Compare it with x-orbit-signature using a constant-time comparison, so the time taken does not give the signature away.
  5. Refuse the message if the timestamp is more than five minutes away from your clock.

Node.js

TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto'import express from 'express'const app = express()const FIVE_MINUTES = 5 * 60 * 1000function verify(rawBody: string, timestamp: string, signature: string): boolean {  if (!/^\d+$/.test(timestamp)) return false  if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES) return false  const expected = createHmac('sha256', process.env.CMS_WEBHOOK_SECRET!)    .update(`${timestamp}.${rawBody}`)    .digest('hex')  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature))}app.post('/api/cms-webhook', express.text({ type: 'application/json' }), (req, res) => {  const ok = verify(req.body, req.get('x-orbit-timestamp') ?? '', req.get('x-orbit-signature') ?? '')  if (!ok) return res.sendStatus(401)  const { event, data } = JSON.parse(req.body)  res.sendStatus(204)  refreshCache(event, data).catch(console.error)})

Python

python
import hashlibimport hmacimport osimport timefrom flask import Flask, requestapp = Flask(__name__)SECRET = os.environ["CMS_WEBHOOK_SECRET"].encode()FIVE_MINUTES = 5 * 60 * 1000def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:    if not timestamp.isdigit():        return False    if abs(time.time() * 1000 - int(timestamp)) > FIVE_MINUTES:        return False    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()    return hmac.compare_digest(expected, signature)@app.post("/api/cms-webhook")def cms_webhook():    ok = verify(        request.get_data(),        request.headers.get("x-orbit-timestamp", ""),        request.headers.get("x-orbit-signature", ""),    )    if not ok:        return "", 401    message = request.get_json()    queue_refresh(message["event"], message["data"])    return "", 204

PHP

PHP
<?php$body = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_X_ORBIT_TIMESTAMP'] ?? '';$signature = $_SERVER['HTTP_X_ORBIT_SIGNATURE'] ?? '';$expected = hash_hmac('sha256', $timestamp . '.' . $body, getenv('CMS_WEBHOOK_SECRET'));$fresh = ctype_digit($timestamp) && abs(microtime(true) * 1000 - (int) $timestamp) <= 5 * 60 * 1000;if (!$fresh || !hash_equals($expected, $signature)) {    http_response_code(401);    exit;}$message = json_decode($body, true);queue_refresh($message['event'], $message['data']);http_response_code(204);

Connect a website without Next.js shows how to turn each event into a cache refresh in other frameworks.

Respond correctly

Any 2xx, such as 200 or 204
What the CMS does
Counts it as delivered
Any other status, including 3xx, 4xx and 5xx
What the CMS does
Counts it as failed
No answer within 10 seconds, or no connection
What the CMS does
Counts it as failed, shown as No answer
  • Answer fast. The message is sent while the editor's change is being saved, and each webhook is called in turn. A slow endpoint makes publishing slow for everyone. Check the signature, answer 204, and do slow work, such as rebuilding pages or calling other systems, afterwards or in a queue.
  • Answer 2xx for events you ignore. Any other answer counts as a failure, and 20 failures in a row switch the webhook off.
  • Answer 401 only for a bad signature or an old timestamp.
  • Do not redirect. Register the final address. A redirect usually turns the POST into a GET without a body, so the message is lost.
  • Expect the same page more than once. Publishing a page twice sends two messages, and a release sends one per page plus one for the release. Refreshing a cache twice is harmless.

Test it and read the delivery log

Open the ⋯ menu at the end of the webhook's row.

  • Send a test sends a webhook.test message straight away, with the webhook's name in data.webhook. A message then shows the answer, such as "Website answered 204", or the reason it failed. Websites using the SDK accept the test and refresh nothing.
  • Deliveries shows the latest 50 messages sent to this webhook, newest first. Each row has the Event, the Result and When it was sent. The result is the status code your endpoint answered, or No answer if it could not be reached. Failures are shown in red. Point at a failed result to see why it failed.

The list of webhooks also shows each one's Last delivery, and how many failures in a row it has had, if any.

A test is sent whatever the webhook's Environment, and even when the webhook is switched off.

Retries and failures

Each message is sent once, at the moment of the change. If it fails, the failure is recorded in Deliveries, but the message is not sent again.

  • After 20 failures in a row, the webhook is switched off so it cannot slow publishing down. Its On switch turns off. Tests count towards the 20.
  • Any successful delivery, including a test, sets the count back to zero.
  • While a webhook is off, nothing is sent to it and nothing is saved up for later.

When your endpoint has been down:

  1. Fix the endpoint.
  2. Select Send a test and check it answers 2xx.
  3. Turn the On switch back on. This also sets the failure count to zero.
  4. Bring the website up to date. Changes made while it was down were not sent, so clear the website's cache or wait for it to expire. With revalidate: 60 that takes at most a minute.

Secure it

  • Always check the signature. Anyone can find your webhook address. Only the CMS knows the secret.
  • Refuse old timestamps. Rejecting anything more than five minutes from your clock stops someone replaying a message they captured. Keep your server's clock in sync.
  • Use HTTPS. The CMS refuses plain http addresses, except localhost.
  • Keep the secret out of code. Store it as an environment variable or in a secrets store, and never log it.

To try webhooks from your own computer, use a tunnelling service that gives your machine a public https address, and register that address.

Change the secret

Change it when someone who knew it leaves, or if it may have leaked. Webhooks cannot be edited, so swap in a new one:

  1. Add a second handler on your website, such as /api/cms-webhook-new, that reads a new setting, such as CMS_WEBHOOK_SECRET_NEW.
  2. Create a new webhook for that address with the same events and environment, and store its secret in the new setting.
  3. Send a test and check Deliveries.
  4. Delete the old webhook, then remove the old handler and setting.

While both webhooks exist, each change is sent to both. That is harmless.

Troubleshooting

"A webhook must be sent over https" when creating
Likely cause and fix
The address starts with http://. Use https://.
"… is not a URL" when creating
Likely cause and fix
The address is incomplete. Include https:// and the full host name.
Result 401, and the SDK answered "Bad signature"
Likely cause and fix
The secret is wrong, or the body was parsed before checking. Check CMS_WEBHOOK_SECRET holds the whole secret, including whsec_, and sign the raw body. A proxy that changes the body also breaks the signature.
Result 401, and the SDK answered "The message is too old"
Likely cause and fix
Your server's clock is more than five minutes out, or your code treats the timestamp as seconds. It is in milliseconds.
No answer
Likely cause and fix
The endpoint took longer than 10 seconds, or could not be reached. Check it is public, the host name resolves, and no firewall blocks it. Answer before doing slow work.
Result 404 or 405
Likely cause and fix
The path is wrong, or the route does not accept POST.
Result 301, 302 or 308, or a GET arrives with no body
Likely cause and fix
The address redirects, for example from example.com to www.example.com, or to add a trailing slash. Register the final address.
Result 500
Likely cause and fix
Your handler threw an error. Check your website's logs.
Nothing in Deliveries
Likely cause and fix
The webhook is off, its Environment does not include where the change was made, or the event is not ticked. A rollback or promotion arrives as Page published; Redirect published sends nothing at the moment.
Answered 200, but the page did not change
Likely cause and fix
The client is not created with cache: { tags: true }, the page is fetched some other way, or the page's region does not match. Check the revalidated list in the answer.
The On switch turned itself off
Likely cause and fix
The webhook failed 20 times in a row. See Retries and failures above.

Was this page helpful?

Read next