# Webhooks

> 接收来自 Kanorio 的已签署事件通知，验证请求，确保每个传送仅处理一次，并刷新您的前端缓存。

> Markdown version of https://kanorio.com/zh-CN/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
>
> Translated from the English original: https://kanorio.com/en/developers/webhooks.md

Webhooks 会在网站发生特定事件时通知您的系统：例如发布文章、提交表单或支付订单。Kanorio 会向订阅该事件的每个端点发送包含 JSON 内容的 HTTPS `POST` 请求。

## 新增端点

您可以在 [网站设置 › Webhooks](https://app.kanorio.com/settings/webhooks) 新增端点，或通过 Management API 使用 `POST /sites/{id}/webhooks`。针对每个端点，您可以选择：

- **事件 (Events)。** [事件参考](https://kanorio.com/en/developers/webhooks/events) 列出了所有事件，并按产品分组，同时标注所需的权限。
- **过滤器 (Filters)。** 集合事件可限制为特定集合，表单事件则可限制为特定表单。
- **签名密钥 (Signing secret)。** 每个端点都有专属的 `whsec_` 密钥，用于验证签名。

每个网站的端点数量取决于您的计划。请参阅 [速率限制与配额](https://kanorio.com/zh-CN/developers/basics/rate-limits)。

## 请求 (The request)

| 标头 | 值 |
|---|---|
| `Content-Type` | `application/json` |
| `X-Kanorio-Event` | 事件键值，例如 `collections.item.published` |
| `X-Kanorio-Delivery` | 传递 ID。在重试或手动重新发布时保持不变。 |
| `X-Kanorio-Signature` | `t=<unix seconds>,v1=<hex signature>` |
| `User-Agent` | `Kanorio-Notifications/1.0` |

每个事件都使用相同的封装格式。事件特定的字段位于 `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" }
}
```

| 字段 | 备注 |
|---|---|
| `id` | 传递 ID，等于 `X-Kanorio-Delivery`。请使用此 ID 进行去重。 |
| `event` | 等于 `X-Kanorio-Event`。 |
| `data` | 事件特定内容。内容事件包含 `locale`、`publicVersion` 和 `cacheTags`。 |
| `operationId` | 针对属于追踪操作的事件进行设置，例如表单提交、支付、退款和访问授权。请使用 `GET /operations/{id}` 查询。否则为 `null`。 |
| `principal` | 发起变更的对象：`USER`、`SERVICE_ACCOUNT`、`CUSTOMER`、`SYSTEM` 或 `VISITOR`。未知时为 `null`；切勿将 `null` 视为上述任何一种。 |

## 验证签名

使用端点的密钥计算 `{t}.{rawBody}` 的 HMAC-SHA256，并以恒定时间 (constant time) 与每个 `v1` 值进行比较。若请求中的 `t` 与您的系统时间相差超过五分钟，请拒绝该请求。请务必使用原始请求内容 (raw request body)，在进行任何 JSON 解析之前进行验证。

当您轮替签名密钥时，Kanorio 会在 24 hours 期间同时使用新旧密钥进行签署，因此标头会包含两个 `v1` 值。只要其中一个相符，即可接受请求。

SDK 会为您处理此过程：

```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 });
}
```

若不使用 SDK，在 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);
  });
}
```

## 响应与重试

- 请在 8 seconds 内回传任何 `2xx` 状态码。若处理时间较长，请先将事件放入队列并立即响应。
- Kanorio 不会跟随重定向。`3xx` 会被视为失败，因此请注册最终的 URL，包含您的框架所预期的结尾斜线。
- 超时、网络错误、`429` 与 `5xx` 错误将在 1m, 5m, 30m, 2h, 8h, 1d, 1d 后重试 7 次。每次重试都会使用新的时间戳重新签署。
- `410 Gone` 会立即禁用该端点。若连续失败 3 次，端点将会暂停，并通知网站团队。
- 传送内容会保留 7 days，因此您可以在此期间从后台重新发送传送记录。

## 确保每个传送仅处理一次

重试与手动重新发送可能会导致同一个事件被传送多次。请存储您已处理的 `X-Kanorio-Delivery` ID，当您再次看到相同的 ID 时，请直接回传 `200` 而无需重复执行作业。

## 刷新前端缓存

每个 Content Delivery API 的响应都会在 `X-Kanorio-Cache-Tags` 中列出其依赖项目，而每个内容事件也会在 `data.cacheTags` 中列出变更项目，并使用相同的标签。请使用响应标签标记您框架的缓存，并重新验证事件标签，这样所有使用该变更内容的页面将在几秒钟内刷新，而其他内容则保持缓存状态。

| 标签 | 响应范围 | 触发事件 |
|---|---|---|
| `site:<id>` | 网站的每项响应 | `content.site.published`, `localization.locale.*`（语言切换器和 hreflang 存在于每个页面） |
| `locale:<code>` | 次要语言的所有响应 | `localization.job.completed` |
| `page:<id>`, `item:<id>`, `product:<id>`, `form:<id>` | 该记录在所有语言中的响应，以及嵌入该记录的响应（面包屑导航祖先、展开的引用） | 记录本身的事件，因此主要语言的变更会刷新所有语言（未翻译的字段会回退至主要文本） |
| `page:<id>@<code>`, `item:<id>@<code>`, … | 仅该语言的响应 | `content.translation.published` / `deleted`：仅刷新该语言 |
| `pages`, `collection:<key>`, `products` (以及 `@<code>`) | 仅列表响应 | 该列表中记录的事件 |
| `collection:<key>:items` | 该集合的每项单一项目响应 | `collections.schema.changed`，以及过大而无法逐项列出的批量变更 |
| `globals`, `header:<key>`, `footer` | 页眉和页脚响应 | `content.site.published`，全局区段翻译 |
| `search`, `seo`, `redirects` | 网站搜索、SEO 文件 (`/seo/*`)、重定向表 | 变更这些项目的内容事件；`content.page.updated` 会在页面重命名时新增 `redirects` |

标签使用语言代码，而非 URL 前缀（拥有者可能会变更前缀）。`revalidateTag` 会匹配完整字符串，因此事件总是列出具体的标签。单笔记录事件不会携带 `site:<id>`：如果您仅使用网站标签来标记缓存，请订阅 `content.site.published`，或者更好的做法是，使用响应本身的标签来标记每次抓取。

在 Next.js 中，`createRevalidateHandler` 可在单行代码内完成签名验证与标签重新验证。它会为每个标签加上 `kanorio:` 前缀，以避免 Kanorio 的标签与您的标签冲突；请通过 `frameworkCacheTags` 或 `cacheTag` 构建器来标记您的抓取内容，确保双方匹配（传入 `tagPrefix: ""` 即可使用原始标签）：

```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),
});
```

对于静态构建的网站，请将 Webhook 指向您主机的部署 Hook，并订阅内容事件即可。
