Payment IPN
Verify and handle payment status webhooks.
payzum delivers a Payment IPN when an invoice is paid (delivered as payment_status: "finished") or expires (payment_status: "expired"). The delivery is a POST of a canonically serialized JSON body, signed with HMAC-SHA-512.
This page describes the default NowPayments dialect (ipnVersion='nowpayments-v1'). Merchants with CoinPayments-style keys receive IPNs in the CoinPayments dialect instead (form-urlencoded body, fixed HMAC header, same webhook secret) — see Overview & verification for the full comparison of both dialects.
Signature algorithm
| Property | Value |
|----------|-------|
| Algorithm | HMAC-SHA-512 |
| Input | Raw request body bytes (sorted-key canonical JSON) |
| Output | Lowercase hex string (128 chars) |
| Header | x-nowpayments-sig — fixed, not configurable |
| Secret | Your merchant webhook secret (shown once at creation or rotation) |
The body payzum transmits is the payment object serialized with alphabetically sorted keys, recursively. Because the signature is computed over the bytes on the wire, the bytes you receive are the signed bytes — you do not need to re-sort or re-serialize before verifying.
Verify the signature against the raw bytes received, before calling JSON.parse. Re-serializing the payload — even identically — can change whitespace or key order and break the comparison.
Delivery transport
Content-Type: application/jsonUser-Agent: payzum-ipn/0.1.0- 10-second timeout per attempt
x-payzum-event-idheader: same value as the body'sevent_id, so you can deduplicate cheaply without parsing the body
When IPNs fire
- Invoice paid — when the invoice reaches
paidoroverpaid. Both are delivered aspayment_status: "finished"— the IPN never says "overpaid". Detect overpayment by comparingactually_paid > pay_amount. - Invoice expired —
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_receiveduse the same payload shape (the payload is the invoice's current state).
No IPN is emitted today on partial payment (partially_paid) or cancellation (cancelled/failed) — detect both by polling GET /v1/payment/:id. A partial-payment IPN is on the roadmap.
Payload
The payload is the same payment object returned by GET /v1/payment (snake_case, payment_status ∈ waiting | partially_paid | finished | expired | failed), plus two additive fields:
| Field | Type | Description |
|-------|------|-------------|
| event_id | string | Stable across retries of the same delivery — use it as your dedup key |
| event_at | number | Epoch seconds at signing time — reject deliveries older than your tolerance window (10 minutes recommended) |
Verify in Node.js
Use timingSafeEqual to prevent timing attacks. The buffers must be the same length before comparison, so check lengths first.
import crypto from 'node:crypto'
export function verifyPaymentIpn(rawBody, sigHeader, secret) {
const expected = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(sigHeader, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Express handler
Read the raw body with express.raw before JSON parsing, verify the signature from the fixed x-nowpayments-sig header, then deduplicate and enforce an anti-replay window.
import express from 'express'
import { verifyPaymentIpn } from './verify.js'
const app = express()
const seenEventIds = new Set() // use a durable store (DB/redis) in production
app.post(
'/payzum/ipn',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('x-nowpayments-sig')
if (!sig || !verifyPaymentIpn(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)
// Route on payload.invoice_type and payload.payment_status
res.sendStatus(200)
},
)invoice_type discriminator
Every IPN payload carries an invoice_type field that identifies which product created the invoice.
| invoice_type | Notes |
|----------------|-------|
| payment | One-shot payment from a reusable payment button. No extra fields. |
| donation | Buyer-driven amount that may differ from the link default. Optional metadata available in _payzum.donation. |
| subscription | Recurring billing cycle. Adds subscriber_email, subscription_cycle, and next_renewal_at. Renewal reminder IPNs fire on days -3, -2, -1, 0, +1, +2, +3 relative to next_renewal_at. |
| pos | Cashier-driven single-use terminal invoice. No subscriber tracking. |
Payment status values
The payment_status field takes one of: waiting, partially_paid, finished, expired, failed. IPNs are delivered for finished and expired; poll GET /v1/payment/:id to observe partially_paid and failed transitions.
Retries
Deliveries 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, after which the delivery lands in the dashboard DLQ (re-enqueueable). See Retries & DLQ.
Common pitfalls
- Re-serializing before verifying. Always HMAC the raw body bytes, not a re-encoded version.
- Assuming the header is configurable. It is not — the signature always arrives in
x-nowpayments-sig(orHMACfor the CoinPayments dialect). - Skipping deduplication. Retries and replays deliver the same event more than once. Dedupe on
event_id(or, as a legacy fallback, onpayment_id+payment_status). - Mixing up webhook channels. Payment IPNs use HMAC-SHA-512 with the fixed
x-nowpayments-sigheader. Mass-payout webhooks use HMAC-SHA-256 withX-Payzum-Signature— see Mass-payout webhooks.