Z Zise Developers 简体中文
Account Center › Guides

Events carry only IDs and states; query details separately. Signature verification, deduplication, and redelivery.

Webhook

We push state changes to you, faster than polling and without consuming rate-limit quota. This guide covers overview, configuration, and troubleshooting. For each event's exact trigger and which object's ID data.id contains, see the Event Catalog.

1 · Overview

What an Event Looks Like

We send POST to your configured endpoint with Content-Type: application/json and a 10-second timeout. Four request headers:


Content-Type: application/json
z-signature:  t=<unix秒>,v1=<base64>
z-event-id:   evt_2f1c8a9b4d7e4c1fa0b3e5d6c7a8b901
z-event-type: deposit.credited

The body has the same structure for all events; only the values in data differ:


{
  "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
  }
}
FieldMeaning
event_idID of the event for this delivery. Redelivery retains it; use it in your idempotency table
event_typeEvent type, matching the z-event-type header
created_atWhen the event was created, not delivered or retried
livemodetrue = production data; false = sandbox data. Ordinary merchant events on the independent test Worker are also false, without an _sbx principal; the production Worker defaults to true
data.objectObject type, determining which endpoint to query using data.id
data.idThe object's raw internal ID, without REST prefixes such as rmt_ / crd_
data.external_member_idYour supplied external member ID; pass it unchanged in x-on-behalf-of when querying
data.statusState string
data.status_versionBasis for forward-only merging. Some events always use 0; see section 5

There is no previous_status. Before 2026-08-13, this field existed but was always empty

(no producer populated it). It was removed because an always-empty field is worse than none: "" reads like

“the previous state was empty,” so guards such as if (data.previous_status === "processing")

never match and never error. Even a populated value could not be reliable: the same change commonly arrives twice,

and on the second arrival, the previous state already equals the new state. Use your database's current value for state-machine guards.

Strict Boundary: Event Bodies Contain Only IDs and States

No amounts, asset codes, card number fragments, risk reasons, or upstream names.

This is not about saving bytes. The webhook endpoint is your service, whose transport and storage we cannot guarantee; the GET /v1/<资源>/{id} path has three validation layers: API Key, scope, and delegated member. Query details using data.id with x-on-behalf-of: <external_member_id>; do not expect amounts in event bodies for accounting.

A direct consequence: some event outcomes cannot be distinguished by status. The clearest example is kyc.result.updated: approval and rejection both use status: updated. Granting access based on it directly is incorrect. See individual explanations in the Event Catalog.

What We Guarantee and What We Do Not

2 · Configuration

Where to Configure It

Merchant Portal → Developer → Webhooks. Viewing requires mp.webhook.read; adding endpoints and redelivery require mp.webhook.write. The page shows endpoints and the latest 100 delivery records.

Only https is accepted when adding an endpoint; http:// is rejected immediately. Creation returns a webhook secret shown only once. It cannot be retrieved after closing the dialog.

Three current facts, to avoid assumptions based on other systems:

We set no endpoint-count limit. Each additional endpoint adds a delivery row and retry budget for the same event, while sharing each cycle's delivery quota (see below).

Endpoint Fan-Out: One Row per Event × Endpoint

Fan-out happens at enqueue time, not by looping during delivery. Three endpoints produce three delivery records, each with independent attempts, backoff, and dead state.

This matters because the previous implementation stored one row per event, called every endpoint during delivery, and marked the whole row sent if any endpoint returned 2xx. With three endpoints—one healthy and two returning 500— the two failing endpoints never retried, while our record showed delivered. Your symptom was a downstream system randomly missing events, without errors on either side.

You can now inspect attempts and HTTP codes per endpoint; redelivery simply returns that row to the queue without affecting the other endpoints already delivered.

Three Forms of No Recipient

no_subscriber means no recipient, not failure. It arises in two cases:

Neither retries nor becomes dead. Dead letters are actionable alerts; an endpoint you disabled should not appear there. However, a row is always recorded: no row would look like no event ever occurred, which differs from an event occurring with nobody subscribed.

Sandbox and Production

Business data remains environment-isolated: members, KYC, and orders created by sandbox keys belong to a shadow data principal, with event livemode: false; production keys produce livemode: true. The shadow principal is only an internal isolation mechanism. Payload merchant_id always uses your main merchant short code, never _sbx.

Webhooks are operational configuration; sandbox principals inherit the main merchant's subscriptions:

The delivery record's env is the event environment, not the configuration environment of the selected endpoint. A sandbox event falling back to a live endpoint therefore still clearly shows sandbox in both record and payload.

Delivery Cadence and Backoff

The delivery worker runs every 5 minutes, selecting up to 50 pending deliveries per cycle across all merchants. Therefore:

Failures follow the backoff schedule below: initial attempt + 5 retries = at most 6 deliveries.

Delivery attemptWait since previous failure
1 (initial)—
21 minute
35 minutes
430 minutes
52 hours
6 (final)6 hours

Failure on attempt 6 marks the record dead and alerts in the merchant portal. The schedule spans about 8 hours 36 minutes— your window to fix the endpoint before manual redelivery is required.

⚠ Waits start at failure time, but delivery occurs only on the 5-minute cycle, so the 1-minute step is effectively 1~5 minutes. Do not expect exact retry timing.

Any 2xx means success; the response body is not parsed. Therefore, return 2xx first, then process asynchronously. Heavy synchronous work in the handler exceeding 10 seconds is treated as failure and triggers backoff, even if your system actually finished processing; you may then receive five duplicates.

3 · Signature Verification

Which Secret to Use

Use the endpoint's own webhook secret, not the API Key's signing_key. They are different values used in opposite directions:

SecretSourcePurposeDirection
signing_keyReturned when creating an API KeyCompute x-signature for requests you send usOutbound
webhook secretReturned when creating a webhook endpointVerify z-signature on requests we send youInbound

Their counts do not even correspond: one merchant can have multiple API Keys and webhook endpoints, with a separate secret for each endpoint. The wrong secret causes every signature check to fail, looking like we signed incorrectly—the top issue in this page's troubleshooting table.

Calculation


v1 = base64( HMAC-SHA256( webhook_secret, "<t>." + 原始请求体字节 ) )

t is the Unix seconds value in the z-signature header. Verify t falls within ±300 seconds, or an intercepted old request can be replayed indefinitely.

⚠ Signing uses the original body bytes. Do not JSON.parse and then stringify— any change in key order, whitespace, or number formatting changes the result. Use your framework's raw-body access, such as Express express.raw or Flask request.get_data().

⚠ Every delivery, including retries, is signed again using the current t. Multiple deliveries of one event_id have different signatures; never use signatures as deduplication keys.

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

4 · Idempotency and Forward-Only Merging

Two Layers of Deduplication

Layer 1 · By event_id. This catches duplicates from backoff retries and portal-triggered redeliveries— they retain the same event_id. This is intentional: a new ID would make a redelivery look like a new occurrence, which can cause erroneous duplicate credits for financial events.

Layer 2 · By business key. If the same state change is generated twice by duplicate business triggers, it has two different event_id values, so layer 1 cannot catch it. Your handler must also be idempotent on (data.object, data.id, data.status): skip if that state has already been processed.

Layer 1 alone can record the same business fact twice in rare cases; layer 2 alone reruns business logic unnecessarily on every retry. You need both.

Forward-Only Merging

Only update forward by status_version; discard out-of-order events. Otherwise, a slow response can turn “received” back into “processing,” without errors on either side.

⚠ However, do not simply skip every version less than or equal to the current one: events whose version is always 0 (see below) would apply only once. Compare versions for versioned events; for unversioned events, compare created_at and query by ID to confirm.

5 · Troubleshooting

SymptomMost likely causeAction
Sandbox events do not arriveA sandbox endpoint exists but does not subscribe to this event, and live endpoints do not match eitherCheck events on endpoints in both environments; no match produces no_subscriber
No events arriveOnly a few minutes have passedDelivery runs every 5 minutes; wait up to 5 minutes before concluding
No events arriveAll delivery records are no_subscriberNo enabled matching endpoint existed when the event was created; configure one, then redeliver each record
One event type never arrivesThat event currently has no producerCheck its explanation in the Event Catalog, and query instead
Delivery records show failed / deadYour endpoint returned non-2xx, timed out, or failed TLS handshakeCheck HTTP and 错误; fix, then redeliver
Delivery shows sent, but you did not receive itThe URL is wrong, but that server returned 2xxVerify the URL; there is no delete action, so contact your account manager
Signature always failsUsing the API Key's signing_keyUse the webhook secret returned when this endpoint was created
Signature always failsBody was deserialized and reserializedUse raw-body access
Signature always failsIncorrect output format or string constructionv1 is base64, not hex; the signing string is "<t>." + rawBody, including the dot
Signature sometimes failst tolerance is too strict, or your clock has driftedUse ±300 seconds; synchronize NTP
The same event arrives repeatedlyWe guarantee at-least-once deliveryImplement both deduplication layers from section 4
State reverts to an older valueNo forward-only mergingMerge by status_version; use created_at plus a query for constant-0 events
status_version is always 0These events have no version criterion; see belowUse arrival time plus a confirming query
data.id cannot resolve the two card eventsYour handler still assumes the pre-2026-08-13 formatThey previously used member IDs; now they use application IDs / card IDs
Older events are missing from delivery recordsThis page shows only the latest 100Contact support with event_id; do not treat the page as an audit archive

Events with Constant status_version 0

The following events always have status_version 0. These are not unfinished work— their object either has no state machine or is not an individual order:

EventWhy it has no version
member.createdNo state-machine version on the member row; emitted once per member
member.suspendedSame, but it can recur (suspend → restore); order by created_at
kyc.result.updatedThe object is a person, not an order (L1 / L2 use separate tables, and one person has multiple rows)
deposit.creditedDeposits expose only “credited”; one event per object's lifetime
exchange.order.executedExchange is atomic, synchronous, and irreversible; execution is final
earn.order.settledOnly flexible-product interest payments (a flexible position is a balance, not an order); fixed-term maturity settlement has a version

Workaround: order these by arrival time and query using data.id to confirm current state, rather than merging solely by version. In particular, do not skip versions less than or equal to the current one— that would apply only the first event and silently discard all subsequent ones.

Two events fixed on 2026-08-13. card.application.approved and card.status.updated

previously appeared in this table too. Their data.id contained the internal member ID, while data.object

said card_application / card, guaranteeing a 404 on lookup. The cause was a bridge fallback:

notification call sites did not identify the target order, so the bridge fell back to the member ID,

leaving no way to obtain a version either. Both now carry the real object ID and version.

If your handler assumes these two card events use member IDs, restore the standard convention.

Recovering After dead

A dead delivery does not mean the event is gone: its body remains stored unchanged in our database.

Go to Merchant Portal → Developer → Webhooks → Delivery Records, filter delivery status to “Abandoned (manual redelivery required),” and select Redeliver for each record. Redelivery:

Three limitations: