Payzo Docs

Webhook Verification

Secure your webhooks by verifying HMAC signatures

Overview

Webhook signatures ensure that webhook requests are authentic and sent by Payzo, not malicious third parties.

Always verify signatures in production.

How Signatures Work

Payzo uses HMAC-SHA256 to sign webhook payloads:

  1. We create a hash of the JSON payload using your webhook secret
  2. We send this hash in the X-Payzo-Signature header
  3. Your server recreates the hash and compares it
  4. If they match, the webhook is authentic

Quick Implementation

Node.js

const crypto = require('crypto');
 
function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');
 
  return signature === expectedSignature;
}
 
// Usage
app.post('/webhook/payzo', (req, res) => {
  const signature = req.headers['x-payzo-signature'];
  const webhookSecret = process.env.PAYZO_WEBHOOK_SECRET;
 
  if (!verifyWebhookSignature(req.body, signature, webhookSecret)) {
    return res.status(401).send('Invalid signature');
  }
 
  // Signature is valid - process webhook
  res.status(200).json({ received: true });
});

Python

import hmac
import hashlib
import json
 
def verify_webhook_signature(payload, signature, secret):
    payload_string = json.dumps(payload, separators=(',', ':'))
    expected_signature = hmac.new(
        secret.encode(),
        payload_string.encode(),
        hashlib.sha256
    ).hexdigest()
 
    return hmac.compare_digest(signature, expected_signature)
 
# Usage
@app.route('/webhook/payzo', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Payzo-Signature')
    webhook_secret = os.getenv('PAYZO_WEBHOOK_SECRET')
 
    if not verify_webhook_signature(request.json, signature, webhook_secret):
        return jsonify({'error': 'Invalid signature'}), 401
 
    # Signature is valid - process webhook
    return jsonify({'received': True}), 200

PHP

<?php
 
function verifyWebhookSignature($payload, $signature, $secret) {
    $payloadString = json_encode($payload);
    $expectedSignature = hash_hmac('sha256', $payloadString, $secret);
 
    return hash_equals($signature, $expectedSignature);
}
 
// Usage
$signature = $_SERVER['HTTP_X_PAYZO_SIGNATURE'];
$webhookSecret = getenv('PAYZO_WEBHOOK_SECRET');
$payload = json_decode(file_get_contents('php://input'), true);
 
if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
    http_response_code(401);
    die('Invalid signature');
}
 
// Signature is valid - process webhook
http_response_code(200);
echo json_encode(['received' => true]);

Important Notes

Use Raw Payload

You must use the raw JSON payload exactly as received:

//  Good: Use body-parser with raw JSON
app.use(express.json());
 
app.post('/webhook', (req, res) => {
  // req.body is already parsed JSON object
  const signature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(req.body))
    .digest('hex');
});
 
//  Bad: Don't modify the payload
app.post('/webhook', (req, res) => {
  const modifiedPayload = { ...req.body, extra: 'field' };
  // Signature verification will fail!
});

Timing-Safe Comparison

Always use timing-safe comparison to prevent timing attacks:

//  Good: Timing-safe comparison
const crypto = require('crypto');
return crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
);
 
// or use string comparison (slightly less secure but acceptable)
return signature === expectedSignature;
 
//  Bad: Never use == (loose equality)
return signature == expectedSignature;

Complete Example

Node.js / Express (Complete)

const express = require('express');
const crypto = require('crypto');
 
const app = express();
 
// Parse JSON bodies
app.use(express.json());
 
// Webhook secret from environment
const WEBHOOK_SECRET = process.env.PAYZO_WEBHOOK_SECRET;
 
// Verify signature function
function verifySignature(payload, signature) {
  const payloadString = JSON.stringify(payload);
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payloadString)
    .digest('hex');
 
  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
 
// Webhook endpoint
app.post('/webhook/payzo', async (req, res) => {
  const signature = req.headers['x-payzo-signature'];
  const event = req.headers['x-payzo-event'];
 
  console.log('Webhook received:', {
    event,
    payment_id: req.body.payment?.id
  });
 
  // 1. Verify signature
  if (!signature) {
    console.error('Missing signature header');
    return res.status(401).json({ error: 'Missing signature' });
  }
 
  if (!verifySignature(req.body, signature)) {
    console.error('Invalid signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }
 
  // 2. Respond immediately
  res.status(200).json({ received: true });
 
  // 3. Process webhook asynchronously
  try {
    await processWebhook(req.body);
  } catch (error) {
    console.error('Webhook processing error:', error);
    // Don't throw - we already responded to Payzo
  }
});
 
async function processWebhook(data) {
  const { event, payment } = data;
 
  console.log(`Processing ${event} for payment ${payment.id}`);
 
  if (event === 'payment.completed') {
    await handlePaymentCompleted(payment);
  } else if (event === 'payment.failed') {
    await handlePaymentFailed(payment);
  } else if (event === 'payment.refunded') {
    await handlePaymentRefunded(payment);
  }
}
 
async function handlePaymentCompleted(payment) {
  // Check if already processed (idempotency)
  const existing = await db.payments.findOne({
    payzo_payment_id: payment.id
  });
 
  if (existing && existing.status === 'completed') {
    console.log('Payment already processed, skipping');
    return;
  }
 
  // Update database
  await db.orders.update({
    order_id: payment.metadata.order_id,
    status: 'paid',
    payzo_payment_id: payment.id,
    amount: payment.amount,
    completed_at: payment.completed_at
  });
 
  // Deliver product
  await deliverProduct(payment.metadata.order_id);
 
  // Send confirmation email
  await sendEmail(payment.customer_email, 'Payment Confirmed');
 
  console.log('Payment completed:', payment.id);
}
 
app.listen(3000, () => {
  console.log('Server running on port 3000');
  console.log('Webhook endpoint: http://localhost:3000/webhook/payzo');
});

Testing Signature Verification

Manual Test

Create a test payload and signature:

const crypto = require('crypto');
 
const testPayload = {
  event: 'payment.completed',
  payment: {
    id: 'test_123',
    amount: 10.00,
    currency: 'usd'
  }
};
 
const webhookSecret = 'whsec_your_webhook_secret';
const signature = crypto
  .createHmac('sha256', webhookSecret)
  .update(JSON.stringify(testPayload))
  .digest('hex');
 
console.log('Payload:', JSON.stringify(testPayload));
console.log('Signature:', signature);
 
// Send test request
fetch('http://localhost:3000/webhook/payzo', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Payzo-Signature': signature,
    'X-Payzo-Event': 'payment.completed'
  },
  body: JSON.stringify(testPayload)
});

Dashboard Test

Use the "Test Webhook" button in your dashboard to send a real test payload with a valid signature.

Security Best Practices

1. Always Verify Signatures

//  Bad: No verification
app.post('/webhook', (req, res) => {
  processPayment(req.body.payment); // Dangerous!
  res.status(200).send('OK');
});
 
//  Good: Always verify
app.post('/webhook', (req, res) => {
  if (!verifySignature(req.body, req.headers['x-payzo-signature'])) {
    return res.status(401).send('Invalid signature');
  }
  processPayment(req.body.payment);
  res.status(200).send('OK');
});

2. Use Environment Variables

//  Good
const WEBHOOK_SECRET = process.env.PAYZO_WEBHOOK_SECRET;
 
//  Bad: Hardcoded secret
const WEBHOOK_SECRET = 'whsec_abc123'; // Never do this!

3. Implement Rate Limiting

const rateLimit = require('express-rate-limit');
 
const webhookLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Max 100 requests per window
  message: 'Too many webhook requests'
});
 
app.post('/webhook/payzo', webhookLimiter, async (req, res) => {
  // ...
});

4. Log Suspicious Activity

if (!verifySignature(req.body, signature)) {
  // Log potential attack
  console.error('Invalid webhook signature', {
    ip: req.ip,
    timestamp: new Date(),
    payload: req.body
  });
 
  // Optionally alert security team
  await alertSecurity('Invalid webhook signature attempt');
 
  return res.status(401).send('Invalid signature');
}

Troubleshooting

Signature Verification Always Fails

Check:

  1. Using correct webhook secret from dashboard
  2. Comparing signature from X-Payzo-Signature header
  3. Using raw JSON payload (not modified)
  4. Using HMAC-SHA256 algorithm
  5. Comparing hex digest (not base64)

Debug:

console.log('Received signature:', signature);
console.log('Expected signature:', expectedSignature);
console.log('Webhook secret:', WEBHOOK_SECRET.substring(0, 10) + '...');
console.log('Payload:', JSON.stringify(req.body));

Works in Test, Fails in Production

Check:

  1. Using production webhook secret (not test secret)
  2. HTTPS endpoint (required in production)
  3. No proxy modifying headers or payload
  4. Correct Content-Type header (application/json)

Advanced: Custom Verification

If you need custom verification logic:

function advancedVerify(payload, signature, secret) {
  // 1. Basic signature check
  if (!verifySignature(payload, signature, secret)) {
    return false;
  }
 
  // 2. Check timestamp (prevent replay attacks)
  const timestamp = new Date(payload.timestamp);
  const now = new Date();
  const fiveMinutes = 5 * 60 * 1000;
 
  if (now - timestamp > fiveMinutes) {
    console.error('Webhook timestamp too old');
    return false;
  }
 
  // 3. Check event type is valid
  const validEvents = [
    'payment.completed',
    'payment.failed',
    'payment.refunded',
    'payment.expired'
  ];
 
  if (!validEvents.includes(payload.event)) {
    console.error('Invalid event type');
    return false;
  }
 
  return true;
}

Next Steps