Payment IPN

Verify and handle payment status webhooks.

payzum delivers a Payment IPN each time an invoice transitions to partially_paid, finished, expired, or failed. The delivery is a POST of a canonically serialized JSON body, signed with HMAC-SHA-512.

Signature algorithm

| Property | Value | |----------|-------| | Algorithm | HMAC-SHA-512 | | Input | Raw request body bytes (sorted-key canonical JSON) | | Output | Lowercase hex string | | Header | Read the exact header name from the incoming request or your merchant webhook settings; store it as PAYZUM_SIG_HEADER |

The body payzum transmits is the payment object serialized with alphabetically sorted keys. 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.

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, then verify.

import express from 'express'
import { verifyPaymentIpn } from './verify.js'
 
const app = express()
 
// PAYZUM_SIG_HEADER: the exact header name payzum sends.
// Inspect the incoming request headers or your merchant webhook settings.
const PAYZUM_SIG_HEADER = process.env.PAYZUM_SIG_HEADER
 
app.post(
  '/payzum/ipn',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header(PAYZUM_SIG_HEADER)
    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'))
    // 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

Invoices progress through these statuses, left to right:

waiting → unconfirmed → partially_paid → finished
                                       → expired
                                       → failed
                                       → cancelled

IPNs fire on transitions to partially_paid, finished, expired, and failed. The payment_status field in the payload reflects the new status.

Common pitfalls

  • Re-serializing before verifying. Always HMAC the raw body bytes, not a re-encoded version.
  • Returning 4xx for transient errors. Use 5xx to request a retry; 4xx permanently drops the delivery.
  • Mixing up IPN types. Payment IPNs use HMAC-SHA-512 with a merchant-configured header. Mass-payout webhooks use a different algorithm — see Mass-payout webhooks.