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
}
}
| Field | Meaning |
|---|---|
event_id | ID of the event for this delivery. Redelivery retains it; use it in your idempotency table |
event_type | Event type, matching the z-event-type header |
created_at | When the event was created, not delivered or retried |
livemode | true = 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.object | Object type, determining which endpoint to query using data.id |
data.id | The object's raw internal ID, without REST prefixes such as rmt_ / crd_ |
data.external_member_id | Your supplied external member ID; pass it unchanged in x-on-behalf-of when querying |
data.status | State string |
data.status_version | Basis 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
- At least once, not exactly once. Duplicate delivery of the same state change is normal.
- No ordering guarantee. Delivery is selected by
next_attempt_at; a retrying event can arrive after later events. - No real-time guarantee (see delivery cadence below).
- No silent discards: undeliverable records become
deadand appear in the merchant portal for redelivery.
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:
- The creation form submits only a URL, so portal-created endpoints all use the
liveenvironment and subscribe to["*"](all events). - There is no endpoint deletion or disable action (nor a server endpoint for it). To retire an endpoint or restrict its event subscriptions, contact your account manager.
- Therefore, do not use it for temporary debugging: an incorrect URL remains and queues an extra delivery for every event.
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:
- No subscribed endpoint existed when the event was created;
- The endpoint was deleted or disabled after the event was created.
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:
- If matching
sandboxendpoints exist on the main merchant, sandbox events go only to them; - Without a matching
sandboxendpoint, delivery falls back to enabledliveendpoints on the main merchant, allowing existing integrations to verify the full flow directly; - Production events always go only to
liveendpoints, never in reverse tosandboxendpoints.
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:
- Initial delivery can take up to about 5 minutes after event creation. Do not design user messages around millisecond-level real time.
- Large backlogs drain at roughly 600 deliveries per hour.
Failures follow the backoff schedule below: initial attempt + 5 retries = at most 6 deliveries.
| Delivery attempt | Wait since previous failure |
|---|---|
| 1 (initial) | — |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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:
| Secret | Source | Purpose | Direction |
|---|---|---|---|
signing_key | Returned when creating an API Key | Compute x-signature for requests you send us | Outbound |
| webhook secret | Returned when creating a webhook endpoint | Verify z-signature on requests we send you | Inbound |
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
| Symptom | Most likely cause | Action |
|---|---|---|
| Sandbox events do not arrive | A sandbox endpoint exists but does not subscribe to this event, and live endpoints do not match either | Check events on endpoints in both environments; no match produces no_subscriber |
| No events arrive | Only a few minutes have passed | Delivery runs every 5 minutes; wait up to 5 minutes before concluding |
| No events arrive | All delivery records are no_subscriber | No enabled matching endpoint existed when the event was created; configure one, then redeliver each record |
| One event type never arrives | That event currently has no producer | Check its explanation in the Event Catalog, and query instead |
Delivery records show failed / dead | Your endpoint returned non-2xx, timed out, or failed TLS handshake | Check HTTP and 错误; fix, then redeliver |
| Delivery shows sent, but you did not receive it | The URL is wrong, but that server returned 2xx | Verify the URL; there is no delete action, so contact your account manager |
| Signature always fails | Using the API Key's signing_key | Use the webhook secret returned when this endpoint was created |
| Signature always fails | Body was deserialized and reserialized | Use raw-body access |
| Signature always fails | Incorrect output format or string construction | v1 is base64, not hex; the signing string is "<t>." + rawBody, including the dot |
| Signature sometimes fails | t tolerance is too strict, or your clock has drifted | Use ±300 seconds; synchronize NTP |
| The same event arrives repeatedly | We guarantee at-least-once delivery | Implement both deduplication layers from section 4 |
| State reverts to an older value | No forward-only merging | Merge by status_version; use created_at plus a query for constant-0 events |
status_version is always 0 | These events have no version criterion; see below | Use arrival time plus a confirming query |
data.id cannot resolve the two card events | Your handler still assumes the pre-2026-08-13 format | They previously used member IDs; now they use application IDs / card IDs |
| Older events are missing from delivery records | This page shows only the latest 100 | Contact 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:
| Event | Why it has no version |
|---|---|
member.created | No state-machine version on the member row; emitted once per member |
member.suspended | Same, but it can recur (suspend → restore); order by created_at |
kyc.result.updated | The object is a person, not an order (L1 / L2 use separate tables, and one person has multiple rows) |
deposit.credited | Deposits expose only “credited”; one event per object's lifetime |
exchange.order.executed | Exchange is atomic, synchronous, and irreversible; execution is final |
earn.order.settled | Only 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.approvedandcard.status.updatedpreviously appeared in this table too. Their
data.idcontained the internal member ID, whiledata.objectsaid
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:
- Retains the same
event_id, so your first deduplication layer recognizes the same event; - Resets
attemptsto zero and requeues the record; - Is not immediate: it waits for the next normal delivery cycle and uses the same backoff schedule on failure.
Three limitations:
- Only
dead/failed/no_subscribercan be redelivered. Notsent— resending a delivered event would unilaterally create duplicates. Use query endpoints to recover data; norpending, which is already queued and would only have its backoff reset. - Redelivering a
no_subscriberrow fans out to all current endpoints subscribed to that event, retaining the originalevent_id. Therefore, configure endpoints first, then redeliver. Redelivery is rejected if none exist. - Delivery records show only the latest 100. If dead letters accumulate until a record falls out, it can no longer be recovered through this view— route dead-letter alerts to your own on-call channel instead of waiting for someone to browse the page.