Webhook 事件
26 条事件。事件体只带 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
实名审核有结果,或被要求补充材料
非端点发起
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
站内兑换已成交