Webhook Security
Webhook security is critical to ensure that requests are actually from ZitoPay and haven't been tampered with. This page explains how to verify webhook signatures using HMAC-SHA256.
Critical: Always verify webhook signatures before processing any webhook payload. Never trust webhook data without signature verification, as it could be a malicious request attempting to manipulate your system.
How Signature Verification Works
ZitoPay uses HMAC-SHA256 to sign webhook payloads. The signature is sent in the X-Zito-Signature header, and you verify it by recreating the signature using your webhook secret.
- ZitoPay creates a signature using HMAC-SHA256 with your webhook secret
- The signature is sent in the
X-Zito-Signatureheader - You recreate the signature using your stored secret
- Compare the signatures - if they match, the webhook is authentic
Headers Sent by ZitoPay
Every webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
X-Zito-Event | The event type | payment.succeeded |
X-Zito-Delivery-Id | Unique delivery ID (for idempotency) | delivery-uuid-123 |
X-Zito-Timestamp | Timestamp in milliseconds | 1769164241000 |
X-Zito-Signature | HMAC-SHA256 signature (hex only, no prefix) | a1b2c3d4e5f6... |
X-Zito-Replay | "true" if replayed (optional) | true |
Content-Type | Always application/json | application/json |
Critical: Signature Format
- NO prefix: The signature header contains ONLY the hexadecimal hash (no
sha256=prefix) - Timestamp in milliseconds: The timestamp is in milliseconds, not seconds
- Raw body required: You must use the raw request body (before JSON parsing) for signature verification
Verification Steps
Step 1: Extract Headers
1const signature = req.headers['x-zito-signature'];
2const timestamp = req.headers['x-zito-timestamp'];
3
4if (!signature || !timestamp) {
5 return res.status(400).send('Missing signature headers');
6}Step 2: Check Timestamp (Prevent Replay Attacks)
Verify that the request is recent (within 5 minutes) to prevent replay attacks. Important: The timestamp is in milliseconds, not seconds.
1const timestamp = parseInt(req.headers['x-zito-timestamp']);
2const now = Date.now(); // Current time in milliseconds
3const age = Math.abs(now - timestamp);
4
5// Reject if older than 5 minutes (300,000 milliseconds)
6if (age > 5 * 60 * 1000) {
7 return res.status(400).send('Request too old');
8}Step 3: Get Raw Request Body
Critical: You must use the raw request body (as string, before JSON parsing) for signature verification. The body must be exactly as received from ZitoPay.
Common Mistake: Using JSON.stringify(req.body) will fail because the JSON formatting may differ. You must capture the raw body before any parsing occurs.
Step 4: Compute Expected Signature
The signature is computed as: HMAC-SHA256(secret, timestamp + "." + payload)
1const crypto = require('crypto');
2
3// Get raw body (must be captured before JSON parsing)
4const payload = req.body.toString(); // Raw body as string
5const timestamp = req.headers['x-zito-timestamp']; // Already a string
6
7// Construct string to sign: timestamp + "." + payload
8const stringToSign = `${timestamp}.${payload}`;
9
10// Compute expected signature
11const expectedSignature = crypto
12 .createHmac('sha256', WEBHOOK_SECRET)
13 .update(stringToSign)
14 .digest('hex');Step 5: Compare Signatures (Timing-Safe)
Use timing-safe comparison to prevent timing attacks. Important: The signature header contains only the hex string (no prefix).
1const signature = req.headers['x-zito-signature']; // Just hex, no prefix
2
3// Constant-time comparison
4const isValid = crypto.timingSafeEqual(
5 Buffer.from(expectedSignature),
6 Buffer.from(signature)
7);
8
9if (!isValid) {
10 return res.status(401).send('Invalid signature');
11}Complete Example (Node.js/Express)
Here's a complete webhook handler with signature verification, idempotency, and proper error handling:
1const express = require('express');
2const crypto = require('crypto');
3const app = express();
4
5// Your webhook secret (from ZitoPay dashboard)
6const WEBHOOK_SECRET = process.env.ZITOPAY_WEBHOOK_SECRET;
7
8// Middleware to capture raw body for signature verification
9// IMPORTANT: Must capture raw body BEFORE JSON parsing
10app.use('/webhooks/zitopay', express.raw({ type: 'application/json' }));
11
12// Track processed deliveries (use Redis or database in production)
13const processedDeliveries = new Set();
14
15function verifyWebhookSignature(req, secret) {
16 const signature = req.headers['x-zito-signature'];
17 const timestamp = req.headers['x-zito-timestamp'];
18 const payload = req.body.toString(); // Raw body as string
19
20 // Validate timestamp (prevent replay attacks)
21 const now = Date.now(); // Milliseconds
22 const age = Math.abs(now - parseInt(timestamp));
23 if (age > 5 * 60 * 1000) { // 5 minutes
24 return false;
25 }
26
27 // Compute expected signature
28 const stringToSign = `${timestamp}.${payload}`;
29 const expectedSignature = crypto
30 .createHmac('sha256', secret)
31 .update(stringToSign)
32 .digest('hex');
33
34 // Constant-time comparison
35 return crypto.timingSafeEqual(
36 Buffer.from(signature),
37 Buffer.from(expectedSignature)
38 );
39}
40
41app.post('/webhooks/zitopay', async (req, res) => {
42 try {
43 // Verify signature
44 if (!verifyWebhookSignature(req, WEBHOOK_SECRET)) {
45 console.error('Invalid webhook signature');
46 return res.status(401).json({ error: 'Invalid signature' });
47 }
48
49 // Parse JSON payload
50 const payload = JSON.parse(req.body.toString());
51 const deliveryId = req.headers['x-zito-delivery-id'];
52 const event = req.headers['x-zito-event'];
53
54 // Check idempotency
55 if (processedDeliveries.has(deliveryId)) {
56 console.log('Webhook already processed:', deliveryId);
57 return res.status(200).json({ message: 'Already processed' });
58 }
59
60 // Log webhook
61 console.log('Webhook received:', {
62 deliveryId,
63 event,
64 timestamp: new Date().toISOString()
65 });
66
67 // Process event asynchronously
68 processWebhookEvent(payload, event).catch(err => {
69 console.error('Error processing webhook:', err);
70 });
71
72 // Mark as processed
73 processedDeliveries.add(deliveryId);
74
75 // Respond immediately (within 30 seconds)
76 res.status(200).json({ received: true });
77 } catch (error) {
78 console.error('Webhook error:', error);
79 res.status(500).json({ error: 'Processing error' });
80 }
81});
82
83async function processWebhookEvent(payload, event) {
84 switch (event) {
85 case 'payment.succeeded':
86 await handlePaymentSucceeded(payload.data);
87 break;
88 case 'payment.failed':
89 await handlePaymentFailed(payload.data);
90 break;
91 case 'payout.completed':
92 await handlePayoutCompleted(payload.data);
93 break;
94 case 'payout.failed':
95 await handlePayoutFailed(payload.data);
96 break;
97 case 'refund.completed':
98 await handleRefundCompleted(payload.data);
99 break;
100 case 'settlement.generated':
101 await handleSettlementGenerated(payload.data);
102 break;
103 default:
104 console.warn('Unknown event type:', event);
105 }
106}
107
108async function handlePaymentSucceeded(data) {
109 console.log('Payment succeeded:', data.transaction_id);
110 // Update your database, send notification, etc.
111}
112
113app.listen(3000, () => {
114 console.log('Webhook server listening on port 3000');
115});Python Example
1from flask import Flask, request, jsonify
2import hmac
3import hashlib
4import time
5import json
6import os
7
8app = Flask(__name__)
9
10# Your webhook secret (from ZitoPay dashboard)
11WEBHOOK_SECRET = os.environ.get('ZITOPAY_WEBHOOK_SECRET')
12
13# Track processed deliveries (use Redis in production)
14processed_deliveries = set()
15
16def verify_webhook_signature(request, secret):
17 """Verify webhook signature"""
18 signature = request.headers.get('X-Zito-Signature')
19 timestamp = request.headers.get('X-Zito-Timestamp')
20 payload = request.get_data(as_text=True) # Raw body as string
21
22 # Validate timestamp (prevent replay attacks)
23 now = int(time.time() * 1000) # Milliseconds
24 age = abs(now - int(timestamp))
25 if age > 5 * 60 * 1000: # 5 minutes
26 return False
27
28 # Compute expected signature
29 string_to_sign = f"{timestamp}.{payload}"
30 expected_signature = hmac.new(
31 secret.encode('utf-8'),
32 string_to_sign.encode('utf-8'),
33 hashlib.sha256
34 ).hexdigest()
35
36 # Constant-time comparison
37 return hmac.compare_digest(signature, expected_signature)
38
39@app.route('/webhooks/zitopay', methods=['POST'])
40def webhook_handler():
41 try:
42 # Verify signature
43 if not verify_webhook_signature(request, WEBHOOK_SECRET):
44 return jsonify({'error': 'Invalid signature'}), 401
45
46 # Parse payload
47 payload = request.get_json()
48 delivery_id = request.headers.get('X-Zito-Delivery-Id')
49 event = request.headers.get('X-Zito-Event')
50
51 # Check idempotency
52 if delivery_id in processed_deliveries:
53 return jsonify({'message': 'Already processed'}), 200
54
55 # Log webhook
56 print(f'Webhook received: {delivery_id}, Event: {event}')
57
58 # Process event asynchronously (use background task queue in production)
59 process_webhook_event(payload, event)
60
61 # Mark as processed
62 processed_deliveries.add(delivery_id)
63
64 # Respond immediately
65 return jsonify({'received': True}), 200
66 except Exception as e:
67 print(f'Webhook error: {e}')
68 return jsonify({'error': 'Processing error'}), 500
69
70def process_webhook_event(payload, event):
71 """Process webhook event"""
72 data = payload.get('data', {})
73
74 if event == 'payment.succeeded':
75 handle_payment_succeeded(data)
76 elif event == 'payment.failed':
77 handle_payment_failed(data)
78 # ... handle other events
79
80def handle_payment_succeeded(data):
81 print(f'Payment succeeded: {data.get("transaction_id")}')
82 # Update your database, send notification, etc.
83
84if __name__ == '__main__':
85 app.run(port=3000)PHP Example
1<?php
2
3// Your webhook secret (from ZitoPay dashboard)
4define('WEBHOOK_SECRET', getenv('ZITOPAY_WEBHOOK_SECRET'));
5
6// Track processed deliveries (use Redis or database in production)
7$processedDeliveries = [];
8
9function verifyWebhookSignature($headers, $payload, $secret) {
10 $signature = $headers['X-Zito-Signature'] ?? '';
11 $timestamp = $headers['X-Zito-Timestamp'] ?? '';
12
13 // Validate timestamp (prevent replay attacks)
14 $now = round(microtime(true) * 1000); // Milliseconds
15 $age = abs($now - (int)$timestamp);
16 if ($age > 5 * 60 * 1000) { // 5 minutes
17 return false;
18 }
19
20 // Compute expected signature
21 $stringToSign = $timestamp . '.' . $payload;
22 $expectedSignature = hash_hmac('sha256', $stringToSign, $secret);
23
24 // Constant-time comparison
25 return hash_equals($expectedSignature, $signature);
26}
27
28// Get raw request body
29$payload = file_get_contents('php://input');
30$headers = getallheaders();
31$deliveryId = $headers['X-Zito-Delivery-Id'] ?? '';
32$event = $headers['X-Zito-Event'] ?? '';
33
34// Verify signature
35if (!verifyWebhookSignature($headers, $payload, WEBHOOK_SECRET)) {
36 http_response_code(401);
37 echo json_encode(['error' => 'Invalid signature']);
38 exit;
39}
40
41// Check idempotency
42if (in_array($deliveryId, $processedDeliveries)) {
43 http_response_code(200);
44 echo json_encode(['message' => 'Already processed']);
45 exit;
46}
47
48// Parse JSON payload
49$data = json_decode($payload, true);
50
51// Log webhook
52error_log("Webhook received: $deliveryId, Event: $event");
53
54// Process event
55processWebhookEvent($data, $event);
56
57// Mark as processed
58$processedDeliveries[] = $deliveryId;
59
60// Respond
61http_response_code(200);
62echo json_encode(['received' => true]);
63
64function processWebhookEvent($payload, $event) {
65 $data = $payload['data'] ?? [];
66
67 switch ($event) {
68 case 'payment.succeeded':
69 handlePaymentSucceeded($data);
70 break;
71 case 'payment.failed':
72 handlePaymentFailed($data);
73 break;
74 // ... handle other events
75 }
76}
77
78function handlePaymentSucceeded($data) {
79 error_log('Payment succeeded: ' . $data['transaction_id']);
80 // Update your database, send notification, etc.
81}
82?>Common Mistakes and How to Fix Them
1. Not Verifying Signatures
Never skip signature verification. Always verify the signature before processing webhook data. This is the most critical security step.
1// ❌ WRONG - No signature verification
2app.post('/webhooks/zitopay', (req, res) => {
3 const event = req.body.event;
4 processEvent(event); // DANGEROUS!
5 res.sendStatus(200);
6});
7
8// ✅ CORRECT - Verify signature first
9app.post('/webhooks/zitopay', (req, res) => {
10 if (!verifySignature(req, WEBHOOK_SECRET)) {
11 return res.status(401).send('Invalid signature');
12 }
13 const event = req.body.event;
14 processEvent(event);
15 res.sendStatus(200);
16});2. Using Parsed Body Instead of Raw Body
Critical mistake: Using JSON.stringify(req.body) will fail because the JSON formatting may differ. You must use the raw request body.
1// ❌ WRONG - Using parsed body
2const payload = JSON.stringify(req.body);
3const stringToSign = `${timestamp}.${payload}`;
4
5// ✅ CORRECT - Using raw body
6app.use('/webhooks/zitopay', express.raw({ type: 'application/json' }));
7const payload = req.body.toString(); // Raw body as string
8const stringToSign = `${timestamp}.${payload}`;3. Using String Comparison Instead of Timing-Safe
Don't use regular string comparison. Use timing-safe comparison functions to prevent timing attacks:
1// ❌ WRONG - Vulnerable to timing attacks
2if (expectedSignature === receivedSignature) { ... }
3
4// ✅ CORRECT - Timing-safe comparison
5if (crypto.timingSafeEqual(
6 Buffer.from(expectedSignature),
7 Buffer.from(receivedSignature)
8)) { ... }4. Wrong Timestamp Format
Critical: The timestamp is in milliseconds, not seconds. Using seconds will cause signature verification to fail.
1// ❌ WRONG - Using seconds
2const currentTime = Math.floor(Date.now() / 1000);
3const requestTime = parseInt(timestamp);
4
5// ✅ CORRECT - Using milliseconds
6const now = Date.now(); // Milliseconds
7const requestTime = parseInt(timestamp); // Already in milliseconds5. Adding Prefix to Signature
Critical: The signature header contains only the hex string. Do NOT add or expect a sha256= prefix.
1// ❌ WRONG - Removing non-existent prefix
2const receivedSignature = signature.replace('sha256=', '');
3
4// ✅ CORRECT - Signature is already just hex
5const signature = req.headers['x-zito-signature']; // Just hex, no prefix6. Not Implementing Idempotency
Webhooks may be delivered multiple times. Use X-Zito-Delivery-Id to prevent duplicate processing.
1// ❌ WRONG - No idempotency check
2app.post('/webhooks/zitopay', (req, res) => {
3 processEvent(req.body); // May process same event multiple times
4 res.sendStatus(200);
5});
6
7// ✅ CORRECT - Check delivery ID
8const deliveryId = req.headers['x-zito-delivery-id'];
9if (processedDeliveries.has(deliveryId)) {
10 return res.status(200).send({ message: 'Already processed' });
11}
12processedDeliveries.add(deliveryId);
13processEvent(req.body);
14res.sendStatus(200);7. Storing Secret in Code
Never commit secrets to code. Store your webhook secret in environment variables or a secret management system.
Security Best Practices
- Always verify signatures: Never process webhooks without verification - this is critical
- Use raw body for signature: Capture the raw request body before JSON parsing
- Use timing-safe comparison: Prevent timing attacks with constant-time comparison
- Check timestamps: Reject requests older than 5 minutes (replay attack prevention)
- Implement idempotency: Use X-Zito-Delivery-Id to prevent duplicate processing
- Store secrets securely: Use environment variables or secret management - never in code
- Use HTTPS: Always use HTTPS for webhook endpoints in production
- Respond quickly: Return 200 OK within 30 seconds, process events asynchronously
- Log everything: Log all webhook deliveries for debugging and auditing
- Handle errors gracefully: Don't let processing errors crash your server
Troubleshooting
Issue: "Invalid signature" errors
Problem: Signature verification fails even with correct secret
Solutions:
- Ensure you're using the raw request body (before JSON parsing)
- Verify timestamp is in milliseconds (not seconds)
- Check that you're concatenating
timestamp + "." + payloadcorrectly - Ensure signature header contains only hex (no
sha256=prefix) - Verify you're using the correct webhook secret
Issue: Webhooks not being received
Problem: No webhooks are reaching your endpoint
Solutions:
- Verify your endpoint is publicly accessible (test with curl/Postman)
- Ensure your endpoint uses HTTPS (HTTP is not allowed in production)
- Check firewall/security group settings
- Verify the endpoint URL is correct in ZitoPay dashboard
- Check that the endpoint is enabled
Issue: Duplicate webhook deliveries
Problem: Same webhook is processed multiple times
Solutions:
- Implement idempotency using
X-Zito-Delivery-Idheader - Store processed delivery IDs in a database or Redis
- Check for duplicates before processing
Issue: Timeout errors
Problem: Webhook requests timeout
Solutions:
- Respond to webhooks within 30 seconds
- Process events asynchronously (don't block the response)
- Use background job queues for heavy processing
Testing Signature Verification
You can test your signature verification with this example:
1// Test signature generation
2const crypto = require('crypto');
3const secret = 'whsec_test123';
4const timestamp = '1768763180';
5const payload = JSON.stringify({
6 event: 'payment.succeeded',
7 data: { transaction_id: 'test-123' }
8});
9
10const signedPayload = `${timestamp}.${payload}`;
11const signature = crypto
12 .createHmac('sha256', secret)
13 .update(signedPayload)
14 .digest('hex');
15
16console.log('X-Zito-Signature: sha256=' + signature);
17console.log('X-Zito-Timestamp: ' + timestamp);Idempotency Implementation
Webhooks may be delivered multiple times. Always implement idempotency using the X-Zito-Delivery-Id header to prevent duplicate processing:
1// Track processed deliveries (use Redis or database in production)
2const processedDeliveries = new Set();
3
4app.post('/webhooks/zitopay', async (req, res) => {
5 // Verify signature first
6 if (!verifySignature(req, WEBHOOK_SECRET)) {
7 return res.status(401).send('Invalid signature');
8 }
9
10 const deliveryId = req.headers['x-zito-delivery-id'];
11
12 // Check if already processed
13 if (processedDeliveries.has(deliveryId)) {
14 console.log('Webhook already processed:', deliveryId);
15 return res.status(200).json({ message: 'Already processed' });
16 }
17
18 // Process event
19 await processEvent(req.body);
20
21 // Mark as processed
22 processedDeliveries.add(deliveryId);
23
24 res.status(200).json({ received: true });
25});Production Tip: In production, use Redis or a database to track processed delivery IDs instead of an in-memory Set. This ensures idempotency works across server restarts and multiple server instances.
Response Time Requirements
Your endpoint must respond with HTTP 200 within 30 seconds. If you need more time to process the event, respond immediately and process asynchronously:
1// ✅ CORRECT - Respond immediately, process async
2app.post('/webhooks/zitopay', (req, res) => {
3 // Verify signature
4 if (!verifySignature(req, WEBHOOK_SECRET)) {
5 return res.status(401).send('Invalid signature');
6 }
7
8 // Respond immediately
9 res.sendStatus(200);
10
11 // Process event asynchronously
12 processEventAsync(req.body).catch(err => {
13 console.error('Error processing webhook:', err);
14 });
15});Monitoring Webhook Activity
After implementing webhook signature verification, you should monitor webhook activity to ensure everything is working correctly:
What to Monitor
- Webhook Receipts: All incoming webhooks should be logged
- Signature Verification: Track invalid signature attempts (security concern)
- Processing Status: Monitor success vs error rates
- Duplicate Deliveries: Track how often duplicate webhooks are received
- Response Times: Ensure responses are within 30 seconds
Status Indicators
Your webhook handler should track and display these statuses:
| Status | Description | Action |
|---|---|---|
| SUCCESS | Webhook processed successfully | No action needed |
| ERROR | Error during processing | Check error logs, fix issue |
| INVALID_SIGNATURE | Signature verification failed | Check webhook secret, verify implementation |
| DUPLICATE | Already processed (idempotency) | No action needed (expected behavior) |
Logging Best Practices
- Log all webhook receipts: Include delivery ID, event type, timestamp
- Log verification failures: Track invalid signatures for security monitoring
- Log processing errors: Include full error context for debugging
- Store payloads: Keep payload data for troubleshooting (sanitize sensitive data)
- Set up alerts: Alert on high error rates or security issues
Production Considerations
When deploying webhooks to production, consider these important factors:
Storage
- Idempotency: Use Redis or database for delivery ID tracking (not in-memory)
- Webhook Logs: Store logs in database or logging service (not in-memory)
- Persistence: Ensure data survives server restarts
Performance
- Async Processing: Always process events asynchronously
- Background Jobs: Use job queues (Bull, BullMQ) for heavy processing
- Response Time: Respond within 30 seconds, don't block on processing
Reliability
- Error Handling: Implement retry mechanisms for failed processing
- Monitoring: Set up error tracking (Sentry, etc.)
- Alerts: Monitor webhook delivery success rates
- Database Integration: Update transaction/refund status in database
Security
- Secret Management: Use secure secret storage (AWS Secrets Manager, etc.)
- Access Control: Restrict access to webhook endpoint logs
- Rate Limiting: Implement rate limiting to prevent abuse
- Audit Logging: Log all webhook activity for compliance
Next Steps
- Learn how to Register a Webhook Endpoint
- See all available Webhook Events
- View Webhooks Overview for general information