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.

EventSent when
content.publishedAssets are published, from the dashboard or a batch write with publishImmediately.
content.unpublishedA rollback, so the previously published version is being served again.
content.disabledA published asset is disabled and stops being served.
content.enabledA disabled asset with published content is served again.
content.deletedA published asset is deleted, so its tag no longer resolves.
pingYou 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"
}
HeaderValue
X-Duggie-Signaturet=1757253792,v1=5f1a…, the timestamp and the HMAC. See below.
X-Duggie-EventThe event name, so a receiver can route without parsing the body.
X-Duggie-DeliveryThe 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.

verify on the receiving server
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.