Skip to content

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

  1. ZevPay computes an HMAC-SHA256 hash of the request body using your webhook secret
  2. The hash is sent as a hex string in the x-zevpay-signature header
  3. 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

js
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');
});
python
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
<?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';
go
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.

js
// 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.

ZevPay Checkout Developer Documentation