Z Zise Developers

Webhook 事件

19 条事件。事件体只带 ID 与状态—— 不含金额、不含卡号任何片段、不含风控原因、不含上游名称。要详情请拿 ID 回查。

一条事件长什么样

我方向你配置的端点发 POSTContent-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兑换原子、同步、不可逆 —— 成交即终局
earn.order.settled仅活期派息那一半活期是余额包不是一笔一单;定期那一半带版本

其余事件都带真实版本号。⚠ card.application.approvedcard.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.objectdata.id 是什么拿它回查
member会员内部 idGET /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卡 idGET /v1/cards/crd_<id>
card_topup充值到卡单号没有单笔端点;GET /v1/cards/crd_<卡 id> 看余额
earn_order定期结算时是定期订单号;活期派息时是会员内部 idGET /v1/earn/orders(只覆盖定期那一半)
exchange_order兑换单号GET /v1/exchange/orders 列表(那里的 id 是 exc_<id>

**withdrawal.order.*POST /v1/withdrawals 不是同一门生意**

两者的 id 都长得像 wdr_,但落在两张表上:

把事件里的 id 拿去 GET /v1/withdrawals/{id} 会得到 not_found

平台自营会员不发事件,只有归属到某个商户的会员才会触发桥接。

订阅清单里有、但今天不会到达的两条

商户后台的订阅勾选框里还留着 card.application.rejectedcard.application.submitted。它们当前没有生产者 —— 开卡失败与开卡补件今天只发邮件,没有落会员通知,而 webhook 的唯一桥接点是会员通知。所以本页不登记它们:登记了你会写一个 handler 然后一直等。开卡结果请轮询 GET /v1/cards/applications/cap_<id>,或等card.application.approved(成功那一侧是有生产者的)。