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 us and hasn't been tampered with.

How it works ​

  1. We compute 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