Guide to Zero Hash Webhooks: Features and Best Practices
Zero Hash webhooks notify your systems about crypto and payments activity: a trade's status changes, a payment status updates, an account balance changes. If you're building on Zero Hash, webhooks are how you react to settlement events without polling.
This guide covers how Zero Hash webhooks work, the events you'll handle, how to verify the signature (HMAC or RSA, and it's not the same as Zero Hash's API auth), the replay window, and the best practices for production.
What are Zero Hash webhooks?
Zero Hash webhooks are HTTP POSTs delivered to a destination URL a Zero Hash rep provisions for you (subscriptions aren't self-service). Each is signed, and you can verify with either HMAC-SHA256 or RSA.
An important clarification up front: webhook signing is not the same as Zero Hash's REST API authentication. There are no X-SCX-* headers and no timestamp+method+path+body base64 scheme here. Webhooks use x-zh-hook-* headers with a hex HMAC (or RSA) over the payload.
Zero Hash webhook features
| Feature | Details |
|---|---|
| Configuration | Provisioned by a Zero Hash rep (destination URL + secret or RSA key) |
| Signature (new) | x-zh-hook-signature = hex HMAC-SHA256 over payload+timestamp; x-zh-hook-timestamp |
| Signature (legacy) | x-zh-hook-signature-256 = hex HMAC-SHA256 over payload (no timestamp) |
| RSA option | x-zh-hook-rsa-signature / -256 (verify with the Zero Hash public key) |
| Encoding | HEX (not base64) |
| Replay window | Reject if x-zh-hook-timestamp is not within +/-5 min |
| Event type | Carried in the x-zh-hook-payload-type header |
| SDK | None |
Common events
Zero Hash event names are inconsistent across its own docs (a header value like trade_status_changed vs event names like trade.status_changed), so the safest are:
| Event | Fires when |
|---|---|
trade_status_changed | A trade's status changes |
payment_status_changed | A payment's status changes |
The event type is carried in the x-zh-hook-payload-type header. Confirm the exact strings with your Zero Hash rep, and branch on the header rather than a value buried in the body.
Setting up Zero Hash webhooks
Subscriptions aren't self-service, a Zero Hash rep configures the destination URL and provisions either the HMAC shared secret or the RSA verification key (zh-public-key, distinct from any API key). Store whichever you're given for verification.
Securing Zero Hash webhooks
The current, replay-protected scheme signs payload+timestamp (the raw payload concatenated with the timestamp, no delimiter) with HMAC-SHA256, hex-encoded, in x-zh-hook-signature, alongside x-zh-hook-timestamp. Reject deliveries whose timestamp isn't within +/-5 minutes.
const crypto = require("crypto");
const SECRET = process.env.ZEROHASH_WEBHOOK_SECRET; // provisioned by Zero Hash
function verify(rawBody, timestamp, signature) {
// 5-minute replay window
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
// New scheme: payload + timestamp, raw concat, no delimiter; HEX
const message = rawBody.toString() + timestamp;
const expected = crypto.createHmac("sha256", SECRET).update(message).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.headers["x-zh-hook-timestamp"];
if (!verify(req.body, timestamp, req.headers["x-zh-hook-signature"])) {
return res.sendStatus(401);
}
const eventType = req.headers["x-zh-hook-payload-type"];
res.sendStatus(200); // acknowledge fast
processQueue.add({ eventType, body: JSON.parse(req.body) }); // async
});
The same check in Python:
import hashlib
import hmac
import os
import time
SECRET = os.environ["ZEROHASH_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
message = raw_body + timestamp.encode() # payload + timestamp, no delimiter
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
If you use the RSA option, verify x-zh-hook-rsa-signature against the Zero Hash public key instead; the legacy HMAC scheme (x-zh-hook-signature-256, over payload only, no timestamp) is also still documented.
Zero Hash webhook limitations and pain points
Webhook signing is not the REST API auth
The Problem: Zero Hash's REST API uses an X-SCX-* scheme over timestamp+method+path+body, base64-encoded. Webhooks are different: x-zh-hook-* headers, hex HMAC over payload+timestamp. Reusing the API auth logic fails.
Why It Happens: The two systems evolved separately.
Workarounds:
- Use the
x-zh-hook-*scheme (hex, overpayload+timestamp), not the APIX-SCX-*scheme.
How Hookdeck Can Help: Hookdeck verifies the webhook signature at the edge, so your app doesn't conflate it with API auth.
The signed message is payload+timestamp, hex
The Problem: The new scheme concatenates payload and timestamp with no delimiter and uses hex (not base64). Signing the payload alone, adding a delimiter, or base64-encoding all fail.
Why It Happens: Zero Hash binds the timestamp into the signed message and encodes as hex.
Workarounds:
- Concatenate
payload + timestamp(no separator), HMAC-SHA256, hex, and compare; enforce the 5-minute window.
How Hookdeck Can Help: Hookdeck verifies the signature and freshness at the edge.
Provisioned, not self-service
The Problem: You can't create or change subscriptions yourself, a Zero Hash rep provisions the destination and secret/key, which slows setup and rotation.
Why It Happens: Zero Hash manages destinations manually.
Workarounds:
- Coordinate with your Zero Hash rep for the URL and secret/key, and store them securely.
How Hookdeck Can Help: Hookdeck gives you a stable ingestion URL to hand to Zero Hash, so your side of the configuration is fixed even as downstream endpoints change.
Inconsistent event names
The Problem: Zero Hash's docs use different event strings in different places (header value vs event name), so a handler keyed to the wrong form mis-routes.
Why It Happens: The docs are inconsistent.
Workarounds:
- Branch on the
x-zh-hook-payload-typeheader, and confirm the exact strings with your rep.
How Hookdeck Can Help: Hookdeck's filters route on the header/fields you actually receive.
Best practices
Verify hex HMAC over payload+timestamp
Concatenate payload and timestamp (no delimiter), HMAC-SHA256, hex, compare in constant time, and enforce the +/-5-minute window. Use the RSA option if provisioned.
Don't reuse the REST API auth
The webhook scheme differs from Zero Hash's X-SCX-* API auth.
Branch on the payload-type header
Route on x-zh-hook-payload-type, and confirm the exact event strings with your rep.
Acknowledge fast and dedupe
Return 200 quickly, process off a queue, and dedupe so retries don't double-process. See why to process webhooks asynchronously.
Make Zero Hash webhooks production-ready
Hookdeck verifies the x-zh-hook signature, deduplicates, and durably queues every settlement event
Conclusion
Zero Hash webhooks are signed with a hex HMAC-SHA256 (or RSA) over payload+timestamp in x-zh-hook-* headers, distinct from Zero Hash's REST API auth, with a 5-minute replay window and the event type in x-zh-hook-payload-type. Verify the right scheme, enforce freshness, branch on the header, and dedupe.
Hookdeck verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique settlement events.
Get started with Hookdeck for free and handle Zero Hash webhooks reliably in minutes.