Webhooks
Rebuild a static site when content is published. Events, payload, signature verification and retries.
Do you need one?
A site that fetches content at request time does not. Publish, and the next request already sees the new content. The webhook is for a site that reads the API at build time (Next.js static export, Astro, Hugo, Eleventy, Gatsby): a publish changes nothing on that site until it rebuilds, and the webhook is how the rebuild gets asked for.
Fetching at request time
Server-rendered pages, client-side fetch, ISR with a short revalidate. No webhook needed.
Building from the API
Static export, or on-demand revalidation. Point the webhook at your host's deploy hook or your revalidate route.
Webhooks are available on paid projects. Owners and admins set one up under Project Settings, in the Webhook section: one https URL per project, a signing secret, and a log of the last ten deliveries.
When it fires
Only when the content the public API serves changes. Saving a draft, submitting for review and approving do not fire it, because nothing a site fetches has moved.
| Event | Sent when |
|---|---|
| content.published | Assets are published, from the dashboard or a batch write with publishImmediately. |
| content.unpublished | A rollback, so the previously published version is being served again. |
| content.disabled | A published asset is disabled and stops being served. |
| content.enabled | A disabled asset with published content is served again. |
| content.deleted | A published asset is deleted, so its tag no longer resolves. |
| ping | You press Send test in the settings. Carries no tags, and is limited to 30 tests an hour. |
One call per burst, not one per asset
Changes of the same kind are gathered for about three seconds of quiet before the call is made, so a batch publish of fifty assets is one delivery listing fifty tags. A steady trickle is still sent at least every fifteen seconds. Build hooks are often billed per run, and this is why the delivery is not made the instant a publish lands.
What you receive
A POST with a JSON body. The tags tell you what changed; most receivers ignore them and rebuild everything.
{
"event": "content.published",
"projectId": "69e6782b633f58fba0fdf79d",
"tags": ["hero-title", "hero-subtitle", "pricing-paid-price"],
"occurredAt": "2026-09-07T14:03:12.481Z",
"deliveryId": "6a7d0f2c8b1e4a3f9c2d5e71"
}| Header | Value |
|---|---|
| X-Duggie-Signature | t=1757253792,v1=5f1a…, the timestamp and the HMAC. See below. |
| X-Duggie-Event | The event name, so a receiver can route without parsing the body. |
| X-Duggie-Delivery | The delivery id. The same id on two requests means a retry of the same delivery. |
Verifying the signature
Anyone who learns your endpoint URL can post to it. The signature is how your server knows the request came from Duggie. Take the timestamp t from the header, compute HMAC-SHA256 over t + "." + raw body with your signing secret, and compare the hex digest to v1 in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
// Express: app.post('/duggie', express.raw({ type: '*/*' }), handler)
export function handler(req, res) {
const header = req.get('X-Duggie-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = createHmac('sha256', process.env.DUGGIE_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || !timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''))) {
return res.sendStatus(401);
}
const { event, tags } = JSON.parse(req.body);
// trigger your build here
res.sendStatus(204);
}Sign the raw body, not the parsed one.
A framework that parses JSON before your handler runs has already changed the bytes, and re-serializing does not put them back. Read the body as text or bytes first, verify, then parse. Rejecting a timestamp older than a few minutes also closes replay of a captured request.
Retries, and what counts as delivered
Any 2xx is success. Answer quickly and do the work afterwards; each attempt waits ten seconds for a response. Three attempts for transient failures. No response, a timeout, a 5xx, a 408 or a 429 is retried after two seconds and again after eight. Other 4xx responses are not retried. The request was refused, and sending it again unchanged would get the same answer. Redirects are not followed. The URL you configure is the one that is called. A 3xx counts as a refusal. The last ten deliveries are kept in the settings panel, with the status or error of the final attempt. Pausing the webhook keeps the URL and secret and skips deliveries; removing it discards both.
Rotating the secret
Unlike API key rotation, there is no standby period: rotating replaces the secret at once, and a delivery signed with the old one will fail your verification. Put the new secret on the receiving server first, in a way that accepts either for a moment if you can, then rotate.