# Webhooks

> 接收來自 Kanorio 的已簽署事件通知，驗證請求，確保每個傳送僅處理一次，並重新整理您的前端快取。

> Markdown version of https://kanorio.com/zh-Hant/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-Hant/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，並訂閱內容事件即可。
