Overview & verification
Verify signatures and handle retries for Payzum webhook events.
payzum delivers Instant Payment Notifications (IPNs) when an invoice transitions to partially_paid, finished, expired, or failed. Every delivery is signed so your backend can reject forged requests before processing them.
Signature scheme
Each delivery POSTs the canonical sorted-keys JSON serialization of the payment object as the body. A dedicated signature header carries the lowercase hex output of HMAC-SHA-512(webhook_secret, body_bytes) — read its exact name from the delivered request headers or your webhook settings.
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.
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 and parses after signature verification.
import express from 'express'
import { verifyPayzumIpn } from './verify.js'
const app = express()
// Set PAYZUM_SIG_HEADER to the signature header name payzum sends. You can
// read the exact name from the delivered request headers or your webhook
// settings.
const PAYZUM_SIG_HEADER = process.env.PAYZUM_SIG_HEADER
// 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(PAYZUM_SIG_HEADER)
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'))
// handle payload.payment_status, payload.payment_id, etc.
res.sendStatus(200)
},
)Retries
Non-2xx responses, timeouts, and network errors are retried up to 5 times with a fixed 30-second backoff. Deliveries that exhaust their retries are marked failed and surface in the admin DLQ.
Return 5xx for transient errors if you want the delivery retried. Returning 4xx signals a permanent failure and the delivery will not be retried.
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 JSON before verifying. Always sign over the raw request body bytes.
- Returning
4xxfor transient errors. Use5xxif you want the delivery retried. - Forgetting to store the webhook secret. The
webhookSecretis shown only at merchant creation or rotation.