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:
{
"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 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.
- In Builder.io, go to Models and choose the model.
- Open the Advanced tab and click Edit next to Webhooks.
- Click + Webhook and enter your HTTPS endpoint URL.
- 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 includeBearerif you useAuthorizationwith a bearer token. - Under Show Advanced, leave Disable proxy off for anything other than local experiments, and set the payload options you want.
- 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.
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:
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 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=truebefore 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 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 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.
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.
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 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 for free and handle Builder.io webhooks reliably in minutes.