Webhook
状态变更由我方推给你,比轮询快、且不占限流配额。这一篇讲总览 / 配置 / 排查三件事。
每条事件具体在什么时刻发、data.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",
"previous_status": "",
"status_version": 0
}
}
| 字段 | 含义 |
|---|---|
event_id | 这一次投递对应的事件号。重投沿用同一个值,你的幂等表就用它 |
event_type | 事件类型,与 z-event-type 头同值 |
created_at | 事件产生时刻(不是投递时刻,也不是重投时刻) |
livemode | true = 生产。今天业务事件一律为 true,见下文「沙盒与生产」 |
data.object | 对象类型,决定你拿 data.id 该去回查哪个端点 |
data.id | 对象的内部原始 id,不带 REST 接口那层 rmt_ / crd_ 前缀 |
data.external_member_id | 你上报的会员外部号;回查时原样放进 x-on-behalf-of |
data.status | 状态串 |
data.previous_status | 目前恒为空串,生产者一条都没有传它。别拿它做状态机的前置判断 |
data.status_version | 向前合并的判据。有几条事件恒为 0,见第五节 |
红线:事件体只带 ID 与状态
没有金额、没有资产代码、没有卡号的任何片段、没有风控原因、没有上游名称。
这不是省字节。webhook 端点是你的服务,它的传输与存储我方无从保证;
而 GET /v1/<资源>/{id} 那条路径上有 API Key、scope、代理会员三层校验。
要详情就拿 data.id 回查(带 x-on-behalf-of: <external_member_id>),
别指望从事件体里读出金额来记账。
这条红线有一个直接后果值得先知道:有几条事件靠 status 分不出结论。
最典型的是 kyc.result.updated —— 通过与驳回是同一个 status: updated,
照着它直接放行是错的。逐条的说明在事件目录里。
我方保证的与不保证的
- 保证至少一次,不保证恰好一次。同一次状态变更重复到达是常态。
- 不保证顺序。投递按
next_attempt_at取,一条重试中的事件会排在更晚的事件后面到达。 - 不保证实时(见下文投递节奏)。
- 不会静默丢弃:投不出去的最终转
dead并在商户后台列出来,等你重投。
二 · 配置
在哪里配
商户后台 → 开发者 → Webhooks。查看要 mp.webhook.read,
添加端点与重投要 mp.webhook.write。这一页同时是端点列表和最近 100 条投递记录。
添加时只收 https,http:// 当场拒。新端点创建后返回一把
webhook secret,只显示这一次 —— 关掉对话框就再也取不回来。
三件当前的实情,写在这里免得你按别处的经验去找:
- 添加表单只提交 URL,所以后台建出来的端点一律是
live环境、
订阅一律是 ["*"](全部事件)。
- 没有删除或停用端点的入口(服务端也没有这个接口)。要下线一个端点,
或要把某个端点改成只订阅几类事件,联系你的客户经理。
- 因此别拿它做临时调试:一个填错的 URL 会一直留在那里,每条事件都为它多排一行投递。
端点个数我方没有设上限。但每多一个端点,同一条事件就多一行投递、多一份重试预算 ——
而它们共享每轮的投递额度(见下)。
按端点扇出:一行 = 一个「事件 × 端点」
扇出发生在入队那一刻,不是投递时循环。配了三个端点就落三行投递记录,
每一行有自己的 attempts、自己的退避、自己的 dead。
这条值得单独讲,因为它此前不是这样:以前是「一个事件一行,投递时循环打所有端点,
只要有一个 2xx 就把整行标 sent」。配三个端点、一个通两个 500 的商户,
那两个端点永远不会重试,而我方投递记录显示「已送达」。
你那边的表现是某一个下游系统随机漏事件,两边都没有报错。
现在你能在投递记录里逐端点看到 attempts 与 HTTP 码,重投也退化成
「把这一行改回排队」,不会连累已经送达的那两个端点。
收件人的三种「没有」
投递记录里 no_subscriber 不是失败,是没有收件人。它有两种来源:
- 事件产生时一个订阅端点都没有;
- 事件产生之后,那个端点被删掉或停用了。
这两种都不进重试、不进 dead —— 死信面是要人处理的告警,
「自己关掉的端点」不该出现在那里。但它一定会落一行:零行的表现是
「看起来还没有事件」,而那与「事件产生了但没人收」是两件完全不同的事。
沙盒与生产
订阅按 env 隔离,投递行也带环境,沙盒端点收不到生产事件。
⚠ 但今天沙盒与 live 共用同一套账本与上游(独立数据面是另一个立项),
所以业务产生的事件一律是 live,livemode 恒为 true。
换句话说:沙盒端点今天收不到任何东西。要验通 webhook 链路,
配一个 live 端点、用你自己的测试会员走一遍真实业务。
投递节奏与退避阶梯
投递器搭在我方 5 分钟一轮的定时任务上,每轮最多取 50 条待投递(全商户共享)。
所以:
- 事件产生到首次投递,最长约 5 分钟。不要按「毫秒级实时」设计你的用户提示。
- 大量积压时消化速度约 600 条/小时。
失败之后按下面的阶梯退避,首投 + 5 次重投 = 最多 6 次投递:
| 第几次投递 | 距上次失败等待 |
|---|---|
| 1(首投) | — |
| 2 | 1 分钟 |
| 3 | 5 分钟 |
| 4 | 30 分钟 |
| 5 | 2 小时 |
| 6(最后一次) | 6 小时 |
第 6 次仍失败转 dead,在商户后台告警。整条阶梯跨度约 8 小时 36 分钟 ——
这是你修好端点的窗口,超过就要手工重投。
⚠ 等待时间是从失败那一刻起算,而投递只在 5 分钟一轮的任务里发生,
所以「1 分钟」那一档实际是 1~5 分钟。别据此写精确的重试期待。
成功判据是任意 2xx,响应体我方不解析。所以:先回 2xx,再异步处理。
在 handler 里同步做重活,超过 10 秒我方按失败处理并进入退避 ——
而你那边其实已经处理完了,接下来会收到 5 次重复。
三 · 验签
用哪把密钥
用端点自己的 webhook secret,不是 API Key 的 signing_key。 两把不同的值,
用途相反:
| 密钥 | 从哪来 | 用途 | 方向 |
|---|---|---|---|
signing_key | 创建 API Key 时下发 | 给你发给我方的请求算 x-signature | 出 |
| webhook secret | 创建 webhook 端点时下发 | 验我方发给你的 z-signature | 入 |
它们连数量都不对应:一个商户可以有多把 API Key、多个 webhook 端点,
而每个端点各有各的 secret。拿错的表现是验签恒不匹配,
而错误长得像「我方签错了」—— 这是本页排查表里的第一名。
怎么算
v1 = base64( HMAC-SHA256( webhook_secret, "<t>." + 原始请求体字节 ) )
t 是 z-signature 头里那个 unix 秒。校验 t 落在 ±300 秒内,
否则一份被截获的旧请求可以被无限重放。
⚠ 签的是原始请求体字节。 不要先 JSON.parse 再 stringify ——
键序、空白、数字格式任何一处差异都会算出另一个值。
用你的框架的 raw body 通道(Express 的 express.raw、Flask 的 request.get_data())。
⚠ 每一次投递(含重投)都用当时的 t 重新签名。同一个 event_id 的多次投递
签名各不相同,别拿签名做去重键。
Node
const crypto = require("node:crypto");
const express = require("express");
const app = express();
function verifyZiseWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = {};
for (const seg of String(header || "").split(",")) {
const i = seg.indexOf("=");
if (i > 0) parts[seg.slice(0, i).trim()] = seg.slice(i + 1).trim();
}
const t = parts.t;
if (!/^\d+$/.test(t || "")) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody) // Buffer,原始字节
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// ⚠ express.raw 而不是 express.json —— 后者拿不到原始字节
app.post("/webhooks/zise", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyZiseWebhook(req.body, req.get("z-signature"), process.env.ZISE_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const evt = JSON.parse(req.body.toString("utf8"));
// 先回 2xx,再异步处理。handler 超过 10 秒我方按失败处理。
res.sendStatus(200);
enqueue(evt); // 你自己的队列
});
Python
import base64, hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["ZISE_WEBHOOK_SECRET"].encode()
def verify_zise_webhook(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = {}
for seg in (header or "").split(","):
k, sep, v = seg.partition("=")
if sep:
parts[k.strip()] = v.strip()
t = parts.get("t", "")
if not t.isdigit():
return False
if abs(int(time.time()) - int(t)) > tolerance:
return False
mac = hmac.new(SECRET, (t + ".").encode() + raw_body, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(mac).decode(), parts.get("v1", ""))
@app.post("/webhooks/zise")
def zise_webhook():
raw = request.get_data() # 原始字节,不要用 request.json
if not verify_zise_webhook(raw, request.headers.get("z-signature", "")):
return "", 401
evt = json.loads(raw)
enqueue(evt) # 入队即返回,重活别放在这里
return "", 200
四 · 幂等与向前合并
去重要做两层
层一 · 按 event_id。 挡住我方退避重试、以及你在后台点的重投带来的重复 ——
这类重复沿用同一个 event_id。这也是我方刻意的:换新 id 会让「重投」在你这边
表现成「又发生了一次」,对资金事件而言那正是一次错误的重复入账的诱因。
层二 · 按业务键。 同一次状态变更如果被产生了两次(业务侧重复触发),
那是两个不同的 event_id —— 层一挡不住。所以你的处理函数还要对
(data.object, data.id, data.status) 幂等:已经处理成这个状态了就直接跳过。
只做层一,你会在少数场景下把同一件事记两次;只做层二,你会在每次重试时白跑一遍业务逻辑。
两层都要。
向前合并
按 status_version 只许向前更新,收到倒序的事件直接丢弃。少了它,
一次慢响应会把「已到账」打回「处理中」,而两边都不会报错。
⚠ 但不要写「version 不大于当前值就跳过」的合并器:恒为 0 的那几条事件
(见下)会因此只有第一条生效。正确的形状是「有版本号的按版本号比,
没有版本号的按 created_at 比 + 拿 ID 回查确认」。
五 · 排查
| 症状 | 最可能的成因 | 怎么办 |
|---|---|---|
| 一条事件都收不到 | 端点建在 sandbox 环境 | 沙盒端点今天收不到任何事件,改配 live 端点 |
| 一条事件都收不到 | 才过了几分钟 | 投递是 5 分钟一轮的,最长等 5 分钟再判断 |
| 一条事件都收不到 | 投递记录全是 no_subscriber | 事件产生时没有匹配的启用端点;配好之后逐条重投 |
| 某类事件永远不来 | 这条事件当前没有生产者 | 对照事件目录逐条的说明,改用回查 |
投递记录是 failed / dead | 你的端点非 2xx、超时、或 TLS 握手失败 | 看 HTTP 与 错误 两列;修好后重投 |
| 投递显示已送达,你没收到 | 端点 URL 填错但那台机器回了 2xx | 核对 URL;注意没有删除入口,联系客户经理 |
| 验签恒不通过 | 用了 API Key 的 signing_key | 换成该端点创建时下发的 webhook secret |
| 验签恒不通过 | 先反序列化再序列化了 body | 改用 raw body 通道 |
| 验签恒不通过 | 输出格式或拼串写错 | v1 是 base64 不是 hex;签名串是 "<t>." + rawBody,那个点不能少 |
| 验签偶尔不通过 | t 容差判太紧,或你的机器时钟偏了 | 容差用 ±300 秒;校准 NTP |
| 同一件事收到多次 | 我方保证至少一次 | 按第四节做两层去重 |
| 状态被改回旧值 | 没有做向前合并 | 按 status_version 合并;恒为 0 的按 created_at + 回查 |
status_version 恒为 0 | 已知问题,见下 | 这几条改用「到达时间 + 回查确认」 |
| 投递记录里翻不到旧事件 | 这一页只显示最近 100 条 | 拿 event_id 找我方支持;别把它当审计存档 |
status_version 恒为 0(已知问题)
下面这几条事件的 status_version 不随状态变更递增,恒为 0:
| 事件 | 说明 |
|---|---|
member.created | |
member.suspended | |
kyc.result.updated | |
deposit.credited | 入金没有状态机,只有「已入账」一态 |
card.application.approved | |
card.status.updated | |
exchange.order.executed | 兑换是原子的,没有中间态 |
transfer.completed | |
earn.order.settled | 只有活期派息那一半;定期到期结算带版本号 |
其中 deposit.credited / exchange.order.executed / transfer.completed 本来就
没有状态机可言,收到即终局;其余几条是缺版本判据。
绕行办法:对这几条改用「按到达时间排序 + 拿 data.id 回查确认当前状态」,
不要只靠版本号向前合并。特别是别写「version 不大于当前值就跳过」——
那会让这几条事件只有第一条生效,后面的全被吃掉,而你这边零报错。
转 dead 之后怎么补
投递转 dead 不代表事件没了:请求体在我方库里原样存着。
去 商户后台 → 开发者 → Webhooks → 投递记录,把「投递状态」筛成
「已放弃(需人工重投)」,逐条点「重投」。重投做的事是:
- 沿用同一个
event_id(你的层一幂等能认出这是同一件事); attempts归零,重新排进队列;- 不是立刻送达 —— 它按正常节奏等下一轮投递,失败仍走同一条退避阶梯。
三点限制:
- 只有
dead/failed/no_subscriber可以重投。sent不可 ——
已送达的事件再推一次是我方单方面制造重复,要补数据请走回查接口;
pending 也不可,它本来就在队列里,重置只会把退避清零。
no_subscriber那一行重投时会绑到现在订阅了这个事件的端点上。
所以「先把端点配好,再回来点重投」是对的顺序;一个端点都没有时点它会被拒。
- 投递记录只有最近 100 条。dead 堆到把它冲掉就真的补不回来了 ——
把死信告警接进你自己的值班通道,别等人去翻那一页。