Stable
Sourced from: docs/07-API-REFERENCE.md §4, §6.5; docs/DECISIONS.md D2.8, D6.3, D6.5; packages/shared/src/contracts/webhooks.ts

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.
  • v1 is HMAC-SHA256(signedString, your_webhook_signing_secret) in hex, where the secret is your endpoint's whsec_... signing secret (shown once when you configure the endpoint).
  • Reject the delivery if |now - t| > 300 seconds (a 5-minute replay window).
  • Compare the signature in constant time — never with === or a naive string comparison.
  • De-duplicate on idempotency_key (equal to id) — 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 failed and surfaced in your node's admin webhook log for manual replay.
  • If your endpoint returns a Retry-After header, 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).