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.
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
webhookUrlif set, otherwise the project'swebhookUrl. - Deposit (wallet projects): the project's
webhookUrl.
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.
| Event | Fires when |
|---|---|
checkout.paid | A checkout is fully paid — paidAmount equals the total. |
checkout.underpaid | A checkout received a partial payment — paidAmount is less than the total. |
checkout.overpaid | A checkout received more than the total — paidAmount exceeds it. |
checkout.created | A checkout was created with a webhookUrl (best-effort, at creation time). |
wallet.created | A deposit wallet was minted on a project that has a webhookUrl (best-effort). |
deposit | A payment arrived into a wallet-type project's wallet. |
Not yet emitted
The event enum also declarescheckout.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.
| Field | Type | Description |
|---|---|---|
typerequired | string | The event type, e.g. checkout.paid. |
datarequired | object | The event payload (shape depends on type — see below). |
timestamprequired | number | Epoch milliseconds when the event was emitted. |
projectIdrequired | string | The project the event belongs to. |
signaturerequired | string | Hex HMAC-SHA256 of the envelope. Mirrors the X-Zyra-Signature header. |
data shapes
// 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
{
"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:
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.// 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
2xxresponse. - Timeout is 10 seconds per attempt — return your
2xxquickly and do heavy work afterward. - Retries: up to 3 retries on failure, with exponential backoff at
1s,5s, then15s. - 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.
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.