Webhooks -- 概述
Webhooks 允许您的应用实时接收 Owem Pay 平台上的事件通知。当事件发生时,Owem Pay 会向您注册的 URL 发送 HTTP POST 请求。
工作原理
- 在您的账户中注册 Webhook URL
- 当事件发生时(如:收到 PIX),Owem Pay 向您的 URL 发送 HTTP POST
- 您的应用处理通知并返回
2xx状态码(200、201 或 204)
可用事件
Owem Pay 仅投递与 PIX 相关的事件。其他产品(boleto、账户、STA、非 PIX 转账)不在范围内。任何尝试订阅下表之外事件的请求都会被拒绝,返回 events: contains invalid events: ...。
| 事件 | 状态 body | 描述 | 触发 |
|---|---|---|---|
pix.charge.created | created | QR 码已生成或 cash-in 已启动 | 激活 |
pix.charge.paid | paid | PIX 已接收并结算 | 激活 |
pix.charge.expired | expired | QR 码在未付款情况下过期(worker 每 5 分钟检查一次) | 激活 |
pix.charge.cancelled | cancelled | QR 码在付款前取消 | 已注册,尚未触发 |
pix.payout.queued | queued | 已发送的 PIX 由于速率限制(每 merchant 的 ClientLimiter 或 DICT BACEN bucket)入队。约每 3 秒自动重试,最大 TTL 2 小时 | 激活 |
pix.payout.processing | processing | PIX 已发送,等待 BACEN 确认 | 激活 |
pix.payout.confirmed | settled | PIX 已发送并确认(终止) | 激活 |
pix.payout.failed | rejected | PIX 已发送,被 SPI 拒绝(终止) | 激活 |
pix.payout.returned | returned | 已发送的 PIX 已退回 | 激活 |
pix.refund.requested | requested | 收到退款请求(BACEN 违规);在客户余额上创建预防性封锁 | 激活 |
pix.refund.completed | settled / completed | 抗辩分析完成,退款已执行(或已释放) | 激活 |
pix.return.received | settled | 收到 PIX 退款(贷记) | 激活 |
pix.infraction.created | ACKNOWLEDGED | 对方通过 BACEN DICT 报告 PIX 违规;需要抗辩或自动生成预防性封锁(>R$1k) | 激活 |
pix.infraction.resolved | CLOSED / CANCELLED | 违规已解决(admin 关闭、auto-deny 或对方取消) | 激活 |
pix.infraction.defense_submitted | defense_submitted | merchant(门户或 API)已提交抗辩;等待 BACEN 分析 | 激活 |
webhook.test | test | 手动测试。仅通过 Admin/Merchant portal 可用 — External API 不暴露触发测试的端点 | 手动触发(非 External API) |
pix.charge.cancelled 尚未触发
事件在 enum 中且可订阅,但系统今天没有 QR 码取消流程。如果您订阅,POST /webhooks 正常响应 201 — 但不会收到通知。继续监控 pix.charge.expired 以了解 QR 的自然生命周期。
安全性
每个通知包含用于验证的安全和识别头:
| 头 | 描述 |
|---|---|
X-Owem-Signature | 载荷的 HMAC-SHA256 签名(前缀 sha256=)。在罕见情况下(注册时没有 secret 的 webhook),文字值为 unsigned — 参见下方注释 |
X-Owem-Timestamp | 发送的 Unix 时间戳(秒) |
X-Owem-Event-Id | 投递的唯一 UUID(用于去重) |
X-Owem-Event-Type | 事件类型(例:pix.charge.paid) |
Content-Type | 始终为 application/json |
User-Agent | 始终为 Owem-Webhook/1.0 — 用于防火墙/WAF 白名单。未来演变将遵循 Owem-Webhook/{version} 模式;如果您想对新版本免疫,按前缀 Owem-Webhook/ 过滤 |
当 webhook 没有 secret 时签名为 unsigned
如果 webhook 是在没有 secret 字段(遗留场景)的情况下注册的,头 X-Owem-Signature 的字面值为 unsigned。这在您那边禁用 HMAC 验证。实际上,当字段省略时(自 session 80 起),POST /api/external/webhooks 生成 64 字符的随机 secret,因此该场景仅出现在非常旧的注册或通过 admin bypass 中。如果您收到 unsigned,请使用显式 secret 注册新 webhook 并移除旧的。
Webhook 中的 SHA256 vs API 中的 SHA512
API 使用 HMAC-SHA512 验证您发送的请求。Owem Pay 发送的 webhook 在 X-Owem-Signature 签名中使用 HMAC-SHA256。它们是不同的算法 -- 各有其上下文。
验证签名
验证签名以确保通知由 Owem Pay 发送:
const crypto = require('crypto');
function validateWebhook(rawBody, timestamp, signature, secret) {
// rawBody is the RAW request body string (before any JSON parse)
// timestamp is unix seconds (e.g., 1712160000)
const message = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}使用 RAW body,不要重新序列化
您必须使用 HTTP 请求的 body,即字节到达您应用时的那一份。如果您先执行 JSON.parse 然后再执行 JSON.stringify,产生的字节不会与 Owem 用于签名的字节完全一致,验证将失败。
在 Express/Node 中:使用 express.raw({ type: 'application/json' }) 或在任何 parse 中间件之前保存 body。
在其他框架中:配置在 JSON 中间件之前捕获 raw body。
WEBHOOK 中的键排序:NÃO 需要
对于 webhook 验证(HMAC-SHA256),您不需要按字母顺序排列键 — 使用从 Owem HTTP 请求中接收的 raw body。
⚠️ 注意 — 与请求发送的差异:在您发送的请求的 HMAC-SHA512 签名中,按字母顺序的键是必须的(Owem 服务器在验证前重新排序)。不要混淆这两种场景:
- 收到的 Webhook(HMAC-SHA256):验证 raw body,无需重新排序
- 发送的 Request(HMAC-SHA512):签名前按字母顺序排列您的键
始终验证
切勿在未验证签名的情况下处理 webhook。这可以防止伪造请求。
此外,验证 X-Owem-Timestamp 是否在当前时间的 ± 5 分钟之内(防重放保护 — 服务器默认不拒绝"旧"webhook;此检查应由您的端点作为防御深度执行)并按 X-Owem-Event-Id 去重事件(针对重试的保护)。
重试策略
如果您的 URL 返回非 2xx 状态(或 30 秒后超时),Owem Pay 将执行最多 8 次重试,采用指数退避策略。第一次和第八次尝试之间的总时间约为 7h45min:
| 尝试 | 与上次尝试的间隔 | 累计时间 |
|---|---|---|
| 第 1 次 | —(立即,通过 Task.start 主动触发) | ~50–200 ms |
| 第 2 次 | 30 秒 | ~30 s |
| 第 3 次 | 2 分钟 | ~2,5 min |
| 第 4 次 | 10 分钟 | ~12,5 min |
| 第 5 次 | 30 分钟 | ~42,5 min |
| 第 6 次 | 1 小时 | ~1,75 h |
| 第 7 次 | 2 小时 | ~3,75 h |
| 第 8 次 | 4 小时 | ~7,75 h |
8 次尝试均失败后,webhook_delivery 被标记为 failed,不再自动重发。您可以向 Owem 支持请求手动重放,提供 X-Owem-Event-Id(或让有 admin 访问权限的操作员通过门户重放)。
delivery 状态
每个 delivery 经过以下状态:pending(已创建,等待投递)→ delivered(收到 2xx)或 failed(8 次尝试耗尽)或 expired(重放保护)。
关于 expired: 当 Oban worker 即将处理第一次尝试且 delivery 自创建(inserted_at)已超过 5 分钟时,发送被中止,状态直接变为 expired。这防止了晚期重新处理(由于队列积累、pod 重启等)触发旧事件的通知。请求 Owem 支持的手动重放通过内部 flag manual_replay: true 传递,绕过此 guard — 客户端正常接收通知。
持久性
在尝试第一次投递之前,事件被持久化到 PostgreSQL 的 webhook_deliveries 表中。如果触发 webhook 的 pod 在投递期间崩溃,Oban 在下次重试时自动恢复 — 不会丢失任何事件。
幂等性
您的应用应具备幂等性:如果多次收到同一事件(通过 X-Owem-Event-Id 识别),应在不产生重复效果的情况下处理。
通过 admin 手动重放
如果 delivery 失败且您需要重新发送,Owem 团队可以通过 admin dashboard 执行手动重放。请联系支持并提供 delivery 的 event_id。
重复投递(已知的 race condition)
系统使用"eager delivery"机制加速第一次投递 + Oban worker 作为持久重试回退。在高并发场景中,这两个路径可能并行触发同一个 webhook(~1 秒的窗口)。在这种情况下,您通过 HTTP 收到相同的载荷 2 次,但具有相同的 X-Owem-Event-Id — 是同一个事件,不是重试。
为避免重复影响:
- 按
X-Owem-Event-Id去重(推荐 — 每个 delivery 唯一的 UUID,在重试和上述 race condition 中稳定) - 或者在事件合适时按
end_to_end_id+event_type去重
这是预期行为,不是错误。合法的重试(5xx/超时后)也重用相同的 X-Owem-Event-Id。
Webhook 中的 External ID
当交易创建时包含 external_id,该字段会包含在 webhook 载荷的 data 对象中。使用它将事件与您系统中的订单关联,无需额外查询。
端点要求
- URL 必须使用 HTTPS(除非注册时设置
allow_insecure: true) - 必须在 30 秒内返回
2xx状态(在投递 worker 中配置的receive_timeout) - 响应 body 被忽略
- 建议快速响应(立即
200 OK)并在您这边异步处理事件;长延迟会降低吞吐量并增加重试机会
下一步
- 注册 Webhook -- 创建、列出和删除 webhook
- 事件载荷 -- 各事件类型的示例