Webhooks
Eleven 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 eleven 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.
The events
list_webhook_event_types is the authoritative allowlist - read it rather than guessing a name. Today it returns eleven:
- Orders -
order.created,order.partially_paid,order.paid,order.partially_refunded,order.refunded,order.canceled,order.fulfilled,order.returned,order.edited,order.pipeline_changed. - Content -
content.changed, one coarse event for pages, records, forms and workspace settings.
content.changed is deliberately coarse
There is no page.published. One event covers every content mutation, and the payload says what happened:
{
"kind": "page", // page | record | form | settings
"action": "published", // published | unpublished | created | updated | deleted
"ids": ["6a63..."],
"slug": "/pricing", // pages only
"modelSlug": null // records 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.
Settings are one such kind. Changing the branding or the enabled languages emits kind: "settings" with action: "updated", an empty ids and a null slug - nothing points at a single page, so invalidate the whole tree. Without it, a new logo or a newly enabled language waits out your cache window.
Two 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". And for a batched page delete, slug is the root of the deleted subtree; the descendants in ids lived under other slugs.
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 { revalidatePath } from "next/cache";
import { NextResponse, type NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const secret = process.env.CMSSY_REVALIDATE_SECRET;
const provided =
request.nextUrl.searchParams.get("secret") ??
request.headers.get("x-revalidate-secret");
if (!secret || provided !== secret) {
return NextResponse.json({ revalidated: false }, { status: 401 });
}
const { data } = (await request.json().catch(() => ({}))) as {
data?: { subject?: { slug?: string | null } };
};
const slug = data?.subject?.slug;
// No slug means the affected set is unknown - invalidate the whole tree.
if (slug) revalidatePath(slug);
else revalidatePath("/", "layout");
return NextResponse.json({ revalidated: true, path: slug ?? "/" });
}If your URLs carry a language prefix, revalidate the localized paths too - each one is cached separately.
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. Sign the raw body - re-serializing parsed JSON changes bytes and breaks the comparison:
import { createHmac, timingSafeEqual } from "crypto";
export function verifyCmssySignature(
header: string | null,
rawBody: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
(header ?? "").split(",").map((p) => p.split("=") as [string, string]),
);
if (!parts.t || !parts.v1) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
// Reject an old timestamp as well: a captured delivery is replayable.
return a.length === b.length && timingSafeEqual(a, b);
}Retries
cmssy waits 5 seconds for a response. Anything other than a 2xx - or a timeout - is retried, up to 8 attempts, backing off 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h. A permanent failure stops earlier.
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: a delivery you already processed can arrive again, keyed by the same id and createdAt.
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. Old signatures stop verifying immediately.update_webhook- partial update; passenabledto pause an endpoint without deleting it.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, *.local, *.internal, link-local and RFC 1918 ranges. 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.