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:

  1. Payment IPN — NowPayments dialect (the default for every merchant).
  2. Payment IPN — CoinPayments dialect (activated when CoinPayments-style keys are issued for the merchant).
  3. 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-sig header carries the lowercase hex (128 chars) of HMAC-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_statuswaiting | 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 whose event_at is older than your tolerance window (10 minutes recommended) to defend against replays.
  • The request also carries an x-payzum-event-id header (same value as event_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 paid or overpaid, delivered as payment_status: "finished". Overpayment is normalized: the IPN never says "overpaid" — detect it by comparing actually_paid > pay_amount.
  • Invoice expired — delivered as payment_status: "expired", including abandoned pay_currency: "all" drafts where the buyer never selected a currency.
  • Informational deliverieswrong_token_received, suspicious_token_received, and late_deposit_received events 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 HMAC header carries the lowercase hex of HMAC-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=v pairs 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 payzum pzi_ id). Conditional fields: buyer_email, invoice (= order_id), custom.
  • Status mapping: pending/partial0 "Waiting for buyer funds"; paid/overpaid100 "Complete"; expired/cancelled-1 "Cancelled / Timed Out".
  • No signed timestamp — deduplicate by ipn_id only.

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-Signature header carries the lowercase hex of HMAC-SHA-256(webhook_secret, body_bytes). The body is JSON.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-Id header (prefix pzwe_, stable across retries) — the same value is in payload.eventId.
  • Payload (camelCase): { eventType, eventId, eventAt, order: { ... } } plus event-specific extras. eventAt is epoch seconds inside the signed body — use it as your anti-replay window. There are 12 mass_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 5xx or network error, 60-second delay, up to 5 attempts. A 4xx response 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 adds subscriber_email, subscription_cycle, and next_renewal_at. payzum sends renewal reminders to the subscriber on days -3, -2, -1, 0, +1, +2, +3 relative to next_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, and X-Payzum-Signature are 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), or eventId (mass payout).
  • Forgetting to store the webhook secret. The webhookSecret is shown only at merchant creation or rotation.