Skip to content

Signature Verification

Webhook signature verification is crucial for security. This guide shows you how to verify that webhooks are actually coming from Zelta Pay and haven’t been tampered with.

Signature verification ensures:

  • Authenticity: The webhook is from Zelta Pay
  • Integrity: The payload hasn’t been modified
  • Security: Prevents replay attacks and spoofing

Zelta Pay uses HMAC-SHA256 to sign webhook payloads:

  1. Create a signature using your webhook secret
  2. Include timestamp to prevent replay attacks
  3. Send signature in the Zeltapay-Signature header
  4. You verify the signature matches the payload

The signature header contains:

Zeltapay-Signature: t=1640995200, v1=abc123def456...

Where:

  • t: Unix timestamp when the webhook was sent
  • v1: HMAC-SHA256 signature (64 hex characters)
const signatureHeader = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
// Extract timestamp and signature
const match = /t=(\d+),\s*v1=([a-f0-9]{64})/i.exec(signatureHeader);
if (!match) {
throw new Error('Invalid signature format');
}
const [, t, v1] = match;
function verifyTimestamp(timestamp, tolerance = 300) {
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
const timeDiff = Math.abs(now - webhookTime);
return timeDiff <= tolerance;
}
if (!verifyTimestamp(timestamp)) {
throw new Error('Webhook timestamp too old');
}
function createExpectedSignature(rawBody, timestamp, secret) {
const input = `t=${timestamp}.${rawBody}`;
const signature = crypto
.createHmac('sha256', secret)
.update(input)
.digest('hex');
return signature;
}
const expected = createExpectedSignature(rawBody, timestamp, secret);
function compareSignatures(expected, received) {
const expectedBuffer = Buffer.from(expected, 'hex');
const receivedBuffer = Buffer.from(received, 'hex');
return expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
if (!compareSignatures(expected, v1)) {
throw new Error('Invalid signature');
}
const crypto = require('crypto');
function verifyZeltapaySignature({
rawBody,
timestamp,
signatureHeader,
secret,
}) {
// Extract timestamp and signature
const match = /t=(\d+),\s*v1=([a-f0-9]{64})/i.exec(signatureHeader);
if (!match) {
return false;
}
const [, t, v1] = match;
// Verify timestamp matches
if (t !== timestamp) {
return false;
}
// Create expected signature
const input = `t=${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(input)
.digest('hex');
// Compare signatures using timing-safe comparison
const expectedBuffer = Buffer.from(expected, 'hex');
const receivedBuffer = Buffer.from(v1, 'hex');
return expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
// Usage in Express
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifyZeltapaySignature({
rawBody: req.body.toString(),
timestamp,
signatureHeader: signature,
secret
})) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature is valid, process webhook
const payload = JSON.parse(req.body);
processWebhook(payload);
res.status(200).json({ received: true });
});
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
try {
// Get headers
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
// Get raw body
const rawBody = await request.text();
// Verify signature
if (!await verifyZeltapaySignature(rawBody, timestamp, signature, env.WEBHOOK_SECRET)) {
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Parse payload
const payload = JSON.parse(rawBody);
// Process webhook
await processWebhook(payload);
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Webhook processing error:', error);
return new Response(JSON.stringify({ error: 'Processing failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
async function verifyZeltapaySignature(rawBody, timestamp, signatureHeader, secret) {
// Extract timestamp and signature
const match = /t=(\d+),\s*v1=([a-f0-9]{64})/i.exec(signatureHeader);
if (!match) {
return false;
}
const [, t, v1] = match;
// Verify timestamp matches
if (t !== timestamp) {
return false;
}
// Create expected signature using Web Crypto API
const input = `t=${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input));
const expected = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Compare signatures using timing-safe comparison
return expected === v1;
}
async function processWebhook(payload) {
// Process the webhook event
console.log('Processing webhook:', payload.type);
}

Hono with Cloudflare Workers Implementation

Section titled “Hono with Cloudflare Workers Implementation”
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Bindings = {
WEBHOOK_SECRET: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Enable CORS
app.use('*', cors());
app.post('/webhook', async (c) => {
try {
// Get headers
const signature = c.req.header('zeltapay-signature');
const timestamp = c.req.header('zeltapay-timestamp');
if (!signature || !timestamp) {
return c.json({ error: 'Missing required headers' }, 400);
}
// Get raw body
const rawBody = await c.req.text();
// Verify signature
if (!await verifyZeltapaySignature(rawBody, timestamp, signature, c.env.WEBHOOK_SECRET)) {
return c.json({ error: 'Invalid signature' }, 401);
}
// Parse payload
const payload = JSON.parse(rawBody);
// Process webhook
await processWebhook(payload);
return c.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return c.json({ error: 'Processing failed' }, 500);
}
});
async function verifyZeltapaySignature(rawBody: string, timestamp: string, signatureHeader: string, secret: string): Promise<boolean> {
// Extract timestamp and signature
const match = /t=(\d+),\s*v1=([a-f0-9]{64})/i.exec(signatureHeader);
if (!match) {
return false;
}
const [, t, v1] = match;
// Verify timestamp matches
if (t !== timestamp) {
return false;
}
// Create expected signature using Web Crypto API
const input = `t=${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input));
const expected = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Compare signatures using timing-safe comparison
return expected === v1;
}
async function processWebhook(payload: any) {
// Process the webhook event
console.log('Processing webhook:', payload.type);
}
export default app;
import hmac
import hashlib
import re
import time
def verify_zeltapay_signature(raw_body, timestamp, signature_header, secret):
"""Verify Zelta Pay webhook signature"""
# Extract timestamp and signature
match = re.search(r't=(\d+),\s*v1=([a-f0-9]{64})', signature_header, re.IGNORECASE)
if not match:
return False
t, v1 = match.groups()
# Verify timestamp matches
if t != timestamp:
return False
# Create expected signature
input_string = f"t={timestamp}.{raw_body}"
expected = hmac.new(
secret.encode('utf-8'),
input_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare signatures using timing-safe comparison
return hmac.compare_digest(expected, v1)
# Usage in Flask
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('Zeltapay-Signature')
timestamp = request.headers.get('Zeltapay-Timestamp')
secret = os.getenv('WEBHOOK_SECRET')
# Get raw body
raw_body = request.get_data(as_text=True)
if not verify_zeltapay_signature(raw_body, timestamp, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401
# Signature is valid, process webhook
payload = request.get_json()
process_webhook(payload)
return jsonify({'received': True}), 200
<?php
function verifyZeltapaySignature($rawBody, $timestamp, $signatureHeader, $secret) {
// Extract timestamp and signature
if (!preg_match('/t=(\d+),\s*v1=([a-f0-9]{64})/i', $signatureHeader, $matches)) {
return false;
}
$t = $matches[1];
$v1 = $matches[2];
// Verify timestamp matches
if ($t !== $timestamp) {
return false;
}
// Create expected signature
$input = "t={$timestamp}.{$rawBody}";
$expected = hash_hmac('sha256', $input, $secret);
// Compare signatures using timing-safe comparison
return hash_equals($expected, $v1);
}
// Usage
$signature = $_SERVER['HTTP_ZELTAPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_ZELTAPAY_TIMESTAMP'] ?? '';
$secret = $_ENV['WEBHOOK_SECRET'] ?? '';
// Get raw body
$rawBody = file_get_contents('php://input');
if (!verifyZeltapaySignature($rawBody, $timestamp, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Signature is valid, process webhook
$payload = json_decode($rawBody, true);
processWebhook($payload);
http_response_code(200);
echo json_encode(['received' => true]);
?>
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strings"
)
func verifyZeltapaySignature(rawBody, timestamp, signatureHeader, secret string) bool {
// Extract timestamp and signature
re := regexp.MustCompile(`t=(\d+),\s*v1=([a-f0-9]{64})`)
matches := re.FindStringSubmatch(signatureHeader)
if len(matches) != 3 {
return false
}
t := matches[1]
v1 := matches[2]
// Verify timestamp matches
if t != timestamp {
return false
}
// Create expected signature
input := fmt.Sprintf("t=%s.%s", timestamp, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(input))
expected := hex.EncodeToString(mac.Sum(nil))
// Compare signatures using timing-safe comparison
return hmac.Equal([]byte(expected), []byte(v1))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("Zeltapay-Signature")
timestamp := r.Header.Get("Zeltapay-Timestamp")
secret := os.Getenv("WEBHOOK_SECRET")
// Read raw body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
rawBody := string(body)
if !verifyZeltapaySignature(rawBody, timestamp, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature is valid, process webhook
var payload map[string]interface{}
json.Unmarshal(body, &payload)
processWebhook(payload)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

Timestamps prevent replay attacks by ensuring webhooks are recent:

function validateTimestamp(timestamp) {
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
const timeDiff = Math.abs(now - webhookTime);
// Reject webhooks older than 5 minutes
return timeDiff <= 300;
}
function verifyWebhook(req, secret) {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const rawBody = req.body.toString();
// Validate timestamp (5 minutes tolerance)
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
if (Math.abs(now - webhookTime) > 300) {
throw new Error('Webhook timestamp too old');
}
// Verify signature
if (!verifyZeltapaySignature({
rawBody,
timestamp,
signatureHeader: signature,
secret
})) {
throw new Error('Invalid signature');
}
return true;
}
async function verifyWebhook(request, secret) {
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
const rawBody = await request.text();
// Validate timestamp (5 minutes tolerance)
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
if (Math.abs(now - webhookTime) > 300) {
throw new Error('Webhook timestamp too old');
}
// Verify signature
if (!await verifyZeltapaySignature(rawBody, timestamp, signature, secret)) {
throw new Error('Invalid signature');
}
return true;
}
async function verifyWebhook(c: Context, secret: string) {
const signature = c.req.header('zeltapay-signature');
const timestamp = c.req.header('zeltapay-timestamp');
const rawBody = await c.req.text();
if (!signature || !timestamp) {
throw new Error('Missing required headers');
}
// Validate timestamp (5 minutes tolerance)
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp);
if (Math.abs(now - webhookTime) > 300) {
throw new Error('Webhook timestamp too old');
}
// Verify signature
if (!await verifyZeltapaySignature(rawBody, timestamp, signature, secret)) {
throw new Error('Invalid signature');
}
return true;
}
import time
def verify_webhook(request, secret):
signature = request.headers.get('Zeltapay-Signature')
timestamp = request.headers.get('Zeltapay-Timestamp')
raw_body = request.get_data(as_text=True)
# Validate timestamp (5 minutes tolerance)
now = int(time.time())
webhook_time = int(timestamp)
if abs(now - webhook_time) > 300:
raise ValueError('Webhook timestamp too old')
# Verify signature
if not verify_zeltapay_signature(raw_body, timestamp, signature, secret):
raise ValueError('Invalid signature')
return True
function handleVerificationError(error) {
switch (error.message) {
case 'Invalid signature format':
return { status: 400, message: 'Malformed signature header' };
case 'Invalid signature':
return { status: 401, message: 'Signature verification failed' };
case 'Webhook timestamp too old':
return { status: 400, message: 'Webhook timestamp expired' };
default:
return { status: 500, message: 'Verification error' };
}
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
throw new Error('Webhook secret not configured');
}
verifyWebhook(req, secret);
// Process webhook
const payload = JSON.parse(req.body);
processWebhook(payload);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error);
const errorResponse = handleVerificationError(error);
res.status(errorResponse.status).json({
error: errorResponse.message
});
}
});
export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
try {
const secret = env.WEBHOOK_SECRET;
if (!secret) {
throw new Error('Webhook secret not configured');
}
await verifyWebhook(request, secret);
// Process webhook
const payload = await request.json();
await processWebhook(payload);
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error('Webhook verification failed:', error);
const errorResponse = handleVerificationError(error);
return new Response(JSON.stringify({
error: errorResponse.message
}), {
status: errorResponse.status,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
app.post('/webhook', async (c) => {
try {
const secret = c.env.WEBHOOK_SECRET;
if (!secret) {
throw new Error('Webhook secret not configured');
}
await verifyWebhook(c, secret);
// Process webhook
const payload = await c.req.json();
await processWebhook(payload);
return c.json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error);
const errorResponse = handleVerificationError(error);
return c.json({
error: errorResponse.message
}, errorResponse.status);
}
});
@app.route('/webhook', methods=['POST'])
def webhook():
try:
secret = os.getenv('WEBHOOK_SECRET')
if not secret:
raise ValueError('Webhook secret not configured')
verify_webhook(request, secret)
# Process webhook
payload = request.get_json()
process_webhook(payload)
return jsonify({'received': True}), 200
except Exception as error:
print(f'Webhook verification failed: {error}')
error_response = handle_verification_error(error)
return jsonify({'error': error_response['message']}), error_response['status']

Use the dashboard’s webhook ping feature to test your verification:

  1. Set up your webhook endpoint with signature verification
  2. Go to Settings → Webhooks in the dashboard
  3. Click “Test” next to your webhook
  4. Check your server logs for verification results

Create a test script to verify your implementation:

const crypto = require('crypto');
function testSignatureVerification() {
const secret = 'test-secret';
const payload = JSON.stringify({ test: 'data' });
const timestamp = Math.floor(Date.now() / 1000).toString();
// Create signature
const input = `t=${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(input)
.digest('hex');
const signatureHeader = `t=${timestamp}, v1=${signature}`;
// Test verification
const isValid = verifyZeltapaySignature({
rawBody: payload,
timestamp,
signatureHeader,
secret
});
console.log('Signature verification test:', isValid ? 'PASSED' : 'FAILED');
}
testSignatureVerification();
// Test endpoint for Cloudflare Workers
export default {
async fetch(request, env, ctx) {
if (request.method === 'POST' && new URL(request.url).pathname === '/test-signature') {
const secret = 'test-secret';
const payload = JSON.stringify({ test: 'data' });
const timestamp = Math.floor(Date.now() / 1000).toString();
// Create signature using Web Crypto API
const input = `t=${timestamp}.${payload}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input));
const signature = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const signatureHeader = `t=${timestamp}, v1=${signature}`;
// Test verification
const isValid = await verifyZeltapaySignature(payload, timestamp, signatureHeader, secret);
return new Response(JSON.stringify({
test: 'Signature verification test',
result: isValid ? 'PASSED' : 'FAILED',
timestamp,
signature: signatureHeader
}), {
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not found', { status: 404 });
}
};
app.post('/test-signature', async (c) => {
const secret = 'test-secret';
const payload = JSON.stringify({ test: 'data' });
const timestamp = Math.floor(Date.now() / 1000).toString();
// Create signature using Web Crypto API
const input = `t=${timestamp}.${payload}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(input));
const signature = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const signatureHeader = `t=${timestamp}, v1=${signature}`;
// Test verification
const isValid = await verifyZeltapaySignature(payload, timestamp, signatureHeader, secret);
return c.json({
test: 'Signature verification test',
result: isValid ? 'PASSED' : 'FAILED',
timestamp,
signature: signatureHeader
});
});
def test_signature_verification():
secret = 'test-secret'
payload = json.dumps({'test': 'data'})
timestamp = str(int(time.time()))
# Create signature
input_string = f"t={timestamp}.{payload}"
signature = hmac.new(
secret.encode('utf-8'),
input_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
signature_header = f"t={timestamp}, v1={signature}"
# Test verification
is_valid = verify_zeltapay_signature(payload, timestamp, signature_header, secret)
print(f"Signature verification test: {'PASSED' if is_valid else 'FAILED'}")
test_signature_verification()

Always use timing-safe comparison functions:

// ✅ Good - timing-safe
crypto.timingSafeEqual(buffer1, buffer2)
// ❌ Bad - vulnerable to timing attacks
signature1 === signature2

Check signature header format before processing:

function validateSignatureFormat(signatureHeader) {
const pattern = /^t=\d+,\s*v1=[a-f0-9]{64}$/i;
return pattern.test(signatureHeader);
}
function robustVerification(req, secret) {
// Check required headers
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
if (!signature || !timestamp) {
throw new Error('Missing required headers');
}
// Validate format
if (!validateSignatureFormat(signature)) {
throw new Error('Invalid signature format');
}
// Validate timestamp
if (!validateTimestamp(timestamp)) {
throw new Error('Invalid timestamp');
}
// Verify signature
if (!verifyZeltapaySignature({
rawBody: req.body.toString(),
timestamp,
signatureHeader: signature,
secret
})) {
throw new Error('Invalid signature');
}
return true;
}

Log failed verification attempts for monitoring:

function logSecurityEvent(event, details) {
console.log(`[SECURITY] ${event}:`, {
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'],
...details
});
}
// Usage
if (!verifySignature(req, secret)) {
logSecurityEvent('INVALID_SIGNATURE', {
signature: req.headers['zeltapay-signature'],
timestamp: req.headers['zeltapay-timestamp']
});
throw new Error('Invalid signature');
}

1. Signature Mismatch

  • Check webhook secret is correct
  • Ensure raw body is used (not parsed JSON)
  • Verify timestamp format
  • Check signature header format

2. Timestamp Validation Fails

  • Check server time synchronization
  • Adjust tolerance window if needed
  • Consider timezone differences

3. Header Not Found

  • Verify header names are case-sensitive
  • Check middleware isn’t modifying headers
  • Ensure raw body middleware is used

Enable debug logging to troubleshoot:

// Node.js debug logging
function debugVerification(req, secret) {
const signature = req.headers['zeltapay-signature'];
const timestamp = req.headers['zeltapay-timestamp'];
const rawBody = req.body.toString();
console.log('Debug verification:', {
signature,
timestamp,
rawBodyLength: rawBody.length,
secretLength: secret.length
});
// Continue with verification...
}
// Cloudflare Workers debug logging
export default {
async fetch(request, env, ctx) {
if (request.method === 'POST' && new URL(request.url).pathname === '/webhook') {
const signature = request.headers.get('zeltapay-signature');
const timestamp = request.headers.get('zeltapay-timestamp');
const rawBody = await request.text();
console.log('Debug verification:', {
signature,
timestamp,
rawBodyLength: rawBody.length,
secretLength: env.WEBHOOK_SECRET?.length || 0,
userAgent: request.headers.get('user-agent'),
cfRay: request.headers.get('cf-ray')
});
// Continue with verification...
}
return new Response('Not found', { status: 404 });
}
};
// Hono debug logging
async function debugVerification(c: Context, secret: string) {
const signature = c.req.header('zeltapay-signature');
const timestamp = c.req.header('zeltapay-timestamp');
const rawBody = await c.req.text();
console.log('Debug verification:', {
signature,
timestamp,
rawBodyLength: rawBody.length,
secretLength: secret.length,
userAgent: c.req.header('user-agent'),
cfRay: c.req.header('cf-ray')
});
// Continue with verification...
}

Now that you understand signature verification, explore these resources: