> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waffo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> 接收实时事件通知

## 概述

Webhook 在事件发生时向您的服务器发送实时通知 -- 订单、支付、订阅和退款。

```
Event occurs → Waffo Pancake → fan-out → Your channel(s)
```

单个门店可以配置**多个 webhook**，每个投递到不同的渠道。可用渠道如下：

| 渠道         | 投递目标                           | 载荷格式                    |
| ---------- | ------------------------------ | ----------------------- |
| `http`     | 您的 HTTPS 端点                    | RSA-SHA256 签名的 JSON 信封  |
| `feishu`   | 飞书 / Lark 机器人入站 webhook        | 飞书交互卡片                  |
| `discord`  | Discord 频道 webhook             | Embed 消息（Discord 格式）    |
| `telegram` | Telegram 机器人 `sendMessage` URL | HTML 格式文本               |
| `slack`    | Slack 入站 webhook               | 含 mrkdwn 字段的 Attachment |

`http` 渠道使用本页所述的 JSON 信封和签名验证 -- 本指南大部分内容覆盖该渠道。对于 IM 类渠道，载荷使用各平台原生格式，认证由 URL 中的 token 完成；无需验证签名。

<Tip>
  **使用 TypeScript？** [@waffo/pancake-ts](https://www.npmjs.com/package/@waffo/pancake-ts) SDK 内置了公钥并自动检测环境 -- 一行代码即可完成 `http` 渠道的验签。
</Tip>

***

## 配置步骤

<Steps>
  <Step title="选择渠道并准备 URL">
    * **HTTP** -- 构建一个接受 POST 请求并返回 `200` 的服务端端点。
    * **飞书 / Discord / Telegram / Slack** -- 在目标平台创建机器人或入站 webhook，复制其 URL。Telegram 还需记录接收消息的 chat ID。
  </Step>

  <Step title="（仅 HTTP）复制验签公钥">
    Waffo 每个环境（Test / Production）使用一对固定密钥，**所有门店共用**。注册 Webhook 时不会返回公钥给你，你需要直接到 Dashboard 里复制。

    打开 [控制台](https://pancake.waffo.ai/merchant/dashboard) → 进入任意门店 → **设置 → Webhooks**，复制你要对接的环境（Test 或 Production）对应的 **Webhook Public Key**，存到你的服务端配置里 —— 之后所有 HTTP webhook 都用它来验签。

    <Note>所有门店在 Dashboard 看到的 Test 公钥相同、Production 公钥相同 —— 这是平台级密钥。新增、修改、删除 Webhook URL 都不会改变它。</Note>
  </Step>

  <Step title="注册 Webhook">
    在 **控制台 → 设置 → Webhooks** 中添加 webhook，或调用 [`POST /v1/actions/store/add-webhook`](/zh/api-reference/endpoints/webhooks/add-webhook)。每条 webhook 记录指定一个渠道、一个 URL、订阅的事件以及目标环境（Test 设 `testMode: true`，Production 设 `false`）。每个门店可注册多条 webhook。
  </Step>

  <Step title="发送测试事件">
    使用 Dashboard 的 "Send Test Event" 按钮，将示例事件投递到您注册的某一条或全部 webhook。
  </Step>

  <Step title="验签并处理事件">
    对于 HTTP 渠道，使用下方代码示例在处理事件前验证签名。IM 渠道投递的是预渲染消息，无需在您侧处理。
  </Step>
</Steps>

***

## 环境隔离

每条 webhook 通过 `testMode` 标志注册到单一环境。Test 和 Production 完全独立：

| 项目           | Test                      | Production                     |
| ------------ | ------------------------- | ------------------------------ |
| Webhook 记录   | `testMode: true`          | `testMode: false`              |
| 签名密钥（仅 HTTP） | Test 密钥对                  | Production 密钥对                 |
| 验签公钥（仅 HTTP） | Dashboard Test 公钥         | Dashboard Production 公钥        |
| 投递时的选择器      | 请求头 `X-Environment: test` | 请求头 `X-Environment: prod`（或省略） |

每个 HTTP 载荷中的 `mode` 字段表示来源环境：`"test"` 或 `"prod"`。

<Warning>
  始终使用与事件 `mode` 匹配的公钥。Test 密钥无法验证 Production 事件，反之亦然。
</Warning>

***

## 载荷格式

### 请求头

| 请求头                 | 说明                                   |
| ------------------- | ------------------------------------ |
| `Content-Type`      | `application/json`                   |
| `X-Waffo-Signature` | 签名字符串：`t=<timestamp>,v1=<signature>` |
| `X-Waffo-Event`     | 事件类型（例如 `order.completed`）           |

### 请求体

```json theme={"system"}
{
  "id": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "timestamp": "2026-03-10T08:30:00.000Z",
  "eventType": "order.completed",
  "eventId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "storeId": "STO_3bVzrkD0FJjFdZNLk8Ualx",
  "storeName": "My Store",
  "mode": "prod",
  "data": {
    "orderId": "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
    "orderStatus": "completed",
    "buyerEmail": "customer@example.com",
    "currency": "USD",
    "amount": "29.00",
    "taxAmount": "2.90",
    "taxRate": 0.1,
    "taxName": "Consumption Tax",
    "subtotal": "26.10",
    "total": "29.00",
    "productName": "Pro Plan",
    "orderMetadata": { "planId": "pro" },
    "orderMerchantExternalId": "ORDER-2026-00891",
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}
```

### 顶层字段

| 字段          | 类型     | 说明                                                    |
| ----------- | ------ | ----------------------------------------------------- |
| `id`        | string | 事件实体 ID -- 对大多数事件与 `eventId` 相同                       |
| `timestamp` | string | 事件时间（ISO 8601 UTC）                                    |
| `eventType` | string | 事件类型（参见 [事件类型](#事件类型)）                                |
| `eventId`   | string | 业务事件标识符 -- 按事件类型映射到不同实体（参见 [eventId 映射](#eventid-映射)） |
| `storeId`   | string | Store ID                                              |
| `storeName` | string | 商店名称                                                  |
| `mode`      | string | `"test"` 或 `"prod"`                                   |

### `data` 字段

`data` 对象包含交易详情。部分字段始终存在，其他字段仅在特定事件类型或数据可用时出现。

**始终存在：**

| 字段                | 类型     | 说明                                              |
| ----------------- | ------ | ----------------------------------------------- |
| `orderId`         | string | 订单 ID                                           |
| `orderStatus`     | string | 订单状态（例如 `"completed"`、`"active"`、`"canceling"`） |
| `buyerEmail`      | string | customer 邮箱地址                                   |
| `currency`        | string | ISO 4217 货币代码（例如 `"USD"`、`"JPY"`）               |
| `amount`          | string | 交易总额含税（显示格式，例如 `"29.00"`）                       |
| `taxAmount`       | string | 税额（显示格式，例如 `"2.90"`）                            |
| `productName`     | string | 商品名称                                            |
| `orderMetadata`   | object | 收银台会话中的订单级元数据（商户定义的键值对）                         |
| `productMetadata` | object | 创建/更新商品时设置的商品级元数据                               |

**有值时包含：**

| 字段                               | 类型     | 出现条件       | 说明                                                                      |
| -------------------------------- | ------ | ---------- | ----------------------------------------------------------------------- |
| `merchantProvidedBuyerIdentity`  | string | 在收银台时设置    | 商户自定义的 customer 标识                                                      |
| `orderMerchantExternalId`        | string | 在收银台时设置    | 订单的商户业务侧标识（在 checkout 创建时设置，最大 128 字符）。订单事件存在；`refund.*` 事件也存在（从原订单继承）。 |
| `refundTicketMerchantExternalId` | string | 在退款工单创建时设置 | 退款工单的商户业务侧标识（最大 128 字符）。仅 `refund.succeeded` / `refund.failed` 事件存在。    |
| `billingDetail`                  | object | 在收银台时设置    | 账单地址（`country`、`isBusiness` 等）                                          |
| `taxRate`                        | number | 应用了税率      | 税率小数（例如 `0.1` 表示 10%）                                                   |
| `taxName`                        | string | 应用了税率      | 税种名称（例如 `"Consumption Tax"`）                                            |
| `subtotal`                       | string | 可用时        | 税前小计（显示格式）                                                              |
| `total`                          | string | 可用时        | 税后合计（显示格式）                                                              |
| `productDescription`             | string | 商品已设置      | 商品描述                                                                    |

**支付事件**（`order.completed`、`subscription.payment_succeeded`）：

| 字段                     | 类型     | 说明                                  |
| ---------------------- | ------ | ----------------------------------- |
| `paymentId`            | string | 支付 ID                               |
| `paymentStatus`        | string | 支付状态（`"succeeded"`、`"failed"`）      |
| `paymentMethod`        | string | 支付方式（例如 `"card"`）                   |
| `paymentLast4`         | string | 支付工具末 4 位                           |
| `paymentDate`          | string | 支付日期（ISO 8601 日期，例如 `"2026-03-10"`） |
| `paymentFailureReason` | string | 失败原因（支付失败时）                         |

**订阅事件**（`subscription.*`）：

| 字段                   | 类型     | 说明                                              |
| -------------------- | ------ | ----------------------------------------------- |
| `billingPeriod`      | string | `"weekly"`、`"monthly"`、`"quarterly"`、`"yearly"` |
| `currentPeriodStart` | string | 当前计费周期开始日期（ISO 8601 日期）                         |
| `currentPeriodEnd`   | string | 当前计费周期结束日期（ISO 8601 日期）                         |
| `canceledAt`         | string | 取消时间戳（ISO 8601，取消中/已取消时存在）                      |

**退款事件**（`refund.succeeded`、`refund.failed`）：

| 字段                               | 类型     | 说明                         |
| -------------------------------- | ------ | -------------------------- |
| `refundStatus`                   | string | `"succeeded"` 或 `"failed"` |
| `refundReason`                   | string | 退款原因                       |
| `refundCreatedAt`                | string | 退款创建时间戳（ISO 8601）          |
| `orderMerchantExternalId`        | string | 订单的商户业务侧标识（从原订单继承，设置时存在）   |
| `refundTicketMerchantExternalId` | string | 退款工单的商户业务侧标识（工单创建时设置则存在）   |
| `paymentId`                      | string | 原始支付 ID                    |
| `paymentStatus`                  | string | 原始支付状态                     |
| `paymentMethod`                  | string | 原始支付方式                     |
| `paymentLast4`                   | string | 原始支付末 4 位                  |
| `paymentDate`                    | string | 原始支付日期                     |

`refund.succeeded` 事件的 `data` 示例（仅展示退款相关字段）：

```json theme={"system"}
{
  "refundStatus": "succeeded",
  "refundReason": "Customer requested refund",
  "refundCreatedAt": "2026-03-12T09:15:00.000Z",
  "orderMerchantExternalId": "ORDER-2026-00891",
  "refundTicketMerchantExternalId": "REF-2026-00891",
  "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "paymentStatus": "succeeded",
  "paymentMethod": "card",
  "paymentLast4": "4242",
  "paymentDate": "2026-03-10"
}
```

<Note>
  金额为**显示格式字符串**，已从最小单位转换。例如，USD `"29.00"` = 2900 美分；JPY `"4500"` = 4500 日元。有值时可使用 `subtotal` 和 `total` 进行明细展示。
</Note>

### eventId 映射

`eventId` 标识触发事件的业务实体：

| 事件类型                             | eventId 映射到   | 示例                                   |
| -------------------------------- | ------------- | ------------------------------------ |
| `order.completed`                | Payment ID    | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.activated`         | Order ID      | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.payment_succeeded` | Payment ID    | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.canceling`         | Order ID      | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.uncanceled`        | Order ID      | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.updated`           | Order ID      | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.canceled`          | Order ID      | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.past_due`          | Order ID + 月份 | `ORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04` |
| `refund.succeeded`               | Refund ID     | `REF_4cWAtlE1GKkGebONl9Xbnx`         |
| `refund.failed`                  | Refund ID     | `REF_4cWAtlE1GKkGebONl9Xbnx`         |

<Note>
  `subscription.past_due` 会在 eventId 后追加 `-YYYY-MM`。同一订阅每个自然月最多触发一次 `past_due` 事件。若下月仍逾期，将触发新事件。
</Note>

***

## 事件类型

### 概览

| 事件                               | 触发条件                | eventId       |
| -------------------------------- | ------------------- | ------------- |
| `order.completed`                | 一次性订单支付成功           | Payment ID    |
| `subscription.activated`         | 订阅首次支付成功            | Order ID      |
| `subscription.payment_succeeded` | 续期支付成功（非首次）         | Payment ID    |
| `subscription.canceling`         | 请求取消 -- 当前付费期结束前仍有效 | Order ID      |
| `subscription.uncanceled`        | 撤回取消                | Order ID      |
| `subscription.updated`           | 产品变更（升级/降级）         | Order ID      |
| `subscription.canceled`          | 订阅终止（付费期结束）         | Order ID      |
| `subscription.past_due`          | 续期支付失败              | Order ID + 月份 |
| `refund.succeeded`               | 退款完成                | Refund ID     |
| `refund.failed`                  | 退款失败                | Refund ID     |

<Note>
  `subscription.uncanceled` 和 `subscription.updated` 事件模板已就绪，将在相应功能上线后激活。
</Note>

### 事件详情

<AccordionGroup>
  <Accordion title="order.completed" icon="check">
    **触发条件**：一次性订单首次支付成功。

    **载荷**：

    * `data.amount` -- 支付金额（含税）
    * `data.orderId` -- 一次性订单 ID

    **建议操作**：

    * 交付数字商品（许可证密钥、下载链接、激活码）
    * 更新您的订单管理系统
    * 向 customer 发送确认（如果未使用 Waffo 内置邮件）

    同一订单只会触发一次 `order.completed`。退款通过 `refund.succeeded` / `refund.failed` 通知。
  </Accordion>

  <Accordion title="subscription.activated" icon="play">
    **触发条件**：新订阅的首次支付成功（`pending` → `active`）。

    **载荷**：

    * `data.amount` -- 首次支付金额（含税）
    * `data.productName` -- 订阅产品名称

    **建议操作**：

    * 为订阅者开通账户和授予访问权限
    * 记录订阅开始日期

    仅在订阅首次从 `pending` 转为 `active` 时触发。后续续期使用 `subscription.payment_succeeded`。
  </Accordion>

  <Accordion title="subscription.payment_succeeded" icon="credit-card">
    **触发条件**：周期性续期支付成功（非首次支付）。

    **载荷**：

    * `data.amount` -- 本期续费金额（含税）
    * `data.orderId` -- 订阅订单 ID

    **建议操作**：

    * 延长服务期限
    * 为本计费周期生成发票
    * 如果订阅之前处于 `past_due` 状态，恢复完整访问权限
  </Accordion>

  <Accordion title="subscription.canceling" icon="clock">
    **触发条件**：customer 或商户请求取消。订阅在当前付费期结束前仍保持有效。

    **建议操作**：

    * 显示"订阅将于 \[日期] 到期"提示
    * 提供挽留流程（例如折扣续费）
    * **不要**撤回访问权限 -- customer 已为当前周期付费

    customer 可以在周期结束前撤回取消（触发 `subscription.uncanceled`）。
  </Accordion>

  <Accordion title="subscription.uncanceled" icon="rotate-left">
    **触发条件**：在当前周期结束前撤回取消。

    **建议操作**：

    * 移除"即将到期"提示
    * 恢复自动续费状态
  </Accordion>

  <Accordion title="subscription.updated" icon="arrows-rotate">
    **触发条件**：订阅产品变更（升级或降级）。

    **载荷**：

    * `data.productName` -- 变更后的新产品名称
    * `data.amount` -- 新金额

    **建议操作**：

    * 更新 customer 的访问级别（添加/移除功能）
    * 更新计费记录
  </Accordion>

  <Accordion title="subscription.canceled" icon="xmark">
    **触发条件**：订阅已终止 -- 付费期结束，不会再有续费。

    **建议操作**：

    * 撤回访问权限（或降级到免费版）
    * 保留数据一段宽限期（以防 customer 重新订阅）
    * 发送"订阅已结束"确认

    这是终态。订阅已不可逆地结束。
  </Accordion>

  <Accordion title="subscription.past_due" icon="triangle-exclamation">
    **触发条件**：续期支付失败，订阅进入逾期状态。

    **载荷**：

    * `data.amount` -- 本期应付金额
    * `eventId` -- 格式：`{orderId}-YYYY-MM`（按月去重）

    **建议操作**：

    * 通知 customer 更新支付方式
    * 可选择降级服务（限制功能而非完全撤回）
    * **不要**立即撤回访问权限 -- PSP 可能会自动重试扣款

    **去重**：每个订阅每自然月最多一次 `past_due` 事件。若下月仍逾期，将触发新事件。
  </Accordion>

  <Accordion title="refund.succeeded" icon="money-bill-transfer">
    **触发条件**：退款已完成，资金已退回。

    **载荷**：

    * `data.amount` -- 退款金额（含税）
    * `data.orderId` -- 原始订单 ID

    **建议操作**：

    * 撤销已交付的数字商品（吊销许可证、禁用下载）
    * 将订单状态更新为"已退款"
  </Accordion>

  <Accordion title="refund.failed" icon="circle-exclamation">
    **触发条件**：退款处理失败。

    **建议操作**：

    * 记录失败信息以供人工审核
    * 不要撤销商品（退款未完成）
  </Accordion>
</AccordionGroup>

### 订阅生命周期

```mermaid theme={"system"}
stateDiagram-v2
    [*] --> pending: Order created
    pending --> active: First payment succeeds<br/>subscription.activated
    pending --> closed: Payment timeout

    active --> canceling: Cancellation requested<br/>subscription.canceling
    active --> past_due: Renewal failed<br/>subscription.past_due
    active --> active: Renewal succeeded<br/>subscription.payment_succeeded
    active --> active: Product changed<br/>subscription.updated

    canceling --> active: Cancellation withdrawn<br/>subscription.uncanceled
    canceling --> canceled: Period ended<br/>subscription.canceled

    past_due --> active: Overdue payment recovered<br/>subscription.payment_succeeded
    past_due --> canceled: Final cancellation<br/>subscription.canceled

    active --> expired: Term ended

    canceled --> [*]
    closed --> [*]
    expired --> [*]
```

| 终态         | 含义                     |       触发 Webhook？       |
| ---------- | ---------------------- | :---------------------: |
| `canceled` | 订阅终止（customer/商户取消或逾期） | `subscription.canceled` |
| `closed`   | 从未激活 -- 支付超时           |            否            |
| `expired`  | 固定期限订阅自然到期             |            否            |

***

## 签名验证

**在生产环境中务必验证签名。** 不验证签名的话，任何人都可以向您的端点发送伪造请求。

### 算法

```
1. Parse t (timestamp in ms) and v1 (Base64 signature) from X-Waffo-Signature header
2. Build signature input: `${t}.${rawRequestBody}`
3. Verify v1 using RSA-SHA256 with the Waffo public key
4. (Recommended) Check that t is within 5 minutes of current time to prevent replay attacks
```

### 使用 SDK（推荐）

SDK 内置公钥，自动检测环境，并处理格式标准化：

```typescript theme={"system"}
import { verifyWebhook, WebhookEventType } from "@waffo/pancake-ts";

app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(
      req.body.toString("utf-8"),
      req.headers["x-waffo-signature"] as string,
    );

    res.status(200).send("OK");

    switch (event.eventType) {
      case WebhookEventType.OrderCompleted:
        // Deliver digital goods
        break;
      case WebhookEventType.SubscriptionActivated:
        // Provision subscription access
        break;
      case WebhookEventType.SubscriptionCanceled:
        // Revoke access
        break;
    }
  } catch {
    res.status(401).send("Invalid signature");
  }
});
```

查看完整的 [SDK Webhook 文档](/zh/integrate/webhooks)。

### 手动验证

如果您不使用 TypeScript SDK，请手动实现签名验证。

<CodeGroup>
  ```javascript Node.js (Express) theme={"system"}
  const crypto = require('crypto');

  // From Dashboard → API 与开发 → Webhook Public Key (PEM format)
  const WAFFO_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----`;

  function parseSignatureHeader(header) {
    const parts = {};
    for (const pair of header.split(',')) {
      const [key, ...rest] = pair.split('=');
      parts[key.trim()] = rest.join('=').trim();
    }
    return parts;
  }

  function verifyWebhookSignature(rawBody, signatureHeader, publicKey) {
    const { t, v1 } = parseSignatureHeader(signatureHeader);
    if (!t || !v1) return false;

    // Replay protection: 5-minute tolerance
    const tolerance = 5 * 60 * 1000;
    if (Math.abs(Date.now() - Number(t)) > tolerance) return false;

    // Verify RSA-SHA256 signature
    const signatureInput = `${t}.${rawBody}`;
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(signatureInput);
    return verifier.verify(publicKey, v1, 'base64');
  }

  app.post('/webhooks',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const sig = req.headers['x-waffo-signature'];
      const rawBody = req.body.toString('utf-8');

      if (!sig || !verifyWebhookSignature(rawBody, sig, WAFFO_WEBHOOK_PUBLIC_KEY)) {
        return res.status(401).send('Invalid signature');
      }

      const event = JSON.parse(rawBody);
      res.status(200).send('OK');

      // Process asynchronously
      handleEvent(event).catch(console.error);
    }
  );

  async function handleEvent(event) {
    switch (event.eventType) {
      case 'order.completed':
        await grantAccess(event.data.buyerEmail, event.data.productName);
        break;
      case 'subscription.activated':
        await createSubscription(event.data.buyerEmail, event.data.orderId);
        break;
      case 'subscription.payment_succeeded':
        await extendSubscription(event.data.orderId);
        break;
      case 'subscription.canceling':
        await markCanceling(event.data.orderId);
        break;
      case 'subscription.canceled':
        await revokeAccess(event.data.orderId);
        break;
      case 'subscription.past_due':
        await notifyPastDue(event.data.buyerEmail, event.data.orderId);
        break;
      case 'refund.succeeded':
        await revokeAccess(event.data.orderId);
        break;
      case 'refund.failed':
        await flagForReview(event.data.orderId);
        break;
    }
  }
  ```

  ```python Python (Flask) theme={"system"}
  import json, time
  from base64 import b64decode
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding
  from flask import Flask, request

  app = Flask(__name__)

  WAFFO_WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----"""

  def verify_webhook_signature(raw_body, signature_header, public_key_pem):
      parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False

      # Replay protection: 5-minute tolerance
      if abs(int(time.time() * 1000) - int(t)) > 5 * 60 * 1000:
          return False

      signature_input = f"{t}.{raw_body}".encode("utf-8")
      public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
      try:
          public_key.verify(b64decode(v1), signature_input, padding.PKCS1v15(), hashes.SHA256())
          return True
      except Exception:
          return False

  @app.route("/webhooks", methods=["POST"])
  def handle_webhook():
      sig = request.headers.get("X-Waffo-Signature", "")
      raw_body = request.get_data(as_text=True)

      if not verify_webhook_signature(raw_body, sig, WAFFO_WEBHOOK_PUBLIC_KEY):
          return "Invalid signature", 401

      event = json.loads(raw_body)
      # Process event...
      return "OK", 200
  ```

  ```go Go (net/http) theme={"system"}
  package main

  import (
  	"crypto"
  	"crypto/rsa"
  	"crypto/sha256"
  	"crypto/x509"
  	"encoding/base64"
  	"encoding/pem"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  const waffoPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----`

  func verifyWebhookSignature(rawBody []byte, signatureHeader string) bool {
  	var t, v1 string
  	for _, pair := range strings.Split(signatureHeader, ",") {
  		parts := strings.SplitN(pair, "=", 2)
  		if len(parts) != 2 { continue }
  		switch strings.TrimSpace(parts[0]) {
  		case "t": t = strings.TrimSpace(parts[1])
  		case "v1": v1 = strings.TrimSpace(parts[1])
  		}
  	}
  	if t == "" || v1 == "" { return false }

  	ts, err := strconv.ParseInt(t, 10, 64)
  	if err != nil || math.Abs(float64(time.Now().UnixMilli()-ts)) > 5*60*1000 {
  		return false
  	}

  	block, _ := pem.Decode([]byte(waffoPublicKeyPEM))
  	if block == nil { return false }
  	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
  	if err != nil { return false }
  	rsaPub, ok := pub.(*rsa.PublicKey)
  	if !ok { return false }

  	hash := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", t, string(rawBody))))
  	sig, err := base64.StdEncoding.DecodeString(v1)
  	if err != nil { return false }
  	return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hash[:], sig) == nil
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	rawBody, _ := io.ReadAll(r.Body)
  	if !verifyWebhookSignature(rawBody, r.Header.Get("X-Waffo-Signature")) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("OK"))
  }

  func main() {
  	http.HandleFunc("/webhooks", webhookHandler)
  	http.ListenAndServe(":8080", nil)
  }
  ```
</CodeGroup>

<Warning>
  **您必须使用原始请求体进行签名验证。** 如果您的框架自动解析了 JSON，签名检查将失败。确保在验证前获取未修改的原始字符串。
</Warning>

***

## 响应要求

* 返回 **2xx** 状态码（推荐 `200`）
* 在 **10 秒**内响应
* 响应体内容无要求

```javascript theme={"system"}
// Recommended: respond immediately, process async
app.post('/webhooks', (req, res) => {
  res.status(200).send('OK');
  processEventAsync(req.body);
});
```

非 2xx 响应或超时将触发重试。

***

## 重试策略

投递失败时自动使用指数退避重试：

| 项目   | 详情                  |
| ---- | ------------------- |
| 重试次数 | 最多 3 次（含首次共 4 次尝试）  |
| 策略   | 指数退避                |
| 超时   | 非 2xx 或在超时窗口内无响应    |
| 最终失败 | 所有重试用尽后标记为 `failed` |

### 投递状态

| 状态        | 说明                |
| --------- | ----------------- |
| `pending` | 已创建，等待投递或重试中      |
| `success` | 投递成功（您的服务器返回 2xx） |
| `failed`  | 所有重试已用尽           |

在 Dashboard Webhook 日志中查看投递历史，包括状态、HTTP 响应码和响应体（截断至 1000 字符）。

***

## 处理重复事件

网络问题可能导致同一事件被多次投递。**确保您的事件处理逻辑是幂等的。**

使用 `eventType` + `eventId` 组合（系统中有唯一约束）进行去重：

```javascript theme={"system"}
async function handleEvent(event) {
  const exists = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_type = $1 AND event_id = $2',
    [event.eventType, event.eventId]
  );
  if (exists.rows.length > 0) return; // Already processed

  await processEventLogic(event);

  await db.query(
    'INSERT INTO processed_webhooks (event_type, event_id, processed_at) VALUES ($1, $2, NOW())',
    [event.eventType, event.eventId]
  );
}
```

<Note>
  同一业务事件（相同的 `eventType` + `eventId`）只会创建一条投递记录 -- 不会重复创建。但是，由于重试机制，单次投递可能多次到达您的端点。
</Note>

***

## 最佳实践

<AccordionGroup>
  <Accordion title="始终验证签名">
    始终验证 `X-Waffo-Signature`。不验证签名的话，任何人都可以向您的端点发送伪造请求。
  </Accordion>

  <Accordion title="使用 HTTPS">
    生产环境的 Webhook URL 必须使用 HTTPS 以保护传输中的数据。
  </Accordion>

  <Accordion title="快速响应，异步处理">
    立即返回 `200`，在后台处理业务逻辑。响应过慢会导致不必要的重试。
  </Accordion>

  <Accordion title="使用 eventType + eventId 去重">
    使用 `eventType` + `eventId` 组合进行去重。确保同一投递被多次处理不会产生副作用。
  </Accordion>

  <Accordion title="检查时间戳">
    验证 `t` 时间戳在当前时间的 5 分钟以内，以防止重放攻击。
  </Accordion>

  <Accordion title="使用正确环境的密钥">
    Test 和 Production 使用不同的密钥对。将公钥与载荷中的 `mode` 字段匹配。
  </Accordion>

  <Accordion title="记录接收到的载荷">
    存储接收到的载荷用于调试。Dashboard 也提供投递日志查询功能。
  </Accordion>

  <Accordion title="处理所有已订阅的事件">
    为您订阅的每个事件添加处理分支，即使暂时不需要处理。对未处理的事件返回 `200` -- 返回错误会触发不必要的重试。
  </Accordion>
</AccordionGroup>

***

## 测试

### 发送测试事件（推荐）

使用 Dashboard "Send Test Event" 按钮发送测试事件，无需触发真实交易。测试事件使用固定的示例数据（amount 0、taxAmount 0、product "\[TEST] Webhook Verification"），始终使用 Test 密钥签名。

支持所有 10 种事件类型 -- 逐一测试以验证您的处理程序。

### 使用测试模式

1. 在 Dashboard 中配置 Test 环境的 Webhook URL 和事件
2. 在 Test 模式下执行真实操作（创建订单、处理支付）
3. 事件将发送到您的 Test Webhook URL，使用 Test 签名密钥

### 本地开发

使用隧道工具暴露您的本地服务器：

```bash theme={"system"}
ngrok http 8080
# Use the generated URL as your Test Webhook URL
# e.g., https://abc123.ngrok.io/webhooks
```

***

## 投递日志

在 Dashboard 中查看 Webhook 投递历史：

* **状态**：pending / success / failed
* **HTTP 状态码**：您的服务器的响应码
* **响应体**：您的服务器的响应（截断至 1000 字符）
* **时间戳**：最后一次投递尝试

***

## 常见问题

### 没有收到 Webhook

1. 确认 Dashboard 中已配置 Webhook URL 且可公开访问
2. 确认已订阅正确的事件类型
3. 确认使用了正确的环境（Test / Production）
4. 检查防火墙是否允许来自 Waffo 的请求
5. 尝试 Dashboard "Send Test Event" 来定位问题

### 签名验证失败

1. 确认使用了正确环境的公钥（Test 与 Production）
2. 确认使用的是**原始请求体** -- 而非解析后的 JSON 对象
3. 检查是否有中间件或代理修改了请求体
4. 确认签名输入格式为 `${t}.${rawBody}`（时间戳 + 点 + 原始请求体）
5. 如果使用 TypeScript，建议切换到 [@waffo/pancake-ts SDK](/zh/integrate/webhooks) -- 它自动处理密钥选择和格式规范化

### 收到重复事件

这是正常的重试行为。如果您的端点返回非 2xx 或超时，系统会重试。确保您的处理逻辑是幂等的 -- 使用 `eventType` + `eventId` 组合进行去重。

### `subscription.canceling` 和 `subscription.canceled` 的区别

* **`canceling`**：已请求取消，但当前付费期尚未结束。订阅仍然有效，customer 可以撤回取消（触发 `uncanceled`）。**不要撤回访问权限。**
* **`canceled`**：订阅已终止。这是不可逆的 -- 撤回访问权限或降级权限。

### 不同事件中 `data.amount` 的含义

所有事件：`data.amount` 是**该特定事件的交易金额**（含税）：

* `order.completed` / `subscription.activated` -- 支付金额
* `subscription.payment_succeeded` -- 本期续费金额
* `subscription.past_due` -- 本期应付金额
* `refund.succeeded` / `refund.failed` -- 退款金额
* `subscription.canceling` / `subscription.canceled` / `subscription.uncanceled` -- 订阅每期金额
