# Guide to Builder.io Webhooks: Features and Best Practices

Builder.io webhooks tell your application when content in the visual headless CMS changes state. When an editor publishes a page, unpublishes a section, archives an entry, deletes it, or a scheduled publish window opens or closes, Builder.io can POST the change to your endpoint so you can update a database, rebuild static pages or bust a CDN cache.

This guide covers how model webhooks are configured, what triggers them and what they send, how to authenticate them with a custom header, and the practices that keep a Builder.io receiver reliable.

## What are Builder.io webhooks?

Builder.io webhooks are attached to a model. Each model, such as `page` or a custom section model, can have one or more webhook URLs, and Builder.io sends a `POST` to each one whenever content of that model is published, unpublished, archived or deleted, or reaches a scheduled start or end date.

The body describes the change: the entry as it is now (`newValue`), the entry as it was (`previousValue`), the model's name, and an `operation` field naming what happened.

Builder.io does not sign webhook requests. Its docs ask you to validate that incoming requests come from Builder.io, and the way to do that is a custom header: you give the webhook a header name and a secret value, and your receiver checks for it.

## Builder.io webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Models** > (your model) > **Advanced** > **Webhooks** > **Edit** > **+ Webhook** |
| Scope | Per model. Several URLs per model are allowed |
| Method | `POST` with a JSON body |
| Operations | `publish`, `unpublish`, `archive`, `delete`, `scheduledStart`, `scheduledEnd` |
| Custom headers | **+ Custom Header** on the webhook, with any name and value |
| Authentication | None built in. Add a secret custom header |
| Signature and timestamp | None |
| Payload options | **Disable payload** (no body), **Submit limited payload fields** (only the fields you list) |
| Proxy | Sent from Builder.io's backend by default. **Disable proxy** makes the editor's browser call your URL |
| Retries, timeout, source IPs | Not documented |

## Operations and payloads

Builder.io's example body looks like this, with `previousValue` shortened:

```json
{
  "newValue": {
    "id": "cs3df",
    "published": "draft",
    "name": "My great page",
    "data": { "property": "value" }
  },
  "previousValue": { "...": "..." },
  "modelName": "page",
  "operation": "publish"
}

```

| `operation` | Fires when |
| --- | --- |
| `publish` | Content moves from a non-published state, such as draft or archived, to published. `previousValue` is `null` on a first publish |
| `unpublish` | Previously published content is moved to another state, such as draft |
| `archive` | Content is archived |
| `delete` | Content is deleted. `newValue` is `null` |
| `scheduledStart` | Scheduled content reaches its start date and time |
| `scheduledEnd` | Scheduled content reaches its end date and time |

Builder.io's docs say webhooks fire for significant state changes only. Routine edits, and changes between non-published states such as draft to archived, do not trigger them. Each `newValue` has the same structure as a Content API entry, so you can store it and render it later, for example by passing it to the Builder.io SDK's content prop.

Two advanced settings change the body. **Disable payload** sends the `POST` with no data at all, which suits a simple "something changed" trigger. **Submit limited payload fields** sends only the fields you list in dot notation, such as `data.myInput`, which keeps large entries small.

> **Inspect Builder.io webhooks as they arrive.** Point a Builder.io model webhook at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact headers and body it sends, with no account or setup required.

## Setting up Builder.io webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`.

1. In Builder.io, go to **Models** and choose the model.
2. Open the **Advanced** tab and click **Edit** next to **Webhooks**.
3. Click **+ Webhook** and enter your HTTPS endpoint URL.
4. Click **+ Custom Header** and enter a name, such as `X-Builder-Secret`, and your secret as the value. The value is sent exactly as typed, so include `Bearer ` if you use `Authorization` with a bearer token.
5. Under **Show Advanced**, leave **Disable proxy** off for anything other than local experiments, and set the payload options you want.
6. Click **Done**, then publish a test entry in that model.

To route deliveries through Hookdeck, create a Hookdeck source with the **Builder.io** type and use its URL as the webhook URL. The source asks for a **Header Name** and a **Header Value**: enter the same name and the whole value you typed in Builder.io. Names match case-insensitively, values exactly. The source accepts `POST`, which is the method Builder.io uses.

For local development, `hookdeck listen 3000 builder-io --path /webhooks/builder-io` gives you a public URL that forwards to your machine, so you do not need **Disable proxy**.

## Securing Builder.io webhooks

Read the header you chose, compare its whole value with your stored secret in constant time, and reject the request if either is missing.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = (process.env.BUILDER_IO_WEBHOOK_HEADER || 'x-builder-secret').toLowerCase();
const SECRET = process.env.BUILDER_IO_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/builder-io', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME);
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  // With "Disable payload" on, the body is empty.
  const raw = req.body.length ? req.body.toString('utf8') : '{}';
  let change;
  try {
    change = JSON.parse(raw);
  } catch {
    return res.sendStatus(400);
  }

  switch (change.operation) {
    case 'publish':
    case 'scheduledStart':
      // Store change.newValue keyed on change.newValue.id, then revalidate pages.
      break;
    case 'unpublish':
    case 'archive':
    case 'scheduledEnd':
    case 'delete':
      // Remove or hide the entry. On delete, newValue is null: use previousValue.id.
      break;
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("BUILDER_IO_WEBHOOK_HEADER", "X-Builder-Secret")
SECRET = os.environ.get("BUILDER_IO_WEBHOOK_SECRET", "")

def verify_builder_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A secret header proves the sender knows the secret. It does not protect the body, so a modified payload still passes, and without a timestamp a captured request can be replayed. Keep the endpoint on HTTPS, use a long random value, and rotate it in Builder.io and in your environment together if it might have leaked.

> **Make Builder.io webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the custom header you configure, stores every content change durably, and retries delivery to your service on a schedule you control.

## Builder.io webhook limitations and pain points

### No signature, and no documented delivery guarantees

**The Problem:** You cannot prove a request's body came from Builder.io unchanged, and you cannot tell from the docs what happens when your endpoint is down.

**Why It Happens:** The webhook's only security feature is the custom header. Builder.io does not document a timeout, a retry policy or a source IP range.

**Workarounds:**

* Use a long random custom header value over HTTPS, and keep it out of logs.
* Treat each webhook as a hint and, for anything sensitive, re-read the entry from the Content API with `cachebust=true` before acting on it.
* Run a periodic sync against the Content API so a missed webhook is eventually corrected.

**How Hookdeck Can Help:** Hookdeck's Builder.io source compares the header you name with the exact value you enter. Hookdeck still answers with a `200`, but requests that fail the check are recorded as failed verification and never reach your service. Requests that pass are stored and delivered with [retries](/docs/retries) you configure, so delivery no longer depends on undocumented behaviour.

### Deletes carry no new value

**The Problem:** Code that reads `newValue.id` crashes on a delete.

**Why It Happens:** On `delete`, `newValue` is `null`. On a first `publish`, `previousValue` is `null`. And with **Disable payload** on, there is no body at all.

**Workarounds:**

* Take the entry ID from `newValue?.id ?? previousValue?.id`.
* Handle an empty body explicitly if you use **Disable payload**.

**How Hookdeck Can Help:** Hookdeck shows every request's full body, so you can see a real `delete` or first `publish` before you write the code that handles it, and replay it against your fixed handler.

### "Disable proxy" changes where requests come from

**The Problem:** With **Disable proxy** on, deliveries come from the Builder.io editor running in a browser rather than from Builder.io's servers, and your endpoint has to return CORS headers.

**Why It Happens:** The setting exists to let the editor reach `localhost` directly.

**Workarounds:**

* Leave **Disable proxy** off in every real environment.
* Use a tunnel such as the Hookdeck CLI for local development instead.

**How Hookdeck Can Help:** The Hookdeck CLI gives you a public URL for your local server, so Builder.io's backend can reach it with the proxy left on, exactly as in production.

### Large entries make large payloads

**The Problem:** A page with many blocks produces a large body, sent twice over because `previousValue` is included.

**Why It Happens:** `newValue` and `previousValue` are full content entries.

**Workarounds:**

* Turn on **Submit limited payload fields** and send only what your receiver uses.
* Or turn on **Disable payload** and fetch the entry from the Content API when notified.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/transformations) can trim or reshape the body before it reaches your service.

## Best practices

### Route on `operation`

Switch on `operation` and treat `publish` and `scheduledStart` as "make visible", and `unpublish`, `archive`, `scheduledEnd` and `delete` as "make hidden". Acknowledge operations you do not handle with a `200`.

### Make handlers idempotent

Builder.io does not document retries or ordering, so assume a change can arrive more than once or late. Upsert by entry ID, and if ordering matters, re-read the entry from the Content API rather than trusting the order in which webhooks arrive. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use one webhook per job

Webhooks are per model, and each model can have several URLs. Give your cache purge and your database sync separate URLs or separate Hookdeck connections, so one failing consumer does not hold up the other.

### Acknowledge fast and process asynchronously

Rebuilding pages or warming caches can take a while. Verify the header, store the change, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Builder.io model webhooks POST the new and previous versions of an entry whenever it is published, unpublished, archived, deleted or reaches a scheduled date. They are not signed, so add a secret custom header, compare its whole value in constant time over HTTPS, handle `null` values on deletes and first publishes, and keep **Disable proxy** off outside local experiments.

[Hookdeck Event Gateway](https://hookdeck.com) checks that header, keeps every content change, and retries and replays deliveries to your service, which fills the gap left by Builder.io's undocumented delivery behaviour.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Builder.io webhooks reliably in minutes.