Z Zise Developers

Webhook

状态变更由我方推给你,比轮询快、且不占限流配额。这一篇讲总览 / 配置 / 排查三件事。

每条事件具体在什么时刻发、data.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",
    "previous_status": "",
    "status_version": 0
  }
}
字段含义
event_id这一次投递对应的事件号。重投沿用同一个值,你的幂等表就用它
event_type事件类型,与 z-event-type 头同值
created_at事件产生时刻(不是投递时刻,也不是重投时刻)
livemodetrue = 生产。今天业务事件一律为 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

照着它直接放行是错的。逐条的说明在事件目录里。

我方保证的与不保证的

二 · 配置

在哪里配

商户后台 → 开发者 → Webhooks。查看要 mp.webhook.read

添加端点与重投要 mp.webhook.write。这一页同时是端点列表和最近 100 条投递记录。

添加时只收 httpshttp:// 当场拒。新端点创建后返回一把

webhook secret,只显示这一次 —— 关掉对话框就再也取不回来。

三件当前的实情,写在这里免得你按别处的经验去找:

订阅一律是 ["*"](全部事件)。

或要把某个端点改成只订阅几类事件,联系你的客户经理。

端点个数我方没有设上限。但每多一个端点,同一条事件就多一行投递、多一份重试预算 ——

而它们共享每轮的投递额度(见下)。

按端点扇出:一行 = 一个「事件 × 端点」

扇出发生在入队那一刻,不是投递时循环。配了三个端点就落三行投递记录,

每一行有自己的 attempts、自己的退避、自己的 dead

这条值得单独讲,因为它此前不是这样:以前是「一个事件一行,投递时循环打所有端点,

只要有一个 2xx 就把整行标 sent」。配三个端点、一个通两个 500 的商户,

那两个端点永远不会重试,而我方投递记录显示「已送达」。

你那边的表现是某一个下游系统随机漏事件,两边都没有报错。

现在你能在投递记录里逐端点看到 attempts 与 HTTP 码,重投也退化成

「把这一行改回排队」,不会连累已经送达的那两个端点。

收件人的三种「没有」

投递记录里 no_subscriber 不是失败,是没有收件人。它有两种来源:

这两种都不进重试、不进 dead —— 死信面是要人处理的告警,

「自己关掉的端点」不该出现在那里。但它一定会落一行:零行的表现是

「看起来还没有事件」,而那与「事件产生了但没人收」是两件完全不同的事。

沙盒与生产

订阅按 env 隔离,投递行也带环境,沙盒端点收不到生产事件

⚠ 但今天沙盒与 live 共用同一套账本与上游(独立数据面是另一个立项),

所以业务产生的事件一律是 livelivemode 恒为 true

换句话说:沙盒端点今天收不到任何东西。要验通 webhook 链路,

配一个 live 端点、用你自己的测试会员走一遍真实业务。

投递节奏与退避阶梯

投递器搭在我方 5 分钟一轮的定时任务上,每轮最多取 50 条待投递(全商户共享)。

所以:

失败之后按下面的阶梯退避,首投 + 5 次重投 = 最多 6 次投递

第几次投递距上次失败等待
1(首投)
21 分钟
35 分钟
430 分钟
52 小时
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>." + 原始请求体字节 ) )

tz-signature 头里那个 unix 秒。校验 t 落在 ±300 秒内,

否则一份被截获的旧请求可以被无限重放。

签的是原始请求体字节。 不要先 JSON.parsestringify ——

键序、空白、数字格式任何一处差异都会算出另一个值。

用你的框架的 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 通道
验签恒不通过输出格式或拼串写错v1base64 不是 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 → 投递记录,把「投递状态」筛成

「已放弃(需人工重投)」,逐条点「重投」。重投做的事是:

三点限制:

已送达的事件再推一次是我方单方面制造重复,要补数据请走回查接口;

pending 也不可,它本来就在队列里,重置只会把退避清零。

所以「先把端点配好,再回来点重投」是对的顺序;一个端点都没有时点它会被拒。

把死信告警接进你自己的值班通道,别等人去翻那一页。