Webhook 事件
20 条事件。事件体只带 ID 与状态—— 不含金额、不含卡号任何片段、不含风控原因、不含上游名称。要详情请拿 ID 回查。
一条事件长什么样
我方向你配置的端点发 POST,Content-Type: application/json,超时 10 秒。
请求头四个:
Content-Type: application/json
z-signature: t=<unix秒>,v1=<base64>
z-event-id: evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901
z-event-type: deposit.credited
请求体的形状对所有事件都一样,只有 data 里的值不同:
{
"event_id": "evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901",
"event_type": "deposit.credited",
"created_at": "2026-08-12T09:30:00Z",
"merchant_id": "acme",
"livemode": true,
"data": {
"object": "deposit",
"id": "184203",
"external_member_id": "u_88123",
"status": "credited",
"status_version": 0
}
}
红线:事件体只带 ID 与状态。
没有金额、没有资产代码、没有卡号的任何片段、没有风控原因、没有上游名称。
这不是省字节 —— webhook 端点是你的服务,我方没有办法保证它的传输与存储;
而 GET /v1/<资源>/{id} 那条路径上有 API Key、scope、代理会员三层校验。
要详情就拿 data.id 回查,别指望从事件体里读出金额来记账。
验签
v1 = HMAC-SHA256(你的 webhook_secret, "<t>." + 原始请求体字节),base64 输出。
签的是原始字节,不是你反序列化再序列化一遍的 JSON —— 后者会因为键序或
空白差异算出另一个值,而那种失败看起来像「我方签错了」。
校验 t 落在 ±300 秒内,否则一份被截获的旧请求可以被无限重放。
幂等与向前合并
我方保证至少一次,不保证恰好一次。同一次状态变更重复到达是常态
(资金线的 webhook 与巡检共用同一段落地代码,这是刻意的)。
1. 按 event_id 幂等。重投(你在商户后台点的,或我方退避重试的)**沿用同一个
event_id** —— 换新 id 会让「重投」在你这边表现成「又发生了一次」,
对资金事件而言那正是一次错误的重复入账。
2. 按 status_version 向前合并,收到倒序的事件直接丢弃。少了它,一次慢响应
会把「已到账」打回「处理中」,而两边都不会报错。
status_version 不是所有事件都有(下面每条事件里注明了)。恒为 0 的那几条
没有版本判据,只能按 created_at 比 —— 别写一个「version 相等就跳过」的合并器,
那会让这几条事件只有第一条生效。
今天恒为 0 的是这几条,每一条都写明了原因(不是待办,是这门业务本来就
没有第二条会乱序的事件):
| 事件 | 为什么没有版本 |
|---|---|
member.created / member.suspended | 会员行上没有状态机版本;停用/恢复是同一位的两个取值 |
kyc.result.updated | 对象是人不是单,L1/L2 分表、补件连单号都没有 |
deposit.credited | 入金对外只有「已入账」一态,一个对象一生只发一条事件 |
exchange.order.executed | 兑换原子、同步、不可逆 —— 成交即终局 |
transfer.completed | 站内转账一步到账,没有待审态 |
earn.order.settled(仅活期派息那一半) | 活期是余额包不是一笔一单;定期那一半带版本 |
其余事件都带真实版本号。⚠ **card.application.approved 与 card.status.updated
在 2026-08-13 之前也是 0**,且 data.id 发的是会员内部 id —— 那是我方的缺陷,
已修。若你的 handler 里写着「卡这两条的 id 是会员 id」,请改回按 data.object
的常规约定读(申请单号 / 卡 id)。
没有 previous_status(2026-08-13 起从事件体里删掉)。它此前恒为空串:
一个生产者都没有。删而不是补上,因为
① 恒空的字段比没有更坏 —— "" 读起来像「上一个状态是空」,
于是 if (data.previous_status === "processing") 这种前置判断**永远不成立
且不报错**;
② 我方也补不出诚实的值:事件产生时那张单早已改完,而同一次变更重复到达是常态
(webhook 与巡检共用同一段落地代码),第二次到达时「上一个状态」已经等于新状态。
一个错的 previous_status 比没有更危险。
状态机的前置判断请用你自己库里的当前值。
重试与死信
首投失败后退避重试,等待 1 / 5 / 30 / 120 / 360 分钟,最多投递 6 次
(首投 + 5 次重投)。第 6 次仍失败转 dead 并在商户后台告警。
不会静默丢弃,也不会无限重试。
成功判据是任意 2xx,响应体我方不解析。所以先回 2xx 再异步处理,
别在 handler 里同步做重活 —— 超过 10 秒我方按失败处理并进入退避,
而你那边其实已经处理完了。
一个端点一行
扇出发生在入队那一刻:配了三个端点就落三行投递,各有自己的 attempts、
退避与 dead。所以某一个端点挂了不会连累另外两个,也不会出现「有一个通了
就整条标已送达」而另外两个永远收不到。
端点在事件产生之后被你删掉或停用了,那一行标 no_subscriber 而不是重试到
dead —— 死信面是要人处理的告警,「自己关掉的端点」不该出现在那里。
产生事件时一个订阅方都没有,同样落一行 no_subscriber:这样「事件产生了但
没人订阅」与「事件根本没产生」在商户后台上是两种不同的显示。
沙盒与生产
订阅按 env 隔离:沙盒端点收不到生产事件。业务产生的事件一律 livemode: true
—— 今天沙盒与 live 共用同一套账本,事件对应的是真实分录。
data.id 是内部原始 id,不带 REST 接口那层前缀
这是最容易踩的一个坑:GET /v1/remittances/... 返回的 id 是 rmt_<uuid>,
而事件里的 data.id 是裸的 <uuid>。回查时自己拼前缀(多数端点两种都认,
但别赌)。另有几条事件的 data.id 根本不是那张单的号,见下表最后一列。
回查一律要带 x-on-behalf-of,值就用事件里的 external_member_id。
data.object | data.id 是什么 | 拿它回查 |
|---|---|---|
member | 会员内部 id | GET /v1/members/mem_<id> |
kyc | 会员内部 id,不是 KYC 单号 | GET /v1/kyc |
deposit | 我方入金流水号(纯数字) | 没有单笔端点;GET /v1/deposits 列表里的 dep_<你上报的 reference> 是另一套标识,两者对不上 |
withdrawal | 会员自助链上提现单号 | 开放 API 上没有回查端点(见下) |
remittance | 汇款单号 | GET /v1/remittances/rmt_<id> |
qrpay_order | 扫码付单号 | GET /v1/qrpay/orders/qrp_<id> |
card_application | 开卡申请单号 | GET /v1/cards/applications/cap_<id> |
card | 卡 id | GET /v1/cards/crd_<id> |
card_topup | 充值到卡单号 | 没有单笔端点;GET /v1/cards/crd_<卡 id> 看余额 |
earn_order | 定期结算时是定期订单号;活期派息时是会员内部 id | GET /v1/earn/orders(只覆盖定期那一半) |
exchange_order | 兑换单号 | GET /v1/exchange/orders 列表(那里的 id 是 exc_<id>) |
internal_transfer | 转账单号 | GET /v1/transfers/itr_<id> |
**withdrawal.order.* 与 POST /v1/withdrawals 不是同一门生意**
两者的 id 都长得像 wdr_,但落在两张表上:
withdrawal.order.*事件说的是会员在 App 里自助发起的链上提现,由我方审核、
我方出款;
POST /v1/withdrawals/.../confirm/.../fail是你自己执行链上出款时
的两段式扣账,全程由你驱动,因此不产生任何事件(你已经知道结果了)。
把事件里的 id 拿去 GET /v1/withdrawals/{id} 会得到 not_found。
平台自营会员不发事件,只有归属到某个商户的会员才会触发桥接。
订阅清单里有、但今天不会到达的两条
商户后台的订阅勾选框里还留着 card.application.rejected 与
card.application.submitted。它们当前没有生产者 —— 开卡失败与开卡补件今天
只发邮件,没有落会员通知,而 webhook 的唯一桥接点是会员通知。
所以本页不登记它们:登记了你会写一个 handler 然后一直等。
开卡结果请轮询 GET /v1/cards/applications/cap_<id>,或等
card.application.approved(成功那一侧是有生产者的)。
member.created
会员建档成功
member.suspended
商户级停用状态变化(停用与恢复共用这一条)
kyc.result.updated
实名审核有结果,或被要求补充材料
非端点发起
deposit.credited
链上入金已确认并记进会员可用余额
非端点发起
withdrawal.order.locked
会员自助链上提现已提交,资金已从可用余额扣走
非端点发起
withdrawal.order.completed
会员自助链上提现已出款完成
非端点发起
withdrawal.order.failed
会员自助链上提现未完成,资金已全额退回可用余额
非端点发起
remittance.order.completed
汇款已到账收款人
remittance.order.failed
汇款未能完成,冻结资金已全额退回
remittance.order.refunded
汇款到账后被收款行退回,款项已退回会员余额
remittance.order.action_required
球在会员脚下:要确认新价格,或要补充材料
qrpay.order.completed
扫码付成功,上游已向收单商户放行
qrpay.order.failed
扫码付未成功,扣款已全额退回可用余额
qrpay.order.refunded
扫码付被退款,款项已退回会员可用余额
card.application.approved
开卡成功,卡片已可用
card.status.updated
卡片状态发生变化(冻结 / 解冻 / 激活 / 销卡 / 制卡与物流推进)
card.topup.credited
充值到卡的钱已经真的到卡上了
earn.order.settled
理财收益已入账(活期派息 / 定期到期结本息)
exchange.order.executed
站内兑换已成交
transfer.completed
会员收到一笔站内转账