PAYATHON 2026

如何验证 Zoho Subscriptions Webhooks 的真实性?

支付老李

结论

不能只根据 webhook 请求体里的 event_id、订阅编号或客户信息判断请求是否来自 Zoho Subscriptions,因为这些内容都可以伪造。

建议采用以下处理方式:

  1. 如果当前版本的 Zoho Subscriptions 支持 webhook 签名,应按官方要求验证签名。
  2. 如果不支持签名,则为 webhook 配置共享密钥,例如通过自定义请求头传递。如果后台无法设置自定义请求头,可以使用带有随机密钥的专用 URL。
  3. 收到通知后,通过 Zoho Subscriptions API 查询对应的 subscription、invoice 或 payment,确认对象存在,且状态符合预期。
  4. 使用 event_id 或业务对象 ID 进行幂等处理,避免重复创建 membership。

API 回查可以确认业务状态,但不能单独证明 webhook 的发送者身份,因为攻击者也可能提交真实存在的订阅 ID。

推荐的验证流程

服务端收到 webhook 后,按以下顺序处理:

  1. 只接受 HTTPS 请求。
  2. 验证 Zoho 提供的签名,或检查预先约定的共享密钥。
  3. 解析请求体并检查必要字段。
  4. 根据对象 ID 调用 Zoho Subscriptions API 回查。
  5. 核对订阅状态、客户、套餐、金额、币种和自定义字段。
  6. 使用 event_id 去重。
  7. 在数据库事务中创建或更新 membership。
  8. 数据成功写入后返回 2xx。

完成身份验证前,不要创建会员、开通权限或记录支付成功。

方案一:验证官方 webhook 签名

如果 Zoho Subscriptions 后台允许配置 signing secret,且官方文档说明了签名请求头和计算算法,应优先使用官方签名机制。

签名验证通常需要使用未经解析的原始请求体,不能将解析后的 req.body 交给 JSON.stringify 重新生成内容。JSON 解析前后的空格、换行和字段顺序可能不同,进而导致签名不一致。

下面是通用的 Node.js 验证结构。SIGNATURE_HEADER、摘要编码方式和待签名内容都是占位内容,必须替换成 Zoho 当前官方文档规定的值:

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/webhooks/zoho-subscriptions",
  express.raw({ type: "*/*" }),
  async (req, res) => {
    const receivedSignature = req.get("SIGNATURE_HEADER");

    if (!receivedSignature) {
      return res.sendStatus(401);
    }

    const expectedSignature = crypto
      .createHmac("sha256", process.env.ZOHO_WEBHOOK_SIGNING_SECRET)
      .update(req.body)
      .digest("hex");

    const received = Buffer.from(receivedSignature, "utf8");
    const expected = Buffer.from(expectedSignature, "utf8");

    if (
      received.length !== expected.length ||
      !crypto.timingSafeEqual(received, expected)
    ) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString("utf8"));

    // 接下来执行事件去重和 Zoho API 回查
    return res.sendStatus(200);
  }
);

不要自行假设 Zoho 使用 HMAC-SHA256、十六进制编码或某个固定的请求头。签名算法、请求头名称、是否包含时间戳,以及签名原文如何拼接,都应以当前 Zoho Subscriptions 文档和管理后台为准。

方案二:使用共享密钥

如果 webhook 配置支持自定义请求头,可以让 Zoho 在每个请求中发送一个随机生成的密钥,例如:

Authorization: Bearer <random-secret>

服务端使用常量时间比较来验证该值:

import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.json());

function safeEqual(a, b) {
  const left = Buffer.from(a ?? "", "utf8");
  const right = Buffer.from(b ?? "", "utf8");

  return (
    left.length === right.length &&
    crypto.timingSafeEqual(left, right)
  );
}

app.post("/webhooks/zoho-subscriptions", async (req, res) => {
  const expected = `Bearer ${process.env.ZOHO_WEBHOOK_SECRET}`;
  const received = req.get("authorization");

  if (!safeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const event = req.body;

  // 验证字段、事件去重并调用 Zoho API 回查
  return res.sendStatus(200);
});

密钥应通过密码学安全的随机数生成器产生,例如:

openssl rand -hex 32

如果 Zoho 后台无法设置自定义请求头,可以改用难以猜测的专用路径:

https://example.com/webhooks/zoho-subscriptions/<random-secret>

这种做法不如正式的签名机制完整。使用时应确保 Web 服务器、反向代理、监控平台和分析系统不会记录或泄露完整 URL,并定期轮换密钥。

通过 Zoho API 回查业务对象

验证请求来源后,还要从 payload 中取出 subscription、invoice 或 payment 的标识符,再使用服务端保存的 OAuth access token 查询 Zoho Subscriptions API。

下面的代码只展示处理结构。具体 API 路径、数据层级和状态名称,应以当前 Zoho Subscriptions API 文档和实际 webhook payload 为准:

async function fetchSubscription(subscriptionId) {
  const url =
    `${process.env.ZOHO_SUBSCRIPTIONS_API_BASE}` +
    `/subscriptions/${encodeURIComponent(subscriptionId)}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Zoho-oauthtoken ${process.env.ZOHO_ACCESS_TOKEN}`,
      "X-com-zoho-subscriptions-organizationid":
        process.env.ZOHO_ORGANIZATION_ID
    }
  });

  if (!response.ok) {
    throw new Error(`Zoho API returned HTTP ${response.status}`);
  }

  return response.json();
}

async function validateMembershipEvent(event) {
  const subscriptionId = extractSubscriptionId(event);

  if (!subscriptionId) {
    throw new Error("Missing subscription ID");
  }

  const result = await fetchSubscription(subscriptionId);
  const subscription = result.subscription;

  if (!subscription) {
    throw new Error("Subscription not found");
  }

  if (subscription.customer_id !== event.data.customer_id) {
    throw new Error("Customer does not match");
  }

  if (subscription.plan?.plan_code !== "membership-pro") {
    throw new Error("Unexpected plan");
  }

  return subscription;
}

不要完全信任 webhook 中提交的金额、套餐名称、会员等级或有效期。创建 membership 时,应尽量采用 API 回查结果或服务端保存的套餐映射。

防止重复创建 membership

Webhook 通常采用”至少投递一次”的策略。网络超时、服务端响应太慢或 Zoho 重试,都可能导致同一个事件被多次发送。

可以为 event_id 建立唯一约束:

CREATE TABLE processed_webhook_events (
    event_id VARCHAR(255) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

处理事件时先写入事件 ID。如果触发唯一约束冲突,说明该事件已经处理过,此时直接返回成功即可。如果 payload 没有稳定的事件 ID,可以将业务对象 ID 和事件类型组合成幂等键,但要确保这种设计不会错误合并同一种合法的状态变更。

还需要注意什么

  • 不要用浏览器端代码接收 webhook,也不要把 OAuth token 或 webhook secret 放在前端。
  • 不要只检查 User-Agent、Referer 或请求体中的组织名称,这些字段都可以伪造。
  • 只有 Zoho 官方公布并持续维护 webhook 出站 IP 范围时,才适合配置 IP allowlist。IP 检查只能作为附加防线。
  • OAuth access token、webhook signing secret 和共享密钥应分开保存,不能混用。
  • 日志中不要记录完整的 Authorization、签名密钥、OAuth token 或带有密钥的 URL。
  • 限制请求体大小,并校验字段类型、长度和允许的事件类型。
  • membership 只能根据明确的支付或订阅状态开通,不能收到任意 webhook 就直接开通。
  • 密钥泄露后应立即轮换,并短暂保留新旧密钥并行验证的窗口,避免在切换期间丢失通知。
  • 如果签名协议包含时间戳,应检查允许的时间偏差,防止攻击者重放截获的合法请求。

最终的安全边界应由”签名或共享密钥验证 + Zoho API 回查 + 幂等处理”共同构成,不能依赖 webhook 请求体本身。

备注:内容仅供参考。