Mass-payout webhooks
Track mass-payout order events.
Mass-payout webhooks notify your endpoint when an order moves through its lifecycle — from creation to settlement or failure. They use a distinct signature scheme from payment IPNs.
Payment IPNs (HMAC-SHA-512, x-nowpayments-sig header) and mass-payout webhooks (HMAC-SHA-256, X-Payzum-Signature) use different signature algorithms and different headers. Do not reuse the same verification function for both — it is a silent security failure.
Signature scheme
| Property | Value |
|----------|-------|
| Algorithm | HMAC-SHA-256 |
| Input | Raw request body bytes (JSON in insertion order — not sorted) |
| Signature header | X-Payzum-Signature (lowercase hex) |
| Secret | Your merchant webhook secret — the same secret that signs payment IPNs |
| Dedup header | X-Payzum-Event-Id — stable across retries |
There is no separate mass-payout signing secret: the HMAC is computed with the same merchant webhook secret used for payment IPNs. The body is JSON.stringify(payload) in insertion order (keys are not sorted), so always verify over the raw bytes you received — never re-serialize.
The X-Payzum-Event-Id header contains an event ID with a pzwe_ prefix that is stable across delivery retries. Store and check this value to implement idempotent handling.
Verify in Node.js
import crypto from 'node:crypto'
export function verifyMassPayoutWebhook(rawBody, sigHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(sigHeader, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// In your handler:
app.post(
'/payzum/mass-payout/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('x-payzum-signature')
const eventId = req.header('x-payzum-event-id')
// PAYZUM_WEBHOOK_SECRET is the same merchant webhook secret used for payment IPNs.
if (!sig || !verifyMassPayoutWebhook(req.body, sig, process.env.PAYZUM_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature')
}
// Deduplicate on eventId before processing
const payload = JSON.parse(req.body.toString('utf8'))
// Anti-replay: payload.eventAt is epoch seconds inside the signed body.
if (Math.abs(Date.now() / 1000 - payload.eventAt) > 600) {
return res.status(401).send('stale event')
}
// payload.eventType, payload.eventId, payload.eventAt, payload.order
res.sendStatus(200)
},
)Common envelope
Every mass-payout event payload contains these top-level fields:
| Field | Type | Description |
|-------|------|-------------|
| eventType | string | One of the event types listed below |
| eventId | string | Stable pzwe_-prefixed ID for deduplication |
| eventAt | number | Unix timestamp (seconds) when the event was emitted — part of the signed body, so use it as your anti-replay window |
| order | object | Order snapshot at the time the event fired |
Event types
| Event type | When it fires |
|------------|---------------|
| mass_payout.created | After POST /:id/confirm — order moves to pending_deposit |
| mass_payout.quote_refreshed | After a successful POST /:id/refresh-quote |
| mass_payout.deposit_detected | payzum receives a deposit callback |
| mass_payout.underfunded | Received amount is less than totalToSendRaw |
| mass_payout.overfunded | Received amount exceeds totalToSendRaw |
| mass_payout.batch_broadcasted | A batch transaction is broadcast on-chain |
| mass_payout.batch_confirmed | A batch reaches the required confirmation threshold |
| mass_payout.batch_failed | A batch fails permanently after exhausting retries |
| mass_payout.completed | All batches confirmed — order is terminal |
| mass_payout.partial_failed | Order ends with at least one failed batch |
| mass_payout.expired | Deposit was not received before expiresAt |
| mass_payout.cancelled | Order was cancelled by the merchant or an operator |
Deduplication
Use the eventId field (or the X-Payzum-Event-Id header) as a dedup key. The same event may be delivered more than once if your endpoint returned a non-2xx response during a previous attempt. Process each eventId at most once in your database or cache before acting on the payload.
Delivery contract
Each event has a 15-second timeout per attempt, retried on 5xx or network error with a 60-second delay, up to 5 attempts. A 4xx response permanently drops the delivery without retry. Events that exhaust all attempts are dead-lettered. See Retries & DLQ for re-enqueue instructions.