# Webhooks

> Receive signed event notifications from Kanorio, verify them, process each delivery once and refresh your front end's cache.

> Markdown version of https://kanorio.com/en/developers/webhooks. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

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](https://app.kanorio.com/settings/webhooks), or with `POST /sites/{id}/webhooks` on the Management API. For each endpoint you choose:

- **Events.** The [event reference](https://kanorio.com/en/developers/webhooks/events) 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](https://kanorio.com/en/developers/basics/rate-limits).

## 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`.

```json
{
  "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:

```ts
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:

```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 `2xx` within 8 seconds. If processing takes longer, queue the event and respond first.
- Kanorio does not follow redirects. A `3xx` counts as a failure, so register the final URL, including any trailing slash your framework expects.
- Timeouts, network errors, `429` and `5xx` are retried 7 times, after 1m, 5m, 30m, 2h, 8h, 1d, 1d. Each retry is signed again with a fresh timestamp.
- `410 Gone` disables 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):

```ts
// 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.
