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');

// 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');
});
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
});

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.

ts
// 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');
}
ts
// 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');
}
ts
// 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.

ZevPay Checkout Developer Documentation