zenydevelopers

Webhooks

Zeny calls your server when something changes. Verify the signature before you trust the call.

Add an endpoint URL in the dashboard and choose its events. Zeny sends each event as a POST with a JSON body. Answer with any 2xx status to say you have it.

Events

EventWhen
payment.paid, payment.expiredThe payment's status changes.
payment_attempt.failedOne attempt failed. The payment can still be paid by another attempt.
payout.paid, payout.failed, payout.rejected, payout.canceledThe payout reaches that status.
dispute.opened, dispute.closedA dispute opens or is decided.

The event

{
  "id": "evt_0190f3e2-…",
  "object": "event",
  "type": "payment.paid",
  "apiVersion": "v1",
  "createdAt": "2026-11-06T08:00:00Z",
  "mode": "live",
  "resourceVersion": 1730880000000000,
  "data": { "object": { "id": "pay_0190f3e2-…", "object": "payment", … } }
}

data.object is exactly what the matching GET returns for that object, so one parser serves both.

Headers

HeaderValue
X-Zeny-Signaturesha256= and the hex HMAC-SHA256 of timestamp + "." + body, keyed with the endpoint's secret
X-Zeny-TimestampUnix seconds when this try was sent
X-Zeny-EventThe event type
X-Zeny-Event-IdThe event's evt_ id
X-Zeny-DeliveryThe id of this delivery

Verify the signature

Use the raw request body, exactly as received. Re-serialising the JSON changes the bytes and breaks the check. Compare in constant time and reject a timestamp more than five minutes old.

// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, headers, secret) {
  const ts = headers["x-zeny-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = "sha256=" +
    createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex");
  const got = headers["x-zeny-signature"] ?? "";
  return got.length === expected.length &&
    timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
# Python
import hashlib, hmac, time

def verify(raw_body: bytes, headers, secret: bytes) -> bool:
    ts = headers["X-Zeny-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(secret, ts.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest("sha256=" + mac.hexdigest(),
                               headers.get("X-Zeny-Signature", ""))

Delivery rules

Rotate an endpoint's secret in the dashboard if it may have leaked.