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 us and hasn't been tampered with.
How it works
- We compute 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');
// rawBody: the request body exactly as received (string or Buffer)
function verifyWebhookSignature(rawBody, signature, webhookSecret) {
if (!signature) return false;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// timingSafeEqual throws on different lengths, so check first
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express.js: read the RAW body for this route, not express.json()
app.post('/webhooks/zevpay', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyWebhookSignature(
req.body, // Buffer
req.headers['x-zevpay-signature'],
process.env.ZEVPAY_WEBHOOK_SECRET,
);
if (!isValid) return res.status(401).send('Invalid signature');
const { event, data, livemode } = JSON.parse(req.body.toString('utf8'));
console.log(`Received ${event}:`, data.reference, { livemode });
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
});Serverless platforms
The rule is the same everywhere: read the body as text, verify the signature over that text, and only then parse it as JSON. Respond 200 quickly and do slow work afterwards.
// app/api/webhooks/zevpay/route.ts
import crypto from 'node:crypto';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const rawBody = await req.text(); // raw, before any JSON parsing
const signature = req.headers.get('x-zevpay-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.ZEVPAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return new Response('Invalid signature', { status: 401 });
const { event, data, livemode } = JSON.parse(rawBody);
if (event === 'charge.success') {
// fulfil once per data.reference (idempotent); check livemode
}
return new Response('OK');
}// api/webhooks/zevpay.ts
import crypto from 'node:crypto';
import type { VercelRequest, VercelResponse } from '@vercel/node';
// Turn off the automatic body parser so we get the raw body
export const config = { api: { bodyParser: false } };
async function readRaw(req: VercelRequest): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString('utf8');
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const rawBody = await readRaw(req);
const signature = String(req.headers['x-zevpay-signature'] ?? '');
const expected = crypto
.createHmac('sha256', process.env.ZEVPAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
const { event, data, livemode } = JSON.parse(rawBody);
// fulfil once per data.reference when event === 'charge.success'
return res.status(200).send('OK');
}// supabase/functions/zevpay-webhook/index.ts
// Deploy with --no-verify-jwt: ZevPay signs with HMAC, not a Supabase JWT.
const secret = Deno.env.get('ZEVPAY_WEBHOOK_SECRET')!;
async function hmacHex(body: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
Deno.serve(async (req) => {
const rawBody = await req.text();
const signature = req.headers.get('x-zevpay-signature') ?? '';
if (!safeEqual(signature, await hmacHex(rawBody))) {
return new Response('Invalid signature', { status: 401 });
}
const { event, data, livemode } = JSON.parse(rawBody);
if (event === 'charge.success') {
// Use the service-role client here to mark data.merchant_reference paid,
// once per data.reference (for example a unique constraint on it).
}
return new Response('OK');
});Test with your webhook secret
Use your test mode webhook secret during development. Test and live mode use separate secrets.