Webhook 事件
27 条事件。事件体只带 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 与巡检共用同一段落地代码,这是刻意的)。
- 按
event_id幂等。重投(你在商户后台点的,或我方退避重试的)**沿用同一个event_id** —— 换新 id 会让「重投」在你这边表现成「又发生了一次」,对资金事件而言那正是一次错误的重复入账。 - 按
status_version向前合并,收到倒序的事件直接丢弃。少了它,一次慢响应会把「已到账」打回「处理中」,而两边都不会报错。
status_version 不是所有事件都有(下面每条事件里注明了)。恒为 0 的那几条没有版本判据,只能按 created_at 比 —— 别写一个「version 相等就跳过」的合并器,那会让这几条事件只有第一条生效。
今天恒为 0 的是这几条,每一条都写明了原因(不是待办,是这门业务本来就没有第二条会乱序的事件):
| 事件 | 为什么没有版本 |
|---|---|
member.created / member.suspended | 会员行上没有状态机版本;停用/恢复是同一位的两个取值 |
kyc.result.updated | 对象是人不是单,L1/L2 分表、补件连单号都没有 |
deposit.credited | 入金对外只有「已入账」一态,一个对象一生只发一条事件 |
exchange.order.executed | 兑换原子、同步、不可逆 —— 成交即终局 |
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:这样「事件产生了但没人订阅」与「事件根本没产生」在商户后台上是两种不同的显示。
沙盒与生产
事件环境按业务数据主体判定:沙盒 Key 产生的事件是 livemode: false,生产Key 产生的事件是 livemode: true。沙盒主体继承主商户的 Webhook 配置;有匹配的 sandbox 端点时优先使用,否则回退到主商户的 live 端点。生产事件不会投到 sandbox 端点。
data.id 按资源类型使用既定格式,不要统一增删前缀
这是最容易踩的一个坑: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 | 公开申请单号,已经带 cap_ 前缀 | GET /v1/cards/applications/{data.id},不要再加前缀 |
card | 卡 id | GET /v1/cards/crd_<id> |
card_topup | 充值到卡单号 | 没有单笔端点;GET /v1/cards/crd_<卡 id> 看余额 |
card_withdrawal | 卡内转出单号(裸 uuid,REST 是 cwd_<id>) | 没有单笔端点;GET /v1/cards/crd_<卡 id> 看余额 |
earn_order | 定期结算时是定期订单号;活期派息时是会员内部 id | GET /v1/earn/orders(只覆盖定期那一半) |
exchange_order | 兑换单号 | GET /v1/exchange/orders 列表(那里的 id 是 exc_<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 可用于已关联持卡人的审核通过同步。后者带 cardholder_review_status: approved 时只说明持卡人通过,不表示卡已开出,申请自己的 status 保留真实进度。实体卡到用户手里、可以开始绑卡时发 card.application.awaiting_bind(下游库存标准申请审核通过后自动当面交付;平台自营是线下面交确认,或邮寄标用户已签收)。平台自营会员不发。其余补件 / 在途物流档仍应回查申请详情,不能把未收到通知当成状态没有变化。
member.created
会员建档成功
member.suspended
商户级停用状态变化(停用与恢复共用这一条)
kyc.result.updated
实名审核有结果,或被要求补充材料
非端点发起
kyc.quick.compensated
一份快捷 KYC 绑定已失败补偿,不再是当前有效绑定
非端点发起
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.application.rejected
开卡申请执行失败
card.application.submitted
已关联持卡人的审核状态同步
card.application.awaiting_bind
实体卡已可绑定
card.3ds.received
卡片 3DS / OTP 验证码已收到
非端点发起
card.status.updated
卡片状态发生变化(冻结 / 解冻 / 激活 / 销卡 / 制卡与物流推进)
card.topup.credited
充值到卡的钱已经真的到卡上了
card.withdraw.completed
卡内资金已退回会员钱包
card.transaction.updated
卡交易已接收或状态已更新
非端点发起
merchant.balance.waterline
全部下游商户资金池水位状态变化
非端点发起
earn.order.settled
理财收益已入账(活期派息 / 定期到期结本息)
exchange.order.executed
站内兑换已成交