# 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.

Source: https://docs.bifrostorbit.com/cms/developers/webhooks/

## 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](https://docs.bifrostorbit.com/cms/developers/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

| 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](https://docs.bifrostorbit.com/cms/developers/events/) for every event and what it carries.

## Receive it with Next.js

With the SDK, the handler is two lines:

```ts
// app/api/cms-webhook/route.ts
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](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/) 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.1
Host: www.example.com
Content-Type: application/json
x-orbit-timestamp: 1792229400412
x-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.

| 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:

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

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()
const FIVE_MINUTES = 5 * 60 * 1000

function 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 hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["CMS_WEBHOOK_SECRET"].encode()
FIVE_MINUTES = 5 * 60 * 1000

def 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](https://docs.bifrostorbit.com/cms/developers/connect-any-website/) shows how to turn each event into a cache refresh in other frameworks.

## Respond correctly

| 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 `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

| 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. |
