PAYATHON 2026

如何通过 NMI Webhook 获取 recurring auto payments

支付老李

结论

NMI 自动续费扣款时,不会再次触发 recurring.subscription.add 或 recurring.subscription.update。这类订阅事件表示订阅配置发生了变化。每次实际扣款通常会生成一笔新交易,扣款结果应通过已启用的 transaction.sale.success、transaction.sale.failure、transaction.sale.unknown 等交易事件接收。

目前需要重点修正四个问题:

  1. 在 NMI 后台为该 Webhook URL 启用相应的 transaction 事件。只启用 recurring subscription 事件,无法收到每次扣款的结果。
  2. 交易事件与订阅事件的 event_body 结构不同。不要假定交易事件一定包含 event_body.plan.amount、billing_address.email 或 subscription_id,否则处理时可能抛出异常并返回 500。
  3. Webhook 签名必须使用请求的原始字节验证,不能依赖 JSON.stringify(req.body) 重新生成正文。
  4. Payment API 的 security_key 与 Webhook signing key 用途不同,不能混用。

为什么只能收到订阅的添加和删除事件

以下事件描述订阅或计划本身的生命周期:

recurring.subscription.add
recurring.subscription.update
recurring.subscription.delete
recurring.plan.add
recurring.plan.update
recurring.plan.delete

执行每日自动扣款时,订阅配置通常没有变化,所以不会再次发送 recurring.subscription.update。扣款会产生交易,应关注以下事件:

transaction.sale.success
transaction.sale.failure
transaction.sale.unknown

可选的事件名称和字段可能因 NMI 账户配置及当前 Webhook 产品版本而异,请以 NMI 后台显示的可订阅事件列表和实际发送的测试载荷为准。

auth、capture、refund、void、credit 等事件分别表示授权、捕获、退款、撤销和贷记。普通 recurring sale 不一定会产生这些事件。要判断自动续费成功还是被拒绝,应先处理 transaction.sale.*。

NMI 后台需要检查的配置

在 NMI Merchant Portal 的 Webhook 配置中检查以下项目:

  • Webhook URL 是否为公网可访问的 HTTPS 地址。
  • Webhook 是否处于启用状态。
  • 是否勾选了 transaction.sale.success。
  • 是否勾选了 transaction.sale.failure。
  • 是否需要启用 transaction.sale.unknown。
  • Webhook 配置所属账户与创建订阅所用的 security_key 是否属于同一个商户账户。
  • 后台是否有投递记录、HTTP 状态码或失败重试记录。
  • Transaction Report 中是否确实生成了 recurring sale。

订阅创建成功,不代表系统会在当天立即扣款。实际执行时间还受 start_date、商户时区、批处理时间影响,也要看创建订阅时是否已经错过当天的执行窗口。

如果 Transaction Report 中没有自动生成交易,应先检查 recurring 配置和账户能力,不必继续修改 Webhook。测试账户或测试处理器是否会按照真实日程运行 recurring 任务,也需要向 NMI 或账户服务商确认。

正确处理原始请求体

签名一般针对 NMI 实际发送的原始 HTTP body。下面的写法并不可靠:

const webhookBody = JSON.stringify(req.body);

JSON 经过解析后,字段顺序、空白或转义形式都可能发生变化,导致签名验证失败。可以通过 Express 保留原始正文:

app.use(
  express.json({
    verify: (req, res, buffer) => {
      req.rawBody = buffer;
    },
  })
);

然后在路由中使用 req.rawBody:

router.post("/nmi", async (req, res) => {
  try {
    const signingKey = process.env.NMI_WEBHOOK_SIGNING_KEY;
    const rawBody = req.rawBody;
    const sigHeader = req.get("webhook-signature");

    if (!rawBody || !sigHeader) {
      return res.status(400).send("Missing webhook body or signature");
    }

    const match = sigHeader.match(/^t=([^,]+),s=(.+)$/);

    if (!match) {
      return res.status(400).send("Invalid webhook signature format");
    }

    const [, nonce, signature] = match;

    // webhookIsVerified 必须按 NMI 当前文档规定的算法验证原始 body。
    if (!webhookIsVerified(rawBody, signingKey, nonce, signature)) {
      return res.status(401).send("Invalid webhook signature");
    }

    await processNmiWebhook(req.body);

    return res.sendStatus(200);
  } catch (error) {
    console.error("NMI webhook processing failed:", error);
    return res.sendStatus(500);
  }
});

如果项目已经在路由之前调用了 express.json(),必须通过它的 verify 回调保存原始 body,或者在 JSON 中间件之前为该路由单独使用 express.raw()。不要先解析 JSON,再重新序列化并验签。

不要用订阅载荷结构处理交易事件

当前代码对交易事件执行了类似操作:

webhook.event_body.plan.amount
webhook.event_body.billing_address.email
webhook.event_body.subscription_id

这些字段来自订阅事件。交易事件未必有相同的字段路径,只要其中一个对象不存在,代码就会报错,并向 NMI 返回 500。

更稳妥的做法是先保存事件原文,再根据事件类型提取字段:

async function processNmiWebhook(webhook) {
  const eventType = webhook.event_type;
  const body = webhook.event_body || {};

  const commonRecord = {
    eventType,
    receivedAt: new Date(),
    rawPayload: webhook,
  };

  switch (eventType) {
    case "transaction.sale.success":
    case "transaction.sale.failure":
    case "transaction.sale.unknown": {
      await AutoRecPayments.updateOne(
        {
          eventType,
          nmiTransactionId: body.transaction_id,
        },
        {
          $setOnInsert: {
            ...commonRecord,
            nmiTransactionId: body.transaction_id,
            nmiSubscriptionId: body.subscription_id,
            amount: body.amount,
            responseCode: body.response_code,
            responseText: body.response_text ?? body.responsetext,
            status: eventType.split(".").at(-1),
          },
        },
        { upsert: true }
      );

      break;
    }

    case "recurring.subscription.add":
    case "recurring.subscription.update":
    case "recurring.subscription.delete": {
      await SubscriptionEvents.updateOne(
        {
          eventType,
          nmiSubscriptionId: body.subscription_id,
        },
        {
          $setOnInsert: {
            ...commonRecord,
            nmiSubscriptionId: body.subscription_id,
            email: body.billing_address?.email,
            amount: body.plan?.amount,
          },
        },
        { upsert: true }
      );

      break;
    }

    default:
      console.log("Unhandled NMI event:", eventType);
  }
}

代码中的字段名只是防御性映射示例。最终的字段路径应根据 NMI 实际发送给该账户的交易载荷确定,不能根据订阅事件的结构推测。

如何关联交易与订阅

如果交易事件直接包含 subscription_id,可以用它关联 MongoDB 中的订阅记录。不过,在看到实际载荷之前,不能假定该字段一定存在。

创建订阅后,建议保存 NMI 返回的标识:

const parsedResponse = querystring.parse(response.data);

if (parsedResponse.response_code == 100) {
  await Subscriptions.create({
    nmiSubscriptionId: parsedResponse.subscription_id,
    email,
    amount: plan_amount,
    frequencyDays: dayFrequency || 30,
    rawCreateResponse: parsedResponse,
  });

  return sendResponse(res, "Custom subscription added successfully.");
}

如果交易 Webhook 中没有 subscription_id,可以查看实际载荷是否提供以下关联信息:

  • order_id
  • customer vault ID
  • merchant-defined fields
  • customer ID
  • transaction ID
  • NMI 提供的 recurring 来源字段

如果 NMI 允许在订阅或交易中设置 merchant-defined field,可以写入本地用户 ID 或订阅记录 ID,避免只通过 email 关联。具体支持哪些字段,请以当前 NMI API 文档和账户配置为准。

MongoDB 存储时的注意事项

Webhook 可能因超时或重试而重复发送,所以处理过程必须具备幂等性。应优先使用 NMI 的事件唯一标识。如果载荷中没有事件 ID,可以组合以下字段建立唯一索引:

event_type + transaction_id

不要只按照 subscription_id 去重,因为同一个订阅会产生多笔 recurring transaction。

建议至少保存以下内容:

{
  eventType,
  nmiTransactionId,
  nmiSubscriptionId,
  status,
  amount,
  responseCode,
  responseText,
  receivedAt,
  rawPayload
}

保留 rawPayload 有助于在首次接入时确认真实字段结构,也方便排查 declined、unknown 及后续退款等状态。

还需要修正的安全问题

创建订阅时应使用 Payment API security key:

security_key: process.env.NMI_API_SECURITY_KEY

验证 Webhook 时应使用 Webhook signing key:

const signingKey = process.env.NMI_WEBHOOK_SIGNING_KEY;

不要把这两个值写死在源代码中,也不要直接接受客户端传入的 security_key。如果示例中的 "MY SIGNING KEY" 实际同时用于这两个位置,应立即检查配置,并更换已经暴露的密钥。

完成这些配置后,可以按以下顺序排查:先确认 NMI Transaction Report 中产生了 recurring sale,再确认 Webhook 已订阅 transaction.sale.*,接着查看投递日志和 HTTP 响应,最后根据收到的真实交易载荷调整 MongoDB 字段映射。

备注:内容仅供参考。