如何通过 NMI Webhook 获取 recurring auto payments
结论
NMI 自动续费扣款时,不会再次触发 recurring.subscription.add 或 recurring.subscription.update。这类订阅事件表示订阅配置发生了变化。每次实际扣款通常会生成一笔新交易,扣款结果应通过已启用的 transaction.sale.success、transaction.sale.failure、transaction.sale.unknown 等交易事件接收。
目前需要重点修正四个问题:
- 在 NMI 后台为该 Webhook URL 启用相应的 transaction 事件。只启用 recurring subscription 事件,无法收到每次扣款的结果。
- 交易事件与订阅事件的
event_body结构不同。不要假定交易事件一定包含event_body.plan.amount、billing_address.email或subscription_id,否则处理时可能抛出异常并返回500。 - Webhook 签名必须使用请求的原始字节验证,不能依赖
JSON.stringify(req.body)重新生成正文。 - 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 字段映射。
备注:内容仅供参考。
