Reference

Webhooks

When an editor publishes, unpublishes or deletes an entry, Citron posts a small signed message to your site, so it can rebuild only the pages that changed.

Set up a webhook#

  1. Add the addressIn the console, open Settings → Developers → Webhooks and add your endpoint, e.g. https://example.com/api/citron-webhook. It must be a public http(s) address.
  2. Choose events and collectionsPick any of entry.published, entry.unpublished and entry.deleted. Leave the collection list empty to hear about every collection.
  3. Keep the secretCopy the generated secret (whsec_…) into your site’s environment, e.g. CITRON_WEBHOOK_SECRET. It signs every delivery.

Events#

EventSent when
entry.publishedAn entry is published, including a scheduled publish and a new version of a live entry.
entry.unpublishedA live entry is taken offline.
entry.deletedA live entry is deleted. Deleting an entry that was never published sends nothing, since no page showed it.

These three are the only events a webhook receives. New event types will be opt-in per webhook, so an existing endpoint never gets a message it did not ask for.

The request#

Each delivery is a POST with a JSON body and three headers: content-type: application/json, x-citron-event (the event name) and x-citron-signature. Citron waits up to 10 seconds for your answer and ignores its body.

HTTP
POST /api/citron-webhook HTTP/1.1
content-type: application/json
x-citron-event: entry.published
x-citron-signature: sha256=a8db6ae35854b938a4493cfb12b30f5db059390af0a89e9057a10f19ed14fe99

{"event":"entry.published","organizationId":"cmf2x0r1k0000s6014h3j5k7l","collection":"products","entry":{"id":"cmf3k9x2p0004s601q8w7e2rt","slug":"bio-6"},"at":"2026-09-21T09:30:00.000Z"}
Payload
{
  "event": "entry.published",
  "organizationId": "cmf2x0r1k0000s6014h3j5k7l",
  "collection": "products",
  "entry": { "id": "cmf3k9x2p0004s601q8w7e2rt", "slug": "bio-6" },
  "at": "2026-09-21T09:30:00.000Z"
}
FieldMeaning
eventThe event name, the same as x-citron-event.
organizationIdYour workspace’s id.
collectionThe collection slug, as in /v1/content/{collection}.
entry{ id, slug } of the entry. slug can be null.
atWhen the change happened (ISO 8601).

The keys and their order are fixed: the signature is computed over exactly these bytes, so the shape will only ever grow, never change.

Verify the signature#

x-citron-signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret. Compute it over the bytes you received, before any JSON parsing, and compare in constant time. Answer 401 when it does not match.

The header has no timestamp. The payload’s at is covered by the signature, so you can refuse old deliveries by age; keep that window generous, because deliveries wait in a queue when Citron’s worker is down.

import { constructWebhookEvent, WebhookVerificationError } from '@citron/sdk/webhooks';

// Any runtime with Request/Response: Node 18+, edge, Workers
export async function handleCitronWebhook(req: Request): Promise<Response> {
  const raw = await req.text(); // the RAW body: re-serialised JSON has different bytes
  try {
    const event = await constructWebhookEvent(raw, req.headers.get('x-citron-signature'), process.env.CITRON_WEBHOOK_SECRET!, {
      maxAgeSeconds: 24 * 3600, // optional; the signed "at" makes old deliveries refusable
    });
    console.log(event.event, event.collection, event.entry.slug);
    return new Response(null, { status: 204 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response(null, { status: 401 });
    throw err;
  }
}

To test a handler, sign a body yourself: signWebhookBody(body, secret) from @citron/sdk/webhooks returns the header value. The example above was signed with the secret whsec_3Jx9mQ2vR7tLp8Kd4Nw6Yb1Hs5Fc0Zeq.

Revalidate a Next.js site#

The SDK ships an App Router route handler. Tag your fetches with citronTags(collection, slug), and the handler revalidates exactly those tags when an entry changes. Pages stay static until an editor publishes.

app/api/citron-webhook/route.ts
// app/api/citron-webhook/route.ts
import { revalidateTag } from 'next/cache';
import { citronTags, createRevalidateHandler } from '@citron/sdk/next';

// 503 without a secret, 401 for a bad signature, 500 if onEvent throws (the API retries)
export const POST = createRevalidateHandler({
  secret: process.env.CITRON_WEBHOOK_SECRET,
  onEvent: (e) => citronTags(e.collection, e.entry.slug).forEach((tag) => revalidateTag(tag)),
});
TypeScript SDK
import { citronTags } from '@citron/sdk/next';

// Tag the fetch so the webhook above can revalidate exactly this page:
// citronTags('products', 'bio-6') → ['citron:products', 'citron:products:bio-6']
const product = await citron.collection('products').find('bio-6', {
  fetchOptions: { next: { revalidate: 3600, tags: citronTags('products', 'bio-6') } },
});
console.log(product?.slug);

Retries#

Any 2xx answer counts as delivered. A timeout, a network error or any other status is retried twice: after 10 seconds and again after one minute. After the third attempt the delivery is dropped. An address that resolves to a private network is refused and not retried.

  • Answer quickly and do slow work afterwards; a handler that takes longer than 10 seconds is retried even if it finished.
  • Make the handler idempotent. Revalidating a tag twice is harmless.
  • Return 500 when your own work fails and you want another attempt; the SDK’s handler does this when onEvent throws.

This reference covers the public API only and follows the API’s compatibility contract: public responses only ever gain fields. The OpenAPI description of these routes is at /developers/openapi.json.