跳转到主要内容

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.

概述

Webhook 在事件发生时向您的服务器发送实时通知 — 订单、支付、订阅和退款。
Event occurs → Waffo Pancake → fan-out → Your channel(s)
单个门店可以配置多个 webhook,每个投递到不同的渠道。可用渠道如下:
渠道投递目标载荷格式
http您的 HTTPS 端点RSA-SHA256 签名的 JSON 信封
feishu飞书 / Lark 机器人入站 webhook飞书交互卡片
discordDiscord 频道 webhookEmbed 消息(Discord 格式)
telegramTelegram 机器人 sendMessage URLHTML 格式文本
slackSlack 入站 webhook含 mrkdwn 字段的 Attachment
http 渠道使用本页所述的 JSON 信封和签名验证 — 本指南大部分内容覆盖该渠道。对于 IM 类渠道,载荷使用各平台原生格式,认证由 URL 中的 token 完成;无需验证签名。
使用 TypeScript? @waffo/pancake-ts SDK 内置了公钥并自动检测环境 — 一行代码即可完成 http 渠道的验签。

配置步骤

1

选择渠道并准备 URL

  • HTTP — 构建一个接受 POST 请求并返回 200 的服务端端点。
  • 飞书 / Discord / Telegram / Slack — 在目标平台创建机器人或入站 webhook,复制其 URL。Telegram 还需记录接收消息的 chat ID。
2

(仅 HTTP)获取验签公钥

前往 控制台 → 设置 → Webhooks,复制目标环境(Test / Production)的 Webhook Public Key
3

注册 Webhook

控制台 → 设置 → Webhooks 中添加 webhook,或调用 POST /v1/actions/store/add-webhook。每条 webhook 记录指定一个渠道、一个 URL、订阅的事件以及目标环境(Test 设 testMode: true,Production 设 false)。每个门店可注册多条 webhook。
4

发送测试事件

使用 Dashboard 的 “Send Test Event” 按钮,将示例事件投递到您注册的某一条或全部 webhook。
5

验签并处理事件

对于 HTTP 渠道,使用下方代码示例在处理事件前验证签名。IM 渠道投递的是预渲染消息,无需在您侧处理。

环境隔离

每条 webhook 通过 testMode 标志注册到单一环境。Test 和 Production 完全独立:
项目TestProduction
Webhook 记录testMode: truetestMode: false
签名密钥(仅 HTTP)Test 密钥对Production 密钥对
验签公钥(仅 HTTP)Dashboard Test 公钥Dashboard Production 公钥
投递时的选择器请求头 X-Environment: test请求头 X-Environment: prod(或省略)
每个 HTTP 载荷中的 mode 字段表示来源环境:"test""prod"
始终使用与事件 mode 匹配的公钥。Test 密钥无法验证 Production 事件,反之亦然。

载荷格式

请求头

请求头说明
Content-Typeapplication/json
X-Waffo-Signature签名字符串:t=<timestamp>,v1=<signature>
X-Waffo-Event事件类型(例如 order.completed

请求体

{
  "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": "buyer@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" },
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}

顶层字段

字段类型说明
idstring事件实体 ID — 对大多数事件与 eventId 相同
timestampstring事件时间(ISO 8601 UTC)
eventTypestring事件类型(参见 事件类型
eventIdstring业务事件标识符 — 按事件类型映射到不同实体(参见 eventId 映射
storeIdstringStore ID
storeNamestring商店名称
modestring"test""prod"

data 字段

data 对象包含交易详情。部分字段始终存在,其他字段仅在特定事件类型或数据可用时出现。 始终存在:
字段类型说明
orderIdstring订单 ID
orderStatusstring订单状态(例如 "completed""active""canceling"
buyerEmailstring买家邮箱地址
currencystringISO 4217 货币代码(例如 "USD""JPY"
amountstring交易总额含税(显示格式,例如 "29.00"
taxAmountstring税额(显示格式,例如 "2.90"
productNamestring商品名称
orderMetadataobject结账会话中的订单级元数据(商户定义的键值对)
productMetadataobject创建/更新商品时设置的商品级元数据
有值时包含:
字段类型出现条件说明
merchantProvidedBuyerIdentitystring在结账时设置商户自定义的买家标识
billingDetailobject在结账时设置账单地址(countryisBusiness 等)
taxRatenumber应用了税率税率小数(例如 0.1 表示 10%)
taxNamestring应用了税率税种名称(例如 "Consumption Tax"
subtotalstring可用时税前小计(显示格式)
totalstring可用时税后合计(显示格式)
productDescriptionstring商品已设置商品描述
支付事件order.completedsubscription.payment_succeeded):
字段类型说明
paymentIdstring支付 ID
paymentStatusstring支付状态("succeeded""failed"
paymentMethodstring支付方式(例如 "card"
paymentLast4string支付工具末 4 位
paymentDatestring支付日期(ISO 8601 日期,例如 "2026-03-10"
paymentFailureReasonstring失败原因(支付失败时)
订阅事件subscription.*):
字段类型说明
billingPeriodstring"weekly""monthly""quarterly""yearly"
currentPeriodStartstring当前计费周期开始日期(ISO 8601 日期)
currentPeriodEndstring当前计费周期结束日期(ISO 8601 日期)
canceledAtstring取消时间戳(ISO 8601,取消中/已取消时存在)
退款事件refund.succeededrefund.failed):
字段类型说明
refundStatusstring"succeeded""failed"
refundReasonstring退款原因
refundCreatedAtstring退款创建时间戳(ISO 8601)
paymentIdstring原始支付 ID
paymentStatusstring原始支付状态
paymentMethodstring原始支付方式
paymentLast4string原始支付末 4 位
paymentDatestring原始支付日期
金额为显示格式字符串,已从最小单位转换。例如,USD "29.00" = 2900 美分;JPY "4500" = 4500 日元。有值时可使用 subtotaltotal 进行明细展示。

eventId 映射

eventId 标识触发事件的业务实体:
事件类型eventId 映射到示例
order.completedPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.activatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.payment_succeededPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.cancelingOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.uncanceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.updatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.canceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.past_dueOrder ID + 月份ORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04
refund.succeededRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
refund.failedRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
subscription.past_due 会在 eventId 后追加 -YYYY-MM。同一订阅每个自然月最多触发一次 past_due 事件。若下月仍逾期,将触发新事件。

事件类型

概览

事件触发条件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
subscription.uncanceledsubscription.updated 事件模板已就绪,将在相应功能上线后激活。

事件详情

触发条件:一次性订单首次支付成功。载荷
  • data.amount — 支付金额(含税)
  • data.orderId — 一次性订单 ID
建议操作
  • 交付数字商品(许可证密钥、下载链接、激活码)
  • 更新您的订单管理系统
  • 向买家发送确认(如果未使用 Waffo 内置邮件)
同一订单只会触发一次 order.completed。退款通过 refund.succeeded / refund.failed 通知。
触发条件:新订阅的首次支付成功(pendingactive)。载荷
  • data.amount — 首次支付金额(含税)
  • data.productName — 订阅产品名称
建议操作
  • 为订阅者开通账户和授予访问权限
  • 记录订阅开始日期
仅在订阅首次从 pending 转为 active 时触发。后续续期使用 subscription.payment_succeeded
触发条件:周期性续期支付成功(非首次支付)。载荷
  • data.amount — 本期续费金额(含税)
  • data.orderId — 订阅订单 ID
建议操作
  • 延长服务期限
  • 为本计费周期生成发票
  • 如果订阅之前处于 past_due 状态,恢复完整访问权限
触发条件:买家或商户请求取消。订阅在当前付费期结束前仍保持有效。建议操作
  • 显示”订阅将于 [日期] 到期”提示
  • 提供挽留流程(例如折扣续费)
  • 不要撤回访问权限 — 买家已为当前周期付费
买家可以在周期结束前撤回取消(触发 subscription.uncanceled)。
触发条件:在当前周期结束前撤回取消。建议操作
  • 移除”即将到期”提示
  • 恢复自动续费状态
触发条件:订阅产品变更(升级或降级)。载荷
  • data.productName — 变更后的新产品名称
  • data.amount — 新金额
建议操作
  • 更新买家的访问级别(添加/移除功能)
  • 更新计费记录
触发条件:订阅已终止 — 付费期结束,不会再有续费。建议操作
  • 撤回访问权限(或降级到免费版)
  • 保留数据一段宽限期(以防买家重新订阅)
  • 发送”订阅已结束”确认
这是终态。订阅已不可逆地结束。
触发条件:续期支付失败,订阅进入逾期状态。载荷
  • data.amount — 本期应付金额
  • eventId — 格式:{orderId}-YYYY-MM(按月去重)
建议操作
  • 通知买家更新支付方式
  • 可选择降级服务(限制功能而非完全撤回)
  • 不要立即撤回访问权限 — PSP 可能会自动重试扣款
去重:每个订阅每自然月最多一次 past_due 事件。若下月仍逾期,将触发新事件。
触发条件:退款已完成,资金已退回。载荷
  • data.amount — 退款金额(含税)
  • data.orderId — 原始订单 ID
建议操作
  • 撤销已交付的数字商品(吊销许可证、禁用下载)
  • 将订单状态更新为”已退款”
触发条件:退款处理失败。建议操作
  • 记录失败信息以供人工审核
  • 不要撤销商品(退款未完成)

订阅生命周期

终态含义触发 Webhook?
canceled订阅终止(买家/商户取消或逾期)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 内置公钥,自动检测环境,并处理格式标准化:
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 文档

手动验证

如果您不使用 TypeScript SDK,请手动实现签名验证。
const crypto = require('crypto');

// From Dashboard → Developers → 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;
  }
}
您必须使用原始请求体进行签名验证。 如果您的框架自动解析了 JSON,签名检查将失败。确保在验证前获取未修改的原始字符串。

响应要求

  • 返回 2xx 状态码(推荐 200
  • 10 秒内响应
  • 响应体内容无要求
// 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 组合(系统中有唯一约束)进行去重:
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]
  );
}
同一业务事件(相同的 eventType + eventId)只会创建一条投递记录 — 不会重复创建。但是,由于重试机制,单次投递可能多次到达您的端点。

最佳实践

始终验证 X-Waffo-Signature。不验证签名的话,任何人都可以向您的端点发送伪造请求。
生产环境的 Webhook URL 必须使用 HTTPS 以保护传输中的数据。
立即返回 200,在后台处理业务逻辑。响应过慢会导致不必要的重试。
使用 eventType + eventId 组合进行去重。确保同一投递被多次处理不会产生副作用。
验证 t 时间戳在当前时间的 5 分钟以内,以防止重放攻击。
Test 和 Production 使用不同的密钥对。将公钥与载荷中的 mode 字段匹配。
存储接收到的载荷用于调试。Dashboard 也提供投递日志查询功能。
为您订阅的每个事件添加处理分支,即使暂时不需要处理。对未处理的事件返回 200 — 返回错误会触发不必要的重试。

测试

发送测试事件(推荐)

使用 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 签名密钥

本地开发

使用隧道工具暴露您的本地服务器:
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 — 它自动处理密钥选择和格式规范化

收到重复事件

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

subscription.cancelingsubscription.canceled 的区别

  • canceling:已请求取消,但当前付费期尚未结束。订阅仍然有效,买家可以撤回取消(触发 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 — 订阅每期金额