Webhooks
Your node POSTs signed events to your configured endpoint(s) as things happen to a session. Webhooks are the reliable trigger for order fulfillment — prefer them over polling GET /v1/sessions/:id.
HTTPS-only
Your webhook endpoint URL must be HTTPS in production — your node SSRF-guards and validates outbound webhook URLs, and will not deliver to a plain-HTTP endpoint outside of local development.
Envelope
Every delivery has the same shape:
{
"id": "evt_5a0c9d21",
"type": "payment.succeeded",
"created_unix": 1768365072,
"livemode": true,
"idempotency_key": "evt_5a0c9d21",
"data": { "object": { /* event-specific payload, see below */ } }
}
idempotency_key always equals id. Since there is no sandbox, livemode is always true — treat every delivery as real money.
Event types
| Event | Fired when | Payload (data.object) |
|---|---|---|
session.created |
POST /v1/sessions succeeds |
{ session_id, order_id, amount, receiver_pubkey, mode, expires_unix, metadata } |
payment.succeeded |
Settlement confirmed on-chain — the reliable "fulfil the order" trigger | { session_id, order_id, amount, amount_received, payer_pubkey, receiver_pubkey, step2_signature, tx_ref, settled_unix, metadata } |
payment.expired |
Session hit its 5-minute expiry with no valid payment | { session_id, order_id, amount, expired_unix } |
session.cancelled |
Session cancelled via the API or the node admin UI | { session_id, order_id, cancelled_unix, cancelled_by } — cancelled_by is "api" or "admin" |
All amounts in every payload are decimal strings — see Getting started.
Refund/outbound-transfer events are not part of the v1 webhook surface; refunds are handled as manual outbound transfers by the merchant admin and are not currently notified via webhook.
payment.succeeded example
{
"session_id": "sess_01JZC8XR4M2QF7N3K5B6D8A0PQ",
"order_id": "order_8842",
"amount": "125",
"amount_received": "125",
"payer_pubkey": "aa91...Q=",
"receiver_pubkey": "n3Kx...Ug=",
"step2_signature": "b3J...",
"tx_ref": "txn_5c1d...",
"settled_unix": 1768365072,
"metadata": { "cart_id": "c_991" }
}
Signature verification
Every delivery carries:
X-ZuPayments-Signature: t=1768365072,v1=6f3b8c...<hex-hmac-sha256>
X-ZuPayments-Event: payment.succeeded
X-ZuPayments-Delivery: 1
- The signed string is
`${t}.${rawBody}`— the exact bytes you received, before any JSON parsing. v1isHMAC-SHA256(signedString, your_webhook_signing_secret)in hex, where the secret is your endpoint'swhsec_...signing secret (shown once when you configure the endpoint).- Reject the delivery if
|now - t| > 300seconds (a 5-minute replay window). - Compare the signature in constant time — never with
===or a naive string comparison. - De-duplicate on
idempotency_key(equal toid) — store processed event IDs for at least 7 days and treat repeats as no-ops. Retries (see below) can and will redeliver an event you've already processed.
Node.js verification (runnable)
This assumes an Express-style handler that has access to the raw, unparsed request body — signature verification must run against the exact bytes received, not a re-serialized JSON object.
import crypto from "node:crypto";
const REPLAY_WINDOW_SECONDS = 300;
/**
* @param {string} rawBody - exact bytes of the request body, unparsed
* @param {string} signatureHeader - the raw X-ZuPayments-Signature header value
* @param {string} signingSecret - your endpoint's whsec_... secret
* @returns {{ event: object }} the parsed, verified event
* @throws if the signature is missing, malformed, expired, or invalid
*/
function verifyZuPaymentsWebhook(rawBody, signatureHeader, signingSecret) {
if (!signatureHeader) throw new Error("missing X-ZuPayments-Signature header");
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const timestamp = parts.t;
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) throw new Error("malformed signature header");
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - Number(timestamp)) > REPLAY_WINDOW_SECONDS) {
throw new Error("webhook timestamp outside the replay window");
}
const signedString = `${timestamp}.${rawBody}`;
const expectedSig = crypto
.createHmac("sha256", signingSecret)
.update(signedString, "utf8")
.digest("hex");
const expectedBuf = Buffer.from(expectedSig, "hex");
const receivedBuf = Buffer.from(receivedSig, "hex");
if (
expectedBuf.length !== receivedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new Error("signature mismatch");
}
return { event: JSON.parse(rawBody) };
}
// --- Express usage ---
// Mount with a raw-body parser scoped to this route ONLY — do not use a JSON
// body parser upstream of this handler, or rawBody won't match what was signed.
//
// app.post(
// "/webhooks/zupayments",
// express.raw({ type: "application/json" }),
// (req, res) => {
// let verified;
// try {
// verified = verifyZuPaymentsWebhook(
// req.body.toString("utf8"),
// req.header("X-ZuPayments-Signature"),
// process.env.ZUPAYMENTS_WEBHOOK_SECRET,
// );
// } catch (err) {
// return res.status(400).send(`webhook verification failed: ${err.message}`);
// }
//
// const { event } = verified;
// if (await alreadyProcessed(event.idempotency_key)) {
// return res.status(200).end(); // de-duped no-op
// }
//
// switch (event.type) {
// case "payment.succeeded":
// await fulfilOrder(event.data.object);
// break;
// // handle session.created / payment.expired / session.cancelled as needed
// }
//
// await markProcessed(event.idempotency_key);
// res.status(200).end();
// },
// );
export { verifyZuPaymentsWebhook };
Delivery semantics & retries
- Your endpoint must return a 2xx status within 10 seconds.
- A 3xx response counts as a failure and triggers a retry, exactly like a non-2xx status, a timeout, or a connection error — a webhook delivery is never treated as successful unless it gets a direct 2xx. Do not respond with a redirect.
- Retries use exponential backoff with jitter: roughly immediate, then +30s, +2m, +10m, +1h, +3h, and so on, capped, for up to 15 attempts over ~48 hours. If all attempts fail, the delivery is marked
failedand surfaced in your node's admin webhook log for manual replay. - If your endpoint returns a
Retry-Afterheader, it is honored (capped at 10 minutes). - Every attempt is recorded with its own per-attempt status, so you can audit delivery history from the node admin UI.
Because retries happen, your webhook handler must be idempotent — always de-duplicate on idempotency_key before taking any side-effecting action (see verification snippet above).