ZeroPay

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.
Webhooks · Docs · ZeroPay