API reference
Webhooks
Receive signed event notifications from Kanorio, verify them, process each delivery once and refresh your front end's cache.
Webhooks tell your systems when something happens on a site: a post is published, a form is submitted, an order is paid. Kanorio sends an HTTPS POST with a JSON body to each endpoint subscribed to the event.
Add an endpoint
Add endpoints under Site settings › Webhooks, or with POST /sites/{id}/webhooks on the Management API. For each endpoint you choose:
- Events. The event reference lists every event, grouped by product, with the permission it needs.
- Filters. Collection events can be limited to specific collections, and form events to specific forms.
- The signing secret. Each endpoint has its own
whsec_secret for verifying signatures.
The number of endpoints per site depends on the plan. See Rate limits and quotas.
The request
| Header | Value |
|---|---|
Content-Type | application/json |
X-Kanorio-Event | The event key, such as collections.item.published |
X-Kanorio-Delivery | The delivery id. It stays the same on retries and manual resends. |
X-Kanorio-Signature | t=<unix seconds>,v1=<hex signature> |
User-Agent | Kanorio-Notifications/1.0 |
Every event uses the same envelope. Event-specific fields are in data.
{ "version": "1", "id": "3f9c2a8b1e4d7c0f9a2b3c4d5e6f7a8b", "event": "collections.item.published", "occurredAt": "2026-11-02T08:30:00.000Z", "websiteId": "ws_example123", "title": "Collection item published: Sourdough Guide", "summary": "blog/sourdough-guide (published)", "data": { "collectionKey": "blog", "itemId": "item_01", "slug": "sourdough-guide", "status": "published", "publicVersion": "1729500000000", "cacheTags": ["collection:blog", "item:item_01", "seo", "search"] }, "actionUrl": "https://app.kanorio.com/data/blog", "operationId": null, "principal": { "kind": "USER", "id": "usr_example01" } }
| Field | Notes |
|---|---|
id | The delivery id, equal to X-Kanorio-Delivery. Use it to deduplicate. |
event | Equal to X-Kanorio-Event. |
data | Event-specific. Content events include locale, publicVersion and cacheTags. |
operationId | Set on events that belong to a tracked operation, such as form submissions, payments, refunds and access grants. Look it up with GET /operations/{id}. null otherwise. |
principal | Who started the change: USER, SERVICE_ACCOUNT, CUSTOMER, SYSTEM or VISITOR. null when unknown; never treat null as any of these. |
Verify the signature
Compute an HMAC-SHA256 of {t}.{rawBody} with the endpoint's secret and compare it, in constant time, to each v1 value. Reject requests whose t is more than five minutes from your clock. Always use the raw request body, before any JSON parsing.
While you rotate the signing secret, Kanorio signs with both the old and the new secret for 24 hours, so the header carries two v1 values. Accept the request if either matches.
The SDK does this for you:
import { verifyWebhookSignature } from "@kanorio/sdk"; export async function POST(request: Request) { const rawBody = await request.text(); const valid = await verifyWebhookSignature({ rawBody, signatureHeader: request.headers.get("x-kanorio-signature"), signingSecret: process.env.KANORIO_WEBHOOK_SECRET!, }); if (!valid) return new Response("Invalid signature", { status: 401 }); const event = JSON.parse(rawBody); // handle event.event and event.data return new Response(null, { status: 200 }); }
Without the SDK, in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyKanorioWebhook(rawBody, header, secret) { if (!header || !secret) return false; const parts = header.split(",").map((part) => part.split("=")); const t = parts.find(([key]) => key === "t")?.[1]; const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value); if (!t || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); return signatures.some((v1) => { const actual = Buffer.from(v1); return actual.length === expected.length && timingSafeEqual(actual, expected); }); }
Respond and retry
- Return any
2xxwithin 8 seconds. If processing takes longer, queue the event and respond first. - Kanorio does not follow redirects. A
3xxcounts as a failure, so register the final URL, including any trailing slash your framework expects. - Timeouts, network errors,
429and5xxare retried 7 times, after 1m, 5m, 30m, 2h, 8h, 1d, 1d. Each retry is signed again with a fresh timestamp. 410 Gonedisables the endpoint immediately. After 3 failed deliveries in a row, the endpoint is paused and the site team is notified.- Delivery payloads are kept for 7 days, so you can resend a delivery from the dashboard during that time.
Process each delivery once
Retries and manual resends can deliver the same event more than once. Store the X-Kanorio-Delivery ids you have handled, and return 200 without doing the work again when you see one twice.
Refresh your front end's cache
Every Content Delivery API response lists what it depends on in X-Kanorio-Cache-Tags, and every content event lists what changed in data.cacheTags, using the same tags. Tag your framework's cache with the response tags, revalidate the event tags, and every page that used the changed content refreshes within seconds while everything else stays cached.
| Tag | On which responses | Sent by which events |
|---|---|---|
site:<id> | Every response of the site | content.site.published, localization.locale.* (the language switcher and hreflang are on every page) |
locale:<code> | Every response of a secondary locale | localization.job.completed |
page:<id>, item:<id>, product:<id>, form:<id> | The record's own response in every locale, plus responses that embed it (breadcrumb ancestors, expanded references) | The record's own events, so a change in the primary locale refreshes every locale (untranslated fields fall back to the primary text) |
page:<id>@<code>, item:<id>@<code>, … | Only that locale's response | content.translation.published / deleted: only that locale refreshes |
pages, collection:<key>, products (and @<code>) | List responses only | Events of records in that list |
collection:<key>:items | Every single-item response of the collection | collections.schema.changed, and bulk changes too large to list item by item |
globals, header:<key>, footer | Header and footer responses | content.site.published, global-section translations |
search, seo, redirects | Site search, SEO files (/seo/*), the redirect table | Content events that change them; content.page.updated adds redirects when a page was renamed |
Tags use locale codes, never URL prefixes (the owner can change a prefix). revalidateTag matches whole strings, so events always list concrete tags. Single-record events do not carry site:<id>: if you only tag your cache with the site tag, subscribe to content.site.published or, better, tag each fetch with the response's own tags.
In Next.js, createRevalidateHandler verifies the signature and revalidates the tags in one line. It prefixes every tag with kanorio: so Kanorio's tags cannot collide with your own; tag your fetches through frameworkCacheTags or the cacheTag builders so both sides match (pass tagPrefix: "" to use the raw tags instead):
// app/api/revalidate/route.ts import { revalidateTag } from "next/cache"; import { createRevalidateHandler } from "@kanorio/sdk"; export const POST = createRevalidateHandler({ signingSecret: process.env.KANORIO_WEBHOOK_SECRET!, revalidateTag: (tag) => revalidateTag(tag), });
For a statically built site, point a webhook at your host's deploy hook instead and subscribe to the content events.