Webhooks

Two tiers of events, an HMAC signature over the raw body, eight attempts with backoff - and one event that keeps a headless frontend fresh.

A webhook is how cmssy tells your app that something changed, instead of your app asking. There are two tiers of events, one endpoint shape, and one detail that matters more than the rest: content.changed is what turns a publish into a live page on a cached frontend.

Two tiers, two questions

The tiers answer different questions, and most integrations only need one of them.

  • content.changed - "something I serve changed, invalidate your cache". One coarse event. This is what a headless frontend subscribes to.
  • Authoring events - "somebody did something". Twenty-one granular events for automation, audit trails and notifications: a Slack message when a page is published, a CRM row when a form is submitted.
  • order.* - ten commerce lifecycle events.

list_webhook_event_types is the authoritative allowlist and it is filtered by your permissions - read it rather than guessing a name.

content.changed, for cache invalidation

One event covers every change to what the delivery API serves, and the payload says what happened:

{
  "kind": "page",          // page | record | model | form | media | settings
  "action": "published",   // published | unpublished | created | updated | deleted
  "ids": ["6a63..."],
  "slug": "/pricing",      // pages only
  "modelSlug": null         // records and models only
}

Subscribe once and treat any pair as "invalidate". That is the point of the coarseness: a new subject kind added later reaches you without a new subscription.

Three edges worth coding for. A bulk operation whose affected set is unknown or larger than 100 sends an empty ids array - treat that as "invalidate everything". For a batched page delete, slug is the root of the deleted subtree; the descendants in ids lived under other slugs. And kind: "settings" always carries an empty ids and a null slug - nothing points at a single page, so invalidate the whole tree.

Deleting a model emits two events: one for the definition and one for the records that went with it. A consumer caching model definitions and one caching records need different things invalidated.

The authoring tier

These do not replace content.changed - they answer the other question. Subscribe to them for automation, not for cache invalidation.

  • Pages - page.created, page.updated, page.deleted, page.published, page.unpublished.
  • Records - record.created, record.updated, record.deleted.
  • Models - model.created, model.updated, model.deleted.
  • Forms - form.created, form.updated, form.deleted, form.submitted.
  • Media - media.uploaded, media.updated, media.deleted.
  • Members - member.added, member.updated, member.removed.
  • Settings - settings.updated.

They carry the same payload shape as content.changed, so one parser handles both tiers. Payloads are lean references, never content: form.submitted gives you the submission id and the form slug, not the submitted values - fetch those through the API with your own credentials.

Two asymmetries are deliberate. page.updated fires for a doc-level change whether or not the page is published, but only a published page also emits content.changed - editing an unpublished draft is authoring work, not a delivery change. And media.uploaded is granular-only: an asset created a second ago cannot be referenced by any published page, so there is nothing to invalidate. Editing or deleting one does emit content.changed.

Which permissions a subscription needs

Subscribing an endpoint to an event requires permission to read what the event describes, on top of webhooks:manage. A mixed subscription requires all of them.

  • order.* - orders:view
  • page.* - pages:view
  • record.*, model.* - models:view
  • form.created|updated|deleted - forms:view
  • form.submitted - forms:submissions:view
  • media.* - media:view
  • member.* - users:view
  • settings.updated - site:config:edit

content.changed can carry any of the delivery kinds, so it requires the union of what those kinds need: pages:view + models:view + forms:view + media:view. The same set is checked when you edit an existing endpoint, so a role that cannot subscribe to an event cannot keep an endpoint carrying it alive either.

Keeping a cached frontend fresh

Publishing does not deploy, and it does not bypass your cache either. A page cached with revalidate = 3600 keeps serving the old copy for up to an hour unless something invalidates it. That something is a content.changed webhook pointed at a revalidation route:

// app/api/revalidate/route.ts
import { createCmssyRevalidateRoute } from "@cmssy/next/server";

export const POST = createCmssyRevalidateRoute({
  secret: process.env.CMSSY_WEBHOOK_SECRET,
});

npx @cmssy/cli init writes exactly this route. It verifies the delivery signature and expires everything cached under the cmssy-content tag, so the next visitor renders the published content; put the signing secret from Settings → Webhooks in CMSSY_WEBHOOK_SECRET. Writing your own route instead? Verify the body with verifyCmssyWebhook from @cmssy/core first - then the notes below apply.

If your URLs carry a language prefix, revalidate the localized paths too - each one is cached separately. And if your nav, sitemap or parent listings are cached under a tag, clear that tag on every event: a newly published page is live but missing from every sidebar until you do.

Prefer per-subject invalidation over a full rebuild where you can. subject.ids gives you the exact records that changed for single-record writes, so tagging your fetches by record id lets you refresh one product detail page instead of the whole site.

What a delivery looks like

Every delivery is a POST with content-type: application/json and this body:

{
  "id": "6a64...",                       // the webhook endpoint id
  "event": "content.changed",
  "createdAt": "2026-07-25T18:30:00.000Z",
  "data": { "workspaceId": "...", "subject": { } }
}

Three headers come with it: x-cmssy-event, x-cmssy-webhook-id and x-cmssy-signature.

Verify the signature

The signature header is t=<unix-ms>,v1=<hex>, where the hex is an HMAC-SHA256 over <t>.<raw body> keyed with the endpoint secret. During a secret rotation the header carries one v1 per active secret, so a verifier has to accept a match against any of them - checking only the last one silently drops half your deliveries mid-rotation.

Use the helper the SDK ships rather than writing this by hand. It reads every v1, compares in constant time, rejects a timestamp more than 5 minutes old so a captured delivery is not replayable, and returns the typed event. It is exported from @cmssy/next, @cmssy/remix and @cmssy/astro (and from @cmssy/core if you use none of them):

import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";

export async function POST(request: Request) {
  const body = await request.text();

  try {
    const event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: process.env.CMSSY_WEBHOOK_SECRET!,
    });

    handle(event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

Pass the raw body - await request.text(), never a re-serialized object. Parsing and re-stringifying JSON changes bytes and breaks the comparison.

Retries

cmssy waits 5 seconds for a response and does not follow redirects: a 3xx is a failed attempt, not a hop, so register the final URL. Anything other than a 2xx, plus timeouts, is retried up to 8 attempts, backing off 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h.

Only 410 Gone ends the ladder early. Every other 4xx is retried, because "this endpoint is finished for good" and "the deploy that owns this route is briefly answering 404" look identical from the outside. Return 410 when you mean it.

A Retry-After header - seconds or an HTTP date - is honoured when it asks for longer than the ladder would wait, capped at 4 hours. It never shortens the backoff.

After 20 consecutive failures an endpoint is disabled automatically and stops receiving deliveries. Fix the handler, then re-enable it with update_webhook, which resets the counter.

Two consequences for your handler. It must be fast: acknowledge with a 2xx and do the work after, or a slow endpoint turns into a retry storm. And it must be idempotent: deduplicate on x-cmssy-webhook-id, which is the same on every attempt of a delivery - as is the createdAt in the body.

list_webhook_deliveries shows recent attempts with their status and response code; deliveries are kept for 30 days.

Managing endpoints

  • create_webhook - returns the endpoint and its secret, once. Store it then; it is never returned again.
  • rotate_webhook_secret - returns a new secret, also once. Rotating from the admin UI keeps the previous secret signing in parallel for 24 hours, so you can redeploy without dropping a delivery; rotating through MCP cuts over immediately. Either way the old secret stops verifying the moment its window ends.
  • update_webhook - partial update; pass enabled to pause an endpoint without deleting it. Disabling never needs the event permissions, so a compromised endpoint can always be switched off.
  • list_webhooks, delete_webhook, list_webhook_deliveries.

Up to 20 endpoints per workspace. URLs must be https in production, and private targets are rejected: localhost and its subdomains, *.local, *.internal, IPv6 literals, and the IPv4 ranges that are not the public internet - RFC 1918, loopback, link-local, CGNAT (100.64/10), benchmarking (198.18/15) and everything from 224.0.0.0 up. The hostname is also resolved before each delivery, so a public name that answers with a private address is rejected too. A webhook that can reach your internal network is an SSRF surface, not a feature.

Next steps

  • MCP server - the tools that create and inspect endpoints.
  • Draft preview - publishing, caching and what a stale page means.
  • API tokens - the credential behind those tools.