Signature Header
Every webhook request includes anX-Zavu-Signature header:
X-Zavu-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | HMAC-SHA256 signature of the payload |
Verifying Signatures
Step 1: Extract the Signature
Parse theX-Zavu-Signature header to get the timestamp and signature:
function parseSignature(header) {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
const signature = parts.find(p => p.startsWith('v1=')).slice(3);
return { timestamp: parseInt(timestamp), signature };
}
Step 2: Prepare the Signed Payload
Concatenate the timestamp and the raw request body:const signedPayload = `${timestamp}.${rawBody}`;
Step 3: Compute the Expected Signature
Use HMAC-SHA256 with your webhook secret:const crypto = require('crypto');
function computeSignature(secret, payload) {
return crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
Step 4: Compare Signatures
Use a constant-time comparison to prevent timing attacks:const crypto = require('crypto');
function verifySignature(expected, actual) {
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(actual)
);
}
Complete Examples
import crypto from "crypto";
import express from "express";
const app = express();
// Use raw body for signature verification
app.use("/webhooks/zavu", express.raw({ type: "application/json" }));
function verifyZavuSignature(req, secret) {
const signatureHeader = req.headers["x-zavu-signature"];
if (!signatureHeader) {
return false;
}
// Parse the signature header
const parts = signatureHeader.split(",");
const timestampPart = parts.find(p => p.startsWith("t="));
const signaturePart = parts.find(p => p.startsWith("v1="));
if (!timestampPart || !signaturePart) {
return false;
}
const timestamp = parseInt(timestampPart.slice(2));
const signature = signaturePart.slice(3);
// Check timestamp (reject if older than 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (now - timestamp > 300) {
console.log("Webhook timestamp too old");
return false;
}
// Compute expected signature
const rawBody = req.body.toString();
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time comparison
try {
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
);
} catch {
return false;
}
}
app.post("/webhooks/zavu", (req, res) => {
const isValid = verifyZavuSignature(req, process.env.ZAVU_WEBHOOK_SECRET);
if (!isValid) {
console.log("Invalid webhook signature");
return res.status(401).send("Invalid signature");
}
// Parse the body and process
const event = JSON.parse(req.body.toString());
console.log("Verified webhook:", event.type);
res.status(200).send("OK");
});
app.listen(3000);
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = 'whsec_your_secret_here' # From sender.webhook.secret
def verify_zavu_signature(request, secret):
signature_header = request.headers.get('X-Zavu-Signature')
if not signature_header:
return False
# Parse the signature header
parts = signature_header.split(',')
timestamp_part = next((p for p in parts if p.startswith('t=')), None)
signature_part = next((p for p in parts if p.startswith('v1=')), None)
if not timestamp_part or not signature_part:
return False
timestamp = int(timestamp_part[2:])
signature = signature_part[3:]
# Check timestamp (reject if older than 5 minutes)
now = int(time.time())
if now - timestamp > 300:
print('Webhook timestamp too old')
return False
# Compute expected signature
raw_body = request.data.decode('utf-8')
signed_payload = f'{timestamp}.{raw_body}'
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected_signature, signature)
@app.route('/webhooks/zavu', methods=['POST'])
def handle_webhook():
if not verify_zavu_signature(request, WEBHOOK_SECRET):
print('Invalid webhook signature')
return 'Invalid signature', 401
event = request.json
print(f'Verified webhook: {event["type"]}')
return 'OK', 200
if __name__ == '__main__':
app.run(port=3000)
require "sinatra"
require "openssl"
require "json"
WEBHOOK_SECRET = ENV["ZAVU_WEBHOOK_SECRET"] # From sender.webhook.secret
def verify_zavu_signature(request, secret)
signature_header = request.env["HTTP_X_ZAVU_SIGNATURE"]
return false unless signature_header
# Parse the signature header
parts = signature_header.split(",")
timestamp_part = parts.find { |p| p.start_with?("t=") }
signature_part = parts.find { |p| p.start_with?("v1=") }
return false unless timestamp_part && signature_part
timestamp = timestamp_part[2..].to_i
signature = signature_part[3..]
# Check timestamp (reject if older than 5 minutes)
now = Time.now.to_i
if now - timestamp > 300
puts "Webhook timestamp too old"
return false
end
# Compute expected signature
raw_body = request.body.read
request.body.rewind
signed_payload = "#{timestamp}.#{raw_body}"
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
# Constant-time comparison
Rack::Utils.secure_compare(expected_signature, signature)
end
post "/webhooks/zavu" do
unless verify_zavu_signature(request, WEBHOOK_SECRET)
puts "Invalid webhook signature"
halt 401, "Invalid signature"
end
event = JSON.parse(request.body.read)
puts "Verified webhook: #{event['type']}"
status 200
"OK"
end
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const webhookSecret = "whsec_your_secret_here" // From sender.webhook.secret
func verifyZavuSignature(r *http.Request, secret string) bool {
signatureHeader := r.Header.Get("X-Zavu-Signature")
if signatureHeader == "" {
return false
}
// Parse the signature header
parts := strings.Split(signatureHeader, ",")
var timestamp int64
var signature string
for _, part := range parts {
if strings.HasPrefix(part, "t=") {
timestamp, _ = strconv.ParseInt(part[2:], 10, 64)
} else if strings.HasPrefix(part, "v1=") {
signature = part[3:]
}
}
if timestamp == 0 || signature == "" {
return false
}
// Check timestamp (reject if older than 5 minutes)
now := time.Now().Unix()
if now-timestamp > 300 {
return false
}
// Read body
body, _ := io.ReadAll(r.Body)
// Compute expected signature
signedPayload := strconv.FormatInt(timestamp, 10) + "." + string(body)
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(h.Sum(nil))
// Constant-time comparison
return hmac.Equal([]byte(expectedSignature), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
if !verifyZavuSignature(r, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func main() {
http.HandleFunc("/webhooks/zavu", webhookHandler)
http.ListenAndServe(":3000", nil)
}
<?php
$webhookSecret = getenv('ZAVU_WEBHOOK_SECRET'); // From sender.webhook.secret
function verifyZavuSignature(string $secret): bool {
$signatureHeader = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? '';
if (empty($signatureHeader)) {
return false;
}
// Parse the signature header
$parts = explode(',', $signatureHeader);
$timestamp = null;
$signature = null;
foreach ($parts as $part) {
if (str_starts_with($part, 't=')) {
$timestamp = (int) substr($part, 2);
} elseif (str_starts_with($part, 'v1=')) {
$signature = substr($part, 3);
}
}
if ($timestamp === null || $signature === null) {
return false;
}
// Check timestamp (reject if older than 5 minutes)
$now = time();
if ($now - $timestamp > 300) {
error_log('Webhook timestamp too old');
return false;
}
// Compute expected signature
$rawBody = file_get_contents('php://input');
$signedPayload = "{$timestamp}.{$rawBody}";
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Constant-time comparison
return hash_equals($expectedSignature, $signature);
}
if (!verifyZavuSignature($webhookSecret)) {
error_log('Invalid webhook signature');
http_response_code(401);
echo 'Invalid signature';
exit;
}
$event = json_decode(file_get_contents('php://input'), true);
error_log("Verified webhook: {$event['type']}");
http_response_code(200);
echo 'OK';
Timestamp Validation
Always validate the timestamp to prevent replay attacks:const MAX_AGE_SECONDS = 300; // 5 minutes
function isTimestampValid(timestamp) {
const now = Math.floor(Date.now() / 1000);
return (now - timestamp) <= MAX_AGE_SECONDS;
}
Never skip signature verification in production. An attacker could send fake webhook events to your endpoint.
Troubleshooting
Signature Mismatch
If signature verification fails:- Check your secret - Ensure you’re using the correct webhook secret from the sender’s webhook configuration
- Use raw body - The signature is computed on the raw request body, not parsed JSON
- Check encoding - Ensure the body is UTF-8 encoded
- Verify timestamp format - The timestamp in the signature is in seconds, not milliseconds
Testing Locally
For local development, you can temporarily disable signature verification or use a tool like ngrok to expose your local server.// Development only - never use in production!
const SKIP_VERIFICATION = process.env.NODE_ENV === 'development';
app.post('/webhooks/zavu', (req, res) => {
if (!SKIP_VERIFICATION && !verifyZavuSignature(req, secret)) {
return res.status(401).send('Invalid signature');
}
// ...
});
Next Steps
- Event Types - Understand webhook payloads
- Webhooks - Configure your endpoints
