Webhooks

A webhook is a message we send to your server. Whenever a customer pays, ZyraPay sends your server the details so you can finish the order. Each message is signed, so you can be sure it really came from us.

The webhook lifecycle
Payment
event occurs
Sign
HMAC-SHA256
Deliver
POST to your URL
Your server
returns 200
ZyraPay signs each event and delivers it to your endpoint, retrying on failure.

A webhook is an HTTP POST ZyraPay sends to a URL you control when something happens to a payment — a checkout is paid, a deposit arrives, a wallet is minted. It is the canonical signal to fulfill an order; do not rely on the customer's browser returning to your successUrl.

Where ZyraPay sends events

The delivery URL is resolved per payment:

  • Checkout (payment projects): the checkout's webhookUrl if set, otherwise the project's webhookUrl.
  • Deposit (wallet projects): the project's webhookUrl.
If no URL is configured at either level, no webhook is sent. Set a project-level webhookUrl in the dashboard, or pass one per checkout.

Event types

The type field tells you what happened. The shape of data depends on the event.

EventFires when
checkout.paidA checkout is fully paid — paidAmount equals the total.
checkout.underpaidA checkout received a partial payment — paidAmount is less than the total.
checkout.overpaidA checkout received more than the total — paidAmount exceeds it.
checkout.createdA checkout was created with a webhookUrl (best-effort, at creation time).
wallet.createdA deposit wallet was minted on a project that has a webhookUrl (best-effort).
depositA payment arrived into a wallet-type project's wallet.

Not yet emitted

The event enum also declares checkout.expired and checkout.cancelled, but no current code path emits them. Do not build logic that waits on those events.

The payload

Every event is delivered as a SimpleWebhookPayload envelope. The same five top-level fields appear on every event; only data varies.

FieldTypeDescription
typerequiredstringThe event type, e.g. checkout.paid.
datarequiredobjectThe event payload (shape depends on type — see below).
timestamprequirednumberEpoch milliseconds when the event was emitted.
projectIdrequiredstringThe project the event belongs to.
signaturerequiredstringHex HMAC-SHA256 of the envelope. Mirrors the X-Zyra-Signature header.

data shapes

TypeScript
// checkout.paid | checkout.underpaid | checkout.overpaid  (CheckoutData)
{ projectId, amount, checkoutId, from, transactionId, metadata }

// deposit  (DepositData)
{ projectId, amount, address, transactionId, metadata? }

// checkout.created
{ projectId, amount, checkoutId, items, checkoutUrl, expiresAt, metadata }

// wallet.created
{ projectId, amount: 0, wallet_address, wallet_id, created_at }

Example: checkout.paid

JSON
{
  "type": "checkout.paid",
  "data": {
    "projectId": "proj_8d2...",
    "amount": 13,
    "checkoutId": "chk_a91...",
    "from": "GBZ...XYZ",
    "transactionId": "<pi-payment-id>",
    "metadata": {}
  },
  "timestamp": 1750000000000,
  "projectId": "proj_8d2...",
  "signature": "9f86d081...c8e7b2"
}

Request headers

Every delivery carries these headers:

Headers
Content-Type: application/json
User-Agent: Zyra-BN/1.0
X-Project-ID: <projectId>
X-Event-Type: <type>
X-Timestamp: <epoch-millis>
X-Zyra-Signature: sha256=<hex>

Verifying the signature

The signature in X-Zyra-Signature is an HMAC-SHA256, hex-encoded, computed over JSON.stringify(payload) and keyed on your WEBHOOK_SECRET. Critically, the signed payload is the envelope without the signature field — i.e. exactly { type, data, timestamp, projectId }. The header value is prefixed with sha256=; strip it before comparing.

Always compare in constant time

Use a timing-safe comparison (e.g. crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal). A plain === leaks timing information about the secret. Reject any request whose signature does not match.
const crypto = require("crypto");

// Express: mount with a RAW body parser on the webhook route (see below).
function verifyZyraWebhook(req, secret) {
  const header = req.get("X-Zyra-Signature") || ""; // "sha256=<hex>"
  const received = header.replace("sha256=", "");

  // Re-create the signed object: the envelope WITHOUT `signature`.
  const { signature, ...payload } = req.body;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(JSON.stringify(payload))
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Gateway (crypto) callbacks are signed differently

The scheme above is for Pi checkout and deposit webhooks. Payments created through the NOWPayments-compatible gateway (POST /v1/payment) send their IPN signed with x-nowpayments-sig — an HMAC-SHA512 over the alphabetically sorted-key JSON body, byte-identical to NOWPayments (the legacy X-Zyra-Signature is sent alongside it). See Migrating from NOWPayments for the exact verification code.

Use the raw request body

Because the signature is computed over JSON.stringify(payload), verification depends on the JSON key order matching what the server produced (type, data, timestamp, projectId). Many frameworks parse and re-serialize the body, reordering keys and breaking the comparison.

Read the raw bytes

Capture the raw request body before any JSON middleware touches it, and compute the HMAC over those exact bytes. If you must reconstruct the object, rebuild it in the server's field order. The raw-body approach is the most robust.
JavaScript — Express raw body
// Mount a raw parser ONLY on the webhook route so other routes keep JSON.
app.post(
  "/webhooks/zyra",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body; // a Buffer
    const sig = (req.get("X-Zyra-Signature") || "").replace("sha256=", "");

    const payload = JSON.parse(raw.toString("utf8"));
    const { signature, ...signed } = payload;
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(JSON.stringify(signed)) // matches server field order
      .digest("hex");

    const ok =
      Buffer.from(expected, "hex").length === Buffer.from(sig, "hex").length &&
      crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex"));
    if (!ok) return res.status(400).send("bad signature");

    res.status(200).send("ok"); // ack FIRST, then process
    handleEvent(payload);       // do the slow work after acking
  }
);

Delivery, timeouts, and retries

  • Success is any HTTP 2xx response.
  • Timeout is 10 seconds per attempt — return your 2xx quickly and do heavy work afterward.
  • Retries: up to 3 retries on failure, with exponential backoff at 1s, 5s, then 15s.
  • Delivery is asynchronous — webhooks are queued and never block payment processing.

Treat delivery as best-effort

Delivery is retried a few times with backoff, but isn't guaranteed to be exactly-once. For anything critical, reconcile by reading the checkout or transaction via the API if an expected event never arrives, rather than assuming the webhook will always land.

Write idempotent consumers

Because an event may be delivered more than once (a retry can fire after your handler already succeeded but your 2xx was lost), your handler must be safe to run repeatedly. De-duplicate on a stable identifier from the payload — data.transactionId for payments, data.checkoutId for checkout events — and make fulfillment a no-op the second time.

JavaScript — idempotent handler
async function handleEvent(event) {
  if (event.type !== "checkout.paid") return;

  const eventKey = event.data.transactionId; // unique per on-chain payment

  // INSERT ... ON CONFLICT DO NOTHING — returns false if we've seen it before.
  const isNew = await markProcessed(eventKey);
  if (!isNew) return; // already fulfilled; ack and move on

  await fulfillOrder(event.data.checkoutId);
}

Next

See Advanced for safe-retry patterns on your own outbound API calls, and Going Live for the production webhook checklist.

Search docs

Search the documentation

Ask the AI assistant