Verifying Signatures
Every webhook request includes an HMAC-SHA256 signature in the x-zevpay-signature header. Verify this signature to ensure the request is genuinely from ZevPay and hasn't been tampered with.
How it works
- ZevPay computes an HMAC-SHA256 hash of the request body using your webhook secret
- The hash is sent as a hex string in the
x-zevpay-signatureheader - You recompute the hash on your end and compare it to the header value
Your webhook secret
Find your webhook secret in the ZevPay Dashboard under Settings → API Keys. It's shared between the public and secret key in each pair.
Keep it secret
Never expose your webhook secret in client-side code, public repositories, or logs.
Verification examples
const crypto = require('crypto');
function verifyWebhookSignature(req, webhookSecret) {
const signature = req.headers['x-zevpay-signature'];
if (!signature) return false;
const body = JSON.stringify(req.body);
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js example
app.post('/webhooks/zevpay', express.json(), (req, res) => {
const isValid = verifyWebhookSignature(req, process.env.ZEVPAY_WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the event
const { event, data } = req.body;
console.log(`Received ${event}:`, data.reference);
res.status(200).send('OK');
});import hmac
import hashlib
import json
def verify_webhook_signature(payload_body, signature, webhook_secret):
expected = hmac.new(
webhook_secret.encode('utf-8'),
payload_body.encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask example
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/zevpay', methods=['POST'])
def webhook():
signature = request.headers.get('x-zevpay-signature', '')
is_valid = verify_webhook_signature(
request.get_data(as_text=True),
signature,
os.environ['ZEVPAY_WEBHOOK_SECRET'],
)
if not is_valid:
return jsonify({'error': 'Invalid signature'}), 401
payload = request.get_json()
print(f"Received {payload['event']}: {payload['data']['reference']}")
return 'OK', 200<?php
function verifyWebhookSignature($payload, $signature, $webhookSecret) {
$expected = hash_hmac('sha256', $payload, $webhookSecret);
return hash_equals($expected, $signature);
}
// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_ZEVPAY_SIGNATURE'] ?? '';
$webhookSecret = getenv('ZEVPAY_WEBHOOK_SECRET');
if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$event = json_decode($payload, true);
error_log("Received {$event['event']}: {$event['data']['reference']}");
http_response_code(200);
echo 'OK';package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifySignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("x-zevpay-signature")
secret := os.Getenv("ZEVPAY_WEBHOOK_SECRET")
if !verifySignature(body, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process event...
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}Important notes
Use timing-safe comparison
Always use a constant-time comparison function (e.g., crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP) to prevent timing attacks.
Parse the raw body
The signature is computed over the raw JSON string of the request body. If your framework parses the body before you access it, make sure to re-serialize it identically, or access the raw body directly.
// Express.js — ensure raw body is available
app.use('/webhooks/zevpay', express.raw({ type: 'application/json' }));
app.post('/webhooks/zevpay', (req, res) => {
const rawBody = req.body.toString();
// Use rawBody for signature verification
});Test with your webhook secret
Use your test mode webhook secret during development. Test and live mode use separate secrets.