Nomba Webhooks
Webhook 是 Nomba 通知你的服务器资金实际发生转移的方式。互联网上的任何人都可以向你的 Webhook URL 发送 POST 请求,因此处理器必须先证明每个请求确实来自 Nomba,然后才能将订单标记为已支付或进行退款。同时,它还必须处理 Nomba 可能重试同一事件的情况。
使用官方 SDK 的验证器;不要手动实现 HMAC。Nomba 的签名覆盖了以 : 连接特定字段,而且 SDK 还能防止简单实现可能遗漏的重放攻击。
| 语言 | 验证方法 |
|---|---|
Node.js / TS (@nombaoss/nomba-node) | verifyWebhook(rawBody, headers, signingKey) |
Python (nomba) | from nomba.webhooks import verify_signature → verify_signature(raw_body, headers, signing_key) |
PHP (nombaoss/nomba-php) | Nomba\Webhook::verify($rawBody, $headers, $signingKey) |
每种语言的确切导入和参数类型请参考 references/sdk-setup.md。每个方法都会返回解析后的事件对象,或者在验证失败时抛出异常(WebhookVerificationError / WebhookVerificationException)。它们都不需要 API 凭证,只需要从 Nomba 后台(开发者 → Webhook 设置)获取的 webhook 签名密钥。请通过环境变量(例如 NOMBA_WEBHOOK_SIGNING_KEY)读取它。这是一个与 API 客户端密钥不同的独立密钥。
处理器分步指南
- 获取原始请求体。 验证 Nomba 发送的精确字节。如果先运行了 JSON 主体解析器,然后你又对对象进行了重新序列化,签名可能不再匹配,或者更糟,你验证的东西和实际处理的东西不一致。每个框架获取原始请求体的方式都不同;请参考 references/frameworks.md 获取正确的方法,并在框架启用 CSRF 保护时将该路由排除在外。
- 验证,然后解析。 使用验证器返回的事件对象。验证失败时,返回
401(或400)状态码并停止处理。不要在 info 级别记录被拒绝请求的完整主体或头部信息。 - 对
requestId进行去重。 Nomba 会重试投递,因此同一事件可能多次到达。使用唯一约束记录每个已处理的requestId,跳过已经见过的。即使对重复事件也要返回200,否则 Nomba 会持续重试。 - 快速确认。 迅速返回
200,并将耗时的工作(如发送邮件、订单履行、账本更新)放在以requestId为键的后台作业中执行。慢速处理器会导致超时和重新投递。 - 根据下表处理事件。 通过引用标识符将事件与你自己的记录匹配,并在履行订单前检查金额和货币是否符合预期。
每个事件的含义
event_type | 匹配依据 | 操作 |
|---|---|---|
payment_success | 结账: data.order.orderReference;虚拟账户/转入: data.transaction.aliasAccountReference 或 transactionId | 在检查 data.transaction.transactionAmount(和货币)与订单匹配后,标记为已支付。对于令牌化结账,将 data.tokenizedCardData.tokenKey(如果存在且不为 "N/A")与客户关联,并仅存储在服务端。 |
payment_failed | 同上 | 标记尝试失败;允许客户重试。未产生任何扣款。 |
payment_reversal | 同上 | 你收到的款项已被撤回。撤销订单履行或标记为审核。 |
payout_success | data.transaction.merchantTxRef | 标记转账或账单支付已完成。 |
payout_failed | data.transaction.merchantTxRef | 不要立即退款。 Nomba 可能通过其他路由重试。将其标记为失败并等待。 |
payout_refund | data.transaction.merchantTxRef | 资金已返回你的 Nomba 钱包。现在可以安全地向客户退款或贷记,并标记该打款为失败。 |
在 payout_failed(或超时)时而非 payout_refund 时退款,是商户支付双倍费用的经典错误。对于任何无法识别的事件,记录日志并返回 200。
通过 API 确认资金决策。 Nomba 的签名只覆盖了 event_type、requestId、data.merchant.userId、data.merchant.walletId、data.transaction.transactionId、type、time、responseCode 和时间戳。金额、orderReference、merchantTxRef 和卡片 tokenKey 并未签名。因此,在履行、退款或存储卡片令牌之前,请使用 SDK 重新查询并根据 API 的响应进行操作:对于结账支付使用 transactions.get({ orderReference })(生产环境也使用 checkout.getTransaction),对于打款使用 transactions.get({ merchantTxRef })。通过对签名后的 requestId 进行去重,可以阻止已处理事件的篡改副本,而重新查询则覆盖其余风险。
重放保护
对 requestId 进行去重是主要的防御手段:如果重放或重试投递了你已处理过的事件,不会产生任何影响。
验证器还可以拒绝旧的时间戳(toleranceSeconds / tolerance_seconds / PHP 中的第四个参数),但默认情况下关闭此功能。Nomba 文档将 nomba-timestamp 描述为负载发送的时间,但没有说明自动重试是否会携带一个新的时间戳。如果它们保留了原始时间戳,那么短于 Nomba 重试窗口的容差将会拒绝中断后的合法重试,导致已支付的订单永远无法被确认。仅当在沙箱环境中确认了重新投递会携带新时间戳后,才启用此功能,并且要保持一个宽松的容差(例如一小时),同时确保服务器时钟同步。
示例(Node.js + Express)
import express from "express";
import { verifyWebhook, WebhookEventType } from "@nombaoss/nomba-node";
const app = express();
// 仅为此路由获取原始请求体;在应用级 express.json() 之前注册它。
app.post("/webhooks/nomba", express.raw({ type: "*/*" }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.NOMBA_WEBHOOK_SIGNING_KEY!);
} catch {
return res.sendStatus(401);
}
// 对 request_id 添加唯一约束;如果已见过则返回 false。
const isNew = await webhookEvents.recordOnce(event.requestId, event.event_type);
if (isNew) await jobs.enqueue("nomba-webhook", event); // 在后台处理
res.sendStatus(200);
});
Python(Flask/FastAPI/Django)、PHP(Laravel/纯 PHP)和其他 Node.js 框架示例见 references/frameworks.md。
测试
- 使用 SDK 文档中的测试向量对处理器进行单元测试:请求体来自 SDK 的
webhook-fixture.json,密钥为SampleSignatureKey123,头部为nomba-timestamp: 2025-09-29T10:51:44Z,nomba-signature: q30yFZDyykoymBf9+mt1wz5UAuLk57raPGPHlGzIv00=。断言错误的密钥、被篡改的请求体和缺失的头部会被拒绝,并且重复的requestId只会被处理一次。 - 端到端测试:在后台中设置一个测试用的 webhook URL 和签名密钥,使用隧道工具暴露你的本地服务器,并创建沙箱支付。后台可以重新推送或调试投递记录 (https://developer.nomba.com/docs/api-basics/troubleshoot-webhooks)。
检查清单
- 使用 SDK 验证原始请求体,在任何解析或业务逻辑之前。
- 签名密钥来自环境变量,而非硬编码。
- 签名无效时返回 401/400;不执行任何其他操作。
- 通过唯一约束对
requestId进行去重;重复事件仍然返回 200。 - 耗时工作在后台执行;处理器快速返回 200。
- 履行订单前,根据订单核对金额和货币。
- 仅在
payout_refund(或重新查询返回REFUND)时进行退款。 - 该路由已排除 CSRF 保护,并使用 HTTPS。