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 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 JSON before verifying. Always sign over the raw request body bytes.
  • Returning 4xx for transient errors. Use 5xx if you want the delivery retried.
  • Forgetting to store the webhook secret. The webhookSecret is shown only at merchant creation or rotation.