Skip to main content

Webhook Verification

Verify webhook signatures to ensure requests are authentic.

Why Verify?

Webhook signatures prove that:

  1. The request came from ZKP2P Pay (not an attacker)
  2. The payload hasn't been tampered with
  3. The webhook isn't a replay of an old request

Signature Format

Each webhook includes these headers:

HeaderDescription
X-Webhook-IdUnique event ID
X-Webhook-TimestampUnix timestamp (seconds)
X-Webhook-SignatureHMAC-SHA256 signature (hex)

Verification Algorithm

The signature is computed as:

HMAC-SHA256(secret, timestamp + "." + payload)

Where:

  • secret is your webhook secret (from webhook creation)
  • timestamp is from X-Webhook-Timestamp header
  • payload is the raw request body

Implementation Examples

Node.js / Express

import express from 'express';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

function verifyWebhookSignature(
payload: string,
signature: string,
timestamp: string
): boolean {
// Check timestamp is recent (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
const webhookTimestamp = parseInt(timestamp, 10);

if (Math.abs(now - webhookTimestamp) > 300) {
console.error('Webhook timestamp too old');
return false;
}

// Compute expected signature
const signatureBase = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signatureBase)
.digest('hex');

// Use timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch {
return false;
}
}

const app = express();

app.post(
'/webhooks/zkp2p',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
const payload = req.body.toString();

if (!verifyWebhookSignature(payload, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(payload);

// Process event...
res.status(200).send('OK');
}
);

Python / Flask

import hmac
import hashlib
import time
from flask import Flask, request, abort

WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']

def verify_webhook_signature(payload: bytes, signature: str, timestamp: str) -> bool:
# Check timestamp is recent
now = int(time.time())
webhook_timestamp = int(timestamp)

if abs(now - webhook_timestamp) > 300:
return False

# Compute expected signature
signature_base = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signature_base.encode('utf-8'),
hashlib.sha256
).hexdigest()

# Timing-safe comparison
return hmac.compare_digest(signature, expected_signature)

app = Flask(__name__)

@app.route('/webhooks/zkp2p', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
payload = request.data

if not verify_webhook_signature(payload, signature, timestamp):
abort(401)

event = request.get_json()
# Process event...

return 'OK', 200

Go

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)

var webhookSecret = []byte(os.Getenv("WEBHOOK_SECRET"))

func verifyWebhookSignature(payload []byte, signature, timestamp string) bool {
// Check timestamp
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}

now := time.Now().Unix()
if abs(now-ts) > 300 {
return false
}

// Compute expected signature
signatureBase := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, webhookSecret)
mac.Write([]byte(signatureBase))
expectedSignature := hex.EncodeToString(mac.Sum(nil))

// Timing-safe comparison
return hmac.Equal(
[]byte(signature),
[]byte(expectedSignature),
)
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")

payload, _ := io.ReadAll(r.Body)

if !verifyWebhookSignature(payload, signature, timestamp) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

// Process event...
w.WriteHeader(http.StatusOK)
}

Security Best Practices

1. Always Verify Signatures

Never skip signature verification, even in development.

2. Check Timestamp

Reject webhooks older than 5 minutes to prevent replay attacks:

const MAX_AGE_SECONDS = 300; // 5 minutes
const webhookAge = Math.floor(Date.now() / 1000) - parseInt(timestamp);

if (webhookAge > MAX_AGE_SECONDS) {
throw new Error('Webhook too old');
}

3. Use Timing-Safe Comparison

Always use timing-safe comparison to prevent timing attacks:

// Good: timing-safe
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Bad: vulnerable to timing attacks
a === b;

4. Use Raw Body

Parse the body after verification to ensure you're verifying the exact bytes received:

// Express
app.post('/webhook', express.raw({ type: 'application/json' }), handler);

// Then parse after verification
const event = JSON.parse(rawBody.toString());

5. Keep Secret Secure

  • Store the webhook secret in environment variables
  • Never log the secret
  • Rotate secrets if compromised