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.
- Open Configuration › Webhooks and select New webhook.
- In Name, enter something that says where it goes, such as
Website. - In Address, enter the full
httpsaddress of your handler, such ashttps://www.example.com/api/cms-webhook. Plainhttpis refused, except forlocalhost. - 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.
- 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.
- Select Create webhook.
- Copy the secret from the next dialog, which starts with
whsec_, and store it in your website's settings asCMS_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
content.publishedcontent.unpublishedcontent.deletedrelease.publishednavigation.changed| On screen | Webhook event | Sent when |
|---|---|---|
| Page published | content.published | A page or entry goes live, or a page goes back to an earlier version |
| Page unpublished | content.unpublished | A page or entry is taken offline |
| Page deleted | content.deleted | A page or entry is moved to the bin |
| Release published | release.published | A release has run |
| Menu changed | navigation.changed | 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:
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:
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-timestampx-orbit-signature| Header | Holds |
|---|---|
x-orbit-timestamp | When the message was sent, in milliseconds since 1 January 1970 UTC, as text |
x-orbit-signature | The signature, as 64 lowercase hexadecimal characters with no prefix |
To check a message:
- Read the raw request body as text, before any JSON parsing. Parsing and re-encoding changes the bytes and breaks the signature.
- Build the signed string: the timestamp header, a full stop, then the raw body. For example
1792229400412.{"event":"content.published",…}. - Compute HMAC-SHA256 of that string, keyed with the webhook secret (the whole value, including
whsec_), and write it as lowercase hex. - Compare it with
x-orbit-signatureusing a constant-time comparison, so the time taken does not give the signature away. - Refuse the message if the timestamp is more than five minutes away from your clock.
Node.js
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
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 "", 204PHP
<?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
2xx, such as 200 or 2043xx, 4xx and 5xx| Your answer | What the CMS does |
|---|---|
Any 2xx, such as 200 or 204 | Counts it as delivered |
Any other status, including 3xx, 4xx and 5xx | Counts it as failed |
| No answer within 10 seconds, or no connection | 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
2xxfor events you ignore. Any other answer counts as a failure, and 20 failures in a row switch the webhook off. - Answer
401only for a bad signature or an old timestamp. - Do not redirect. Register the final address. A redirect usually turns the
POSTinto aGETwithout 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.testmessage straight away, with the webhook's name indata.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:
- Fix the endpoint.
- Select Send a test and check it answers
2xx. - Turn the On switch back on. This also sets the failure count to zero.
- 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: 60that 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
httpaddresses, exceptlocalhost. - 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:
- Add a second handler on your website, such as
/api/cms-webhook-new, that reads a new setting, such asCMS_WEBHOOK_SECRET_NEW. - Create a new webhook for that address with the same events and environment, and store its secret in the new setting.
- Send a test and check Deliveries.
- 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
http://. Use https://.https:// and the full host name.401, and the SDK answered "Bad signature"CMS_WEBHOOK_SECRET holds the whole secret, including whsec_, and sign the raw body. A proxy that changes the body also breaks the signature.401, and the SDK answered "The message is too old"404 or 405POST.301, 302 or 308, or a GET arrives with no bodyexample.com to www.example.com, or to add a trailing slash. Register the final address.500200, but the page did not changecache: { tags: true }, the page is fetched some other way, or the page's region does not match. Check the revalidated list in the answer.| What you see | Likely cause and fix |
|---|---|
| "A webhook must be sent over https" when creating | The address starts with http://. Use https://. |
| "… is not a URL" when creating | The address is incomplete. Include https:// and the full host name. |
Result 401, and the SDK answered "Bad signature" | 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" | Your server's clock is more than five minutes out, or your code treats the timestamp as seconds. It is in milliseconds. |
| No answer | 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 | The path is wrong, or the route does not accept POST. |
Result 301, 302 or 308, or a GET arrives with no body | The address redirects, for example from example.com to www.example.com, or to add a trailing slash. Register the final address. |
Result 500 | Your handler threw an error. Check your website's logs. |
| Nothing in Deliveries | 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 | 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 | The webhook failed 20 times in a row. See Retries and failures above. |
Was this page helpful?