Overview & verification
Verify signatures and handle retries for Payzum webhook events.
payzum signs every outbound webhook so your backend can reject forged requests before processing them. There are three distinct signature schemes, depending on the delivery channel:
- Payment IPN — NowPayments dialect (the default for every merchant).
- Payment IPN — CoinPayments dialect (activated when CoinPayments-style keys are issued for the merchant).
- Mass-payout webhooks (payzum-native format).
All three sign with the same merchant webhook secret — the one shown once at merchant creation or rotation — but they differ in hash algorithm, header, and body encoding. This page is the single reference for all three.
Scheme comparison
| | Payment IPN (NowPayments dialect) | Payment IPN (CoinPayments dialect) | Mass-payout webhooks |
|---|---|---|---|
| Hash | HMAC-SHA-512 | HMAC-SHA-512 | HMAC-SHA-256 |
| Signature header | x-nowpayments-sig (fixed) | HMAC (fixed) | X-Payzum-Signature (fixed) |
| Body encoding | JSON, keys sorted alphabetically (recursive) | form-urlencoded, keys sorted, RFC-3986 percent-encoding | JSON, insertion order (not sorted) |
| Secret | webhook secret | webhook secret | webhook secret (the same one) |
| Event id | event_id in body + x-payzum-event-id header | ipn_id in body | eventId in body + X-Payzum-Event-Id header |
| Signed timestamp | event_at (epoch seconds) | none | eventAt (epoch seconds) |
| Timeout per attempt | 10 s | 10 s | 15 s |
| Retries | 5 × 30 s (any failure) | 5 × 30 s (any failure) | 5 × 60 s (only 5xx/network) |
Do not reuse your payment-IPN verifier for mass-payout webhooks. They use a different hash algorithm (SHA-256 vs SHA-512) and a different header — a reused verifier will silently reject (or worse, mis-verify) deliveries. Treat them as separate endpoints with separate verification functions.
None of the headers is configurable: the signature header for each channel is fixed. The only per-merchant selector is the IPN dialect as a whole — merchants default to the NowPayments dialect, and switch to the CoinPayments dialect when CoinPayments-style keys are issued for them.
Scheme 1 — Payment IPN, NowPayments dialect
The default for every merchant (ipnVersion='nowpayments-v1').
- Signature:
x-nowpayments-sigheader carries the lowercase hex (128 chars) ofHMAC-SHA-512(webhook_secret, body_bytes). - Body: the payment object serialized as JSON with alphabetically sorted keys, recursively. The bytes on the wire are that serialization — verify the HMAC over the raw bytes you received, never re-serialize.
- Transport:
Content-Type: application/json,User-Agent: payzum-ipn/0.1.0, 10-second timeout per attempt. - Payload: the same payment object returned by
GET /v1/payment(snake_case,payment_status∈waiting | partially_paid | finished | expired | failed), plus two additive fields:event_id— string, stable across retries of the same delivery. Use it as your dedup key.event_at— epoch seconds at signing time. Reject deliveries whoseevent_atis older than your tolerance window (10 minutes recommended) to defend against replays.
- The request also carries an
x-payzum-event-idheader (same value asevent_id) so you can deduplicate cheaply without parsing the body.
Verify the signature against the raw bytes you received — do not re-serialize the JSON. Small differences in whitespace or key order will break verification.
When payment IPNs fire
- Invoice paid — when the invoice reaches
paidoroverpaid, delivered aspayment_status: "finished". Overpayment is normalized: the IPN never says "overpaid" — detect it by comparingactually_paid > pay_amount. - Invoice expired — delivered as
payment_status: "expired", including abandonedpay_currency: "all"drafts where the buyer never selected a currency. - Informational deliveries —
wrong_token_received,suspicious_token_received, andlate_deposit_receivedevents use the same payload shape (the payload is the invoice's current state).
No IPN is emitted today for partial payments (partially_paid) or cancellations (cancelled/failed) — detect both by polling GET /v1/payment/:id. A partial-payment IPN is on the roadmap.
Verify in Node.js
Use a timing-safe comparison. The expected and received signatures must be equal-length before comparison.
import crypto from 'node:crypto'
export function verifyPayzumIpn(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signatureHeader, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Express handler
The handler must read the raw body before JSON parsing. The example below uses express.raw, verifies the signature, then deduplicates on event_id and enforces an anti-replay window on event_at.
import express from 'express'
import { verifyPayzumIpn } from './verify.js'
const app = express()
const seenEventIds = new Set() // use a durable store (DB/redis) in production
// IMPORTANT: capture the raw body bytes, do not let express.json() re-encode.
app.post(
'/payzum/ipn',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('x-nowpayments-sig')
if (!sig || !verifyPayzumIpn(req.body, sig, process.env.PAYZUM_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature')
}
const payload = JSON.parse(req.body.toString('utf8'))
// Anti-replay: reject deliveries older than your tolerance window (10 min).
if (Math.abs(Date.now() / 1000 - payload.event_at) > 600) {
return res.status(401).send('stale event')
}
// Dedup: event_id is stable across retries of the same delivery.
if (seenEventIds.has(payload.event_id)) return res.sendStatus(200)
seenEventIds.add(payload.event_id)
// handle payload.payment_status, payload.payment_id, etc.
res.sendStatus(200)
},
)Scheme 2 — Payment IPN, CoinPayments dialect
Activated when CoinPayments-style keys are issued for a merchant (ipnVersion='coinpayments-v1').
- Signature: the fixed
HMACheader carries the lowercase hex ofHMAC-SHA-512(webhook_secret, body_bytes). - Secret: the same merchant webhook secret as the NowPayments dialect — not the CoinPayments private key. The private key signs the merchant's requests to payzum; the outbound IPN is signed with the webhook secret.
- Body:
k=vpairs sorted alphabetically, RFC-3986 percent-encoding,Content-Type: application/x-www-form-urlencoded. - Always-present fields:
amount1,amount2,currency1,currency2(CoinPayments-style ticker, e.g.USDT.TRC20),fee(fixed"0.00000000"),ipn_id(the event id — stable across retries, use it to dedupe),ipn_mode=hmac,ipn_type=api,ipn_version=1.0,merchant,received_amount,received_confirms('1'if paid,'0'otherwise),status(-1 | 0 | 100),status_text,txn_id(the payzumpzi_id). Conditional fields:buyer_email,invoice(=order_id),custom. - Status mapping:
pending/partial→0"Waiting for buyer funds";paid/overpaid→100"Complete";expired/cancelled→-1"Cancelled / Timed Out". - No signed timestamp — deduplicate by
ipn_idonly.
A pay_currency: "all" draft that expires without a currency selection has no representation in the CoinPayments format, so it is not delivered to CoinPayments-dialect merchants — it is audited as a skip.
Scheme 3 — Mass-payout webhooks
The payzum-native channel for mass-payout order lifecycle events.
- Signature: the
X-Payzum-Signatureheader carries the lowercase hex ofHMAC-SHA-256(webhook_secret, body_bytes). The body isJSON.stringify(payload)in insertion order — not sorted — so, as with the IPN, verify over the raw bytes received. - Secret: the same merchant webhook secret that signs payment IPNs. There is no separate mass-payout signing secret.
- Dedup: the
X-Payzum-Event-Idheader (prefixpzwe_, stable across retries) — the same value is inpayload.eventId. - Payload (camelCase):
{ eventType, eventId, eventAt, order: { ... } }plus event-specific extras.eventAtis epoch seconds inside the signed body — use it as your anti-replay window. There are 12mass_payout.*event types (created,quote_refreshed,deposit_detected,underfunded,overfunded,batch_broadcasted,batch_confirmed,batch_failed,completed,partial_failed,expired,cancelled). - Delivery: 15-second timeout per attempt; retried only on
5xxor network error, 60-second delay, up to 5 attempts. A4xxresponse is treated as merchant misconfiguration and the delivery is dropped.
See Mass-payout webhooks for the full event catalogue and a verification example.
Retries
Payment IPNs (both dialects) are retried up to 5 times with a fixed 30-second backoff. A 2xx response marks the delivery as succeeded; any other outcome — including 4xx, timeouts, and network errors — is retried until the attempts are exhausted. Deliveries that exhaust their retries are marked failed and surface in the admin DLQ, from which they can be re-enqueued.
Mass-payout webhooks differ: they are retried only on 5xx/network errors (5 attempts, 60-second delay), and a 4xx drops the delivery immediately.
See Retries & DLQ for the full retry and replay behaviour.
Replaying failed deliveries
If your endpoint was down or rejected a delivery, you can review and replay failed webhook deliveries from your dashboard once your endpoint is healthy again. See Retries & DLQ for the full retry and replay behaviour.
Invoice types
Every IPN payload carries an invoice_type discriminator:
payment— one-shot payment from a reusable button. Behave as you always have.donation— buyer-driven amount (may differ from the link's default). Optional metadata in_payzum.donation.subscription— recurring cycle. The payload addssubscriber_email,subscription_cycle, andnext_renewal_at. payzum sends renewal reminders to the subscriber on days-3, -2, -1, 0, +1, +2, +3relative tonext_renewal_at.pos— cashier-driven, single-use terminal invoice. No subscriber tracking.
Common pitfalls
- Re-serializing the body before verifying. Always sign over the raw request body bytes — for all three schemes.
- Reusing the IPN verifier for mass-payout webhooks. Different algorithm (SHA-256 vs SHA-512) and different header — this is a silent security failure.
- Assuming the signature header is configurable. It is not:
x-nowpayments-sig,HMAC, andX-Payzum-Signatureare fixed per channel. - Skipping deduplication. Retries and replays can deliver the same event more than once. Dedupe on
event_id(NowPayments dialect),ipn_id(CoinPayments dialect), oreventId(mass payout). - Forgetting to store the webhook secret. The
webhookSecretis shown only at merchant creation or rotation.