Webhooks
Signed callbacks for every order status transition.
When an order created with webhook_url changes status, ZeroPay POSTs a JSON payload to that URL. Events: order.processing, order.success, order.failed, order.refunded, order.expired.
payload
{
"event": "order.success",
"created_at": "2026-06-12T16:05:12Z",
"order": {
"order_no": "zp_ord_9f2c41d8a3b7e6f0a1d2",
"external_id": "inv_10086",
"status": "success",
"amount_usd": 49.9,
"amount_out": "49260000",
"settle_tx_hash": "0xdef456...",
"...": "same shape as GET /v1/orders/{order_no}"
}
}Verifying signatures
Each request carries X-ZeroPay-Event and X-ZeroPay-Signature: t=<unix>,v1=<hex>. v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with the webhook_secret of the API key that created the order. Always verify before trusting the payload, and reject stale timestamps (e.g. older than 5 minutes).
verify (Node.js)
import crypto from "node:crypto";
export function verifyZeroPaySignature(req: Request, rawBody: string): boolean {
// Header: X-ZeroPay-Signature: t=1718200000,v1=hex(hmacSHA256(secret, t + "." + body))
const header = req.headers.get("X-ZeroPay-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const expected = crypto
.createHmac("sha256", process.env.ZEROPAY_WEBHOOK_SECRET!)
.update(parts.t + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(parts.v1 ?? "", "hex"),
Buffer.from(expected, "hex"),
);
}Delivery & retries
- A delivery succeeds on any 2xx response within 10 seconds.
- Failures retry with exponential backoff: 1 min, 5 min, 30 min, 2 h — 5 attempts in total, then the delivery is marked dead.
- Deliveries can arrive out of order under retry; use the order.status from the payload (or re-fetch the order) rather than assuming event order.
- Make your handler idempotent — the same event can be delivered more than once.