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 Zyndpay-Signature
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.ZYNDPAY_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // reject events older than 5 minutes

// 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 header = req.header('zyndpay-signature') ?? '';
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=', 2) as [string, string]),
  );
  const timestamp = parts.t;
  const received = parts.v1;
  if (!timestamp || !received) return res.status(401).send('malformed signature');

  if (Math.abs(Date.now() / 1000 - Number(timestamp) / 1000) > TOLERANCE_SECONDS) {
    return res.status(401).send('timestamp out of tolerance');
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');

  const a = Buffer.from(received);
  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.event (e.g. 'payin.confirmed') ...
  res.status(200).send('ok');
});

Event catalog

Every webhook is a JSON envelope with three fields — event, data, and createdAt. The event field carries the type (e.g. payin.confirmed). Replays are safe — the same delivery id is retried on failure.

eventdescription
payin.createdPay-in created — TRON address minted, awaiting payment.
payin.confirmingOn-chain transfer seen, waiting for required confirmations.
payin.confirmedCustomer paid and the transfer settled. Amount and tx hash are in data.
payin.expiredAddress expired before the customer completed payment.
payin.failedUnderlying transfer failed or was rejected.
payin.overpaidCustomer paid more than amountRequested.
payin.underpaidCustomer paid less than amountRequested.
deposit.confirmedConsumer wallet top-up confirmed on-chain.
deposit.failedConsumer wallet top-up failed.
deposit.overpaidConsumer top-up exceeded the requested amount.
deposit.underpaidConsumer top-up was below the requested amount.
payout.broadcastPayout signed and broadcast to TRON.
payout.confirmedPayout confirmed on-chain.
payout.failedPayout rejected (insufficient balance, bad address, compliance).
withdrawal.requestedMerchant withdrawal request submitted.
withdrawal.approvedWithdrawal approved by ZyndPay.
withdrawal.broadcastWithdrawal signed and broadcast to TRON.
withdrawal.confirmedWithdrawal confirmed on-chain.
withdrawal.failedWithdrawal rejected by the gateway or compliance.
refund.createdRefund created — pending approval.
refund.approvedRefund approved.
refund.rejectedRefund request rejected.
refund.completedRefund sent back to the original payer.
refund.failedRefund attempt failed.
subscription.createdRecurring subscription created.
subscription.renewal_initiatedRenewal cycle started.
subscription.renewedRecurring subscription charge succeeded.
subscription.failedRenewal charge failed.
subscription.updatedSubscription details changed.
subscription.pausedSubscription paused.
subscription.resumedSubscription resumed.
subscription.cancelledSubscription cancelled.
conversion.confirmedWallet conversion completed.
conversion.failedWallet conversion failed.
dispute.openedA dispute was opened against a payment.
dispute.resolvedA dispute was resolved.
dispute.rejectedA dispute was rejected.
dispute.escalatedA dispute was escalated to a higher level.
aml.flaggedTransaction flagged by AML screening.
splitpayment.createdMarketplace split-payment created.
Was this helpful?