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.typedescription
payin.succeededCustomer paid. Amount and tx hash are in the payload.
payin.failedUnderlying transfer failed or the paylink expired.
payin.expiredPaylink expired before the customer completed payment.
payout.succeededPayout confirmed on-chain.
payout.failedPayout rejected (insufficient balance, bad address, compliance).
withdrawal.succeededMerchant-initiated withdrawal confirmed on-chain.
withdrawal.failedWithdrawal rejected by the gateway or compliance.
refund.succeededRefund sent back to the original payer.
subscription.renewedRecurring subscription charge succeeded.
dispute.openedA customer or chain flagged a payment. See issue #200 workflow.
Was this helpful?