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:
- We create a hash of the JSON payload using your webhook secret
- We send this hash in the
X-Payzo-Signatureheader - Your server recreates the hash and compares it
- 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:
- Using correct webhook secret from dashboard
- Comparing signature from
X-Payzo-Signatureheader - Using raw JSON payload (not modified)
- Using HMAC-SHA256 algorithm
- 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:
- Using production webhook secret (not test secret)
- HTTPS endpoint (required in production)
- No proxy modifying headers or payload
- 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
- Webhook Events - Available webhook events
- Webhooks Overview - Complete webhook guide
- API Reference - API documentation