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
| Event | When |
|---|---|
payment.paid, payment.expired | The payment's status changes. |
payment_attempt.failed | One attempt failed. The payment can still be paid by another attempt. |
payout.paid, payout.failed, payout.rejected, payout.canceled | The payout reaches that status. |
dispute.opened, dispute.closed | A 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
| Header | Value |
|---|---|
X-Zeny-Signature | sha256= and the hex HMAC-SHA256 of timestamp + "." + body, keyed with the endpoint's secret |
X-Zeny-Timestamp | Unix seconds when this try was sent |
X-Zeny-Event | The event type |
X-Zeny-Event-Id | The event's evt_ id |
X-Zeny-Delivery | The 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
- At least once. The same event can arrive more than once. Keep the
idof events you handled and skip repeats. - No order guarantee. Compare
resourceVersionand ignore an event older than the one you already hold for that object. - Retries. A try that gets no
2xxis retried up to 12 times, with waits growing from one minute to six hours, about 20 hours in total. Each try is signed again with a new timestamp. - Answer fast. Store the event, return
2xx, and do the work afterwards. - Look, then ask. The dashboard lists every delivery and its tries. To have an event sent again, ask support.
Rotate an endpoint's secret in the dashboard if it may have leaked.