Webhook signatures
Every webhook is signed with HMAC-SHA256 over the raw request body using your webhook secret. The signature is sent in the X-ZyndPay-Signature header. Compare using constant-time comparison. Retry: exponential backoff up to 24h.
Choose your language:
Verify X-ZyndPay-Signature
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.ZYNDPAY_WEBHOOK_SECRET!;
// Mount raw-body parser so we can hash the exact bytes ZyndPay signed.
app.post('/webhooks/zyndpay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('x-zyndpay-signature') ?? '';
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event.type (e.g. 'payin.succeeded') ...
res.status(200).send('ok');
});Event catalog
Every event carries a unique id, a type (e.g. payin.succeeded), a delivery attempt count, and an ISO timestamp. Replays are safe β the same id will be redelivered on failure.
| event.type | description |
|---|---|
| payin.succeeded | Customer paid. Amount and tx hash are in the payload. |
| payin.failed | Underlying transfer failed or the paylink expired. |
| payin.expired | Paylink expired before the customer completed payment. |
| payout.succeeded | Payout confirmed on-chain. |
| payout.failed | Payout rejected (insufficient balance, bad address, compliance). |
| withdrawal.succeeded | Merchant-initiated withdrawal confirmed on-chain. |
| withdrawal.failed | Withdrawal rejected by the gateway or compliance. |
| refund.succeeded | Refund sent back to the original payer. |
| subscription.renewed | Recurring subscription charge succeeded. |
| dispute.opened | A customer or chain flagged a payment. See issue #200 workflow. |