Webhooks
Receive signed HTTP notifications when content changes in your project.
What are Webhooks?
Webhooks let your external systems react when events occur in your CMS — an
entry is published, a media asset is uploaded, a content type changes. Each
delivery is a POST request carrying a JSON payload and an HMAC-SHA256
signature you can verify.
Creating a Webhook
- Go to Project Settings > Webhooks
- Click Create Webhook
- Enter the destination URL (must be
https://) - Select the events to subscribe to (see Event Types)
- Click Create
- Copy the Signing Secret — you need it to verify deliveries
Event Types
Event names are past tense. Subscribing to a name not in this list is rejected.
| Event | Fires when |
|---|---|
entry.created | A new entry is created |
entry.updated | An existing entry is saved |
entry.deleted | An entry is permanently deleted |
entry.published | An entry is published |
entry.unpublished | A published entry returns to draft |
media.uploaded | A media asset is uploaded |
media.updated | Media metadata changes |
media.deleted | A media asset is deleted |
content_type.created | A content type is created |
content_type.updated | A content type is changed |
content_type.deleted | A content type is deleted |
Webhook Payload
Every payload shares the same envelope. Event-specific fields live under
data — never at the top level.
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID (evt_…), use for idempotency |
event | string | Event type from the table above |
timestamp | string | ISO 8601 time the event occurred |
projectId | string | ID of the project the event belongs to |
apiVersion | string | Payload structure version (2025-01-01) |
data | object | Event-specific data |
An entry.published delivery:
{
"id": "evt_aa98ec719c394b6585ffc6671930f3a4",
"event": "entry.published",
"timestamp": "2026-08-27T04:17:15.992Z",
"projectId": "cmtayp46j0001l104grq7pfuv",
"apiVersion": "2025-01-01",
"data": {
"entry": {
"id": "entry_789",
"contentTypeApiId": "blogPost",
"contentTypeName": "Blog Post",
"locale": "en",
"slug": "my-blog-post",
"status": "PUBLISHED",
"version": 3,
"data": {
"title": "My Blog Post",
"body": "..."
},
"createdAt": "2026-08-20T09:00:00.000Z",
"updatedAt": "2026-08-27T04:17:15.980Z",
"publishedAt": "2026-08-27T04:17:15.980Z"
},
"isFirstPublish": true
}
}To route on content type, read data.entry.contentTypeApiId. There is no
top-level contentType object and no project object.
Payload by event
| Event | data contains |
|---|---|
entry.created | entry |
entry.updated | entry, changedFields |
entry.deleted | entryId, contentTypeApiId, locale, slug |
entry.published | entry, isFirstPublish |
entry.unpublished | entry |
media.uploaded | asset |
media.updated | asset, changedFields |
media.deleted | assetId, filename, url |
content_type.created | contentType |
content_type.updated | contentType, changedFields |
content_type.deleted | contentTypeId, apiId, name |
The entry object carries the fields shown above. asset carries id,
filename, url, mimeType, size, width, height, alt, folderId,
createdAt. contentType carries id, apiId, name, description,
isSingle, draftEnabled, fieldCount, createdAt, updatedAt.
Webhook Security
Delivery Headers
Every delivery includes three headers:
| Header | Example | Purpose |
|---|---|---|
X-Webhook-Signature | v1=abc123… | HMAC-SHA256 signature, v1= + hex digest |
X-Webhook-Timestamp | 1706123456 | Unix time (seconds) the request was sent |
X-Webhook-Event-Id | evt_abc123 | Unique event ID, for idempotency |
Header names are case-insensitive; most frameworks lowercase them
(x-webhook-signature).
Signature Verification
The signature is computed over the timestamp and the raw body joined by a period — not the payload alone:
HMAC-SHA256(`${timestamp}.${rawBody}`, your_signing_secret)The X-Webhook-Signature header is that hex digest prefixed with v1=. Strip
the v1= prefix before comparing, and verify against the raw request body;
re-serializing parsed JSON can change byte-for-byte content and break the
comparison.
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
// 1. Reject replays outside a 5-minute window
const now = Math.floor(Date.now() / 1000);
if (!timestamp || Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
// 2. Signature must carry the v1 prefix
if (!signature || !signature.startsWith('v1=')) {
return false;
}
const providedSig = signature.slice(3);
// 3. Compute the expected signature over `timestamp.rawBody`
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// 4. Compare timing-safely (equal lengths required)
const provided = Buffer.from(providedSig, 'hex');
const expected = Buffer.from(expectedSig, 'hex');
if (provided.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(provided, expected);
}An Express endpoint, using the raw body:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifyWebhookSignature(
rawBody,
req.headers['x-webhook-signature'],
req.headers['x-webhook-timestamp'],
process.env.WEBHOOK_SECRET
);
if (!ok) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
console.log('Verified:', event.event, event.id);
res.status(200).json({ received: true });
});In a Next.js Route Handler, read the raw body with await request.text().
Getting Your Secret
- Go to Project Settings > Webhooks
- Click on your webhook
- Copy the Signing Secret
The same webhook detail view has a Setup Guide tab with this verification code, and a Deliveries tab showing what was actually sent.
Required Response
Return a 2xx status code within 10 seconds. Any other status, or a
timeout, counts as a failed delivery and is retried. Acknowledge first and do
slow work asynchronously.
Retry Policy
Failed deliveries are retried up to 6 times with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 24 hours |
After the sixth retry fails, the delivery is marked failed and not retried
again. Because retries redeliver the same event, use X-Webhook-Event-Id (or
the payload's id) to make your handler idempotent.
Testing Webhooks
- Click on a webhook
- Click Send Test
- A test payload is sent to your URL
- Check the delivery log for the result
The test event is signed exactly like a real delivery, so it exercises your
verification code. Its body differs from real events: event is test, it
carries createdAt rather than timestamp, and data holds a message and
the webhook that sent it.
{
"id": "evt_aa98ec719c394b6585ffc6671930f3a4",
"event": "test",
"apiVersion": "2025-01-01",
"createdAt": "2026-08-27T04:17:15.992Z",
"projectId": "cmtayp46j0001l104grq7pfuv",
"data": {
"message": "This is a test webhook event",
"timestamp": "2026-08-27T04:17:15.992Z",
"webhook": { "id": "wh_123", "name": "Production rebuild" }
}
}Handle test explicitly, or ignore unknown event types, so a test send does
not fall through your routing logic.
Delivery Logs
View recent webhook deliveries:
- Go to Project Settings > Webhooks
- Click on a webhook
- View the Delivery Log
- See status, response code, and timing
URL Requirements
- Endpoints must use
https:// - URLs that resolve to private or reserved IP ranges are rejected at delivery
time (loopback,
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fd00::/8,::1)
Local endpoints are therefore not reachable directly — use a tunnel that gives you a public HTTPS hostname.
Common Use Cases
Static Site Rebuild
Trigger a rebuild when content changes:
{
"url": "https://api.netlify.com/build_hooks/xxx",
"events": ["entry.published", "entry.unpublished"]
}Cache Invalidation
Clear CDN cache when content updates:
{
"url": "https://your-api.com/invalidate-cache",
"events": ["entry.updated", "entry.deleted"]
}Slack Notifications
Send to a relay that posts to Slack when content is published:
{
"url": "https://your-api.com/notify-slack",
"events": ["entry.published"]
}Slack's incoming-webhook URLs expect Slack's own message format, so point the webhook at your own endpoint and have it reshape the payload.
Disabling Webhooks
To temporarily disable a webhook:
- Go to Project Settings > Webhooks
- Click on the webhook
- Toggle Active to off
- Click Save
The webhook will not fire until re-enabled. Deliveries already queued for a webhook that is disabled before they are sent are marked failed.