Webhook Signature Verification
Learn how to verify webhook authenticity using HMAC-SHA256 signatures to secure your integration.
Why Verify Signatures?
Without signature verification, anyone could send fake webhook requests to your server, potentially:
- Crediting accounts without payment
- Triggering fraudulent order fulfillment
- Manipulating your business logic
Always verify signatures to ensure requests genuinely came from BlockEden.xyz.
How It Works
BlockEden.xyz signs every webhook request using HMAC-SHA256:
signature = HMAC-SHA256(your_webhook_secret, raw_request_body)
The signature is sent in the x-eden-signature
HTTP header. Your server must:
- Read the raw request body (before parsing)
- Compute the expected signature using your secret
- Compare it with the received signature
- Only process the event if signatures match
Implementation Guide
Node.js with Express
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
function verifyWebhookSignature(body, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
// Use constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// IMPORTANT: Use express.raw() to get the raw body
app.post('/api/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-eden-signature'];
if (!signature) {
console.error('❌ Missing signature header');
return res.status(401).send('Missing signature');
}
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
console.error('❌ Invalid signature');
return res.status(401).send('Invalid signature');
}
// Signature valid - safe to process
const event = JSON.parse(req.body.toString());
console.log('✅ Signature verified for event:', event.type);
// Process event...
res.status(200).json({ received: true });
}
);
app.listen(3000);
Next.js App Router
// app/api/webhooks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
function verifySignature(body: string, signature: string): boolean {
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
export async function POST(req: NextRequest) {
const signature = req.headers.get('x-eden-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 401 }
);
}
// Get raw body text for verification
const body = await req.text();
if (!verifySignature(body, signature)) {
console.error('❌ Invalid signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Signature valid - safe to process
const event = JSON.parse(body);
console.log('✅ Signature verified:', event.type);
// Process event...
return NextResponse.json({ received: true });
}
Python with Flask
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify webhook signature using constant-time comparison."""
expected_signature = hmac.new(
secret.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
# Use compare_digest for constant-time comparison
return hmac.compare_digest(signature, expected_signature)
@app.route('/api/webhooks', methods=['POST'])
def webhook():
signature = request.headers.get('x-eden-signature')
if not signature:
return jsonify({'error': 'Missing signature'}), 401
# Get raw body bytes (before parsing)
body = request.get_data()
if not verify_webhook_signature(body, signature, WEBHOOK_SECRET):
print('❌ Invalid signature')
return jsonify({'error': 'Invalid signature'}), 401
# Signature valid - safe to process
event = request.get_json()
print(f'✅ Signature verified: {event["type"]}')
# Process event...
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
Python with FastAPI
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException, Header
from typing import Optional
app = FastAPI()
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify webhook signature using constant-time comparison."""
expected_signature = hmac.new(
secret.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
@app.post("/api/webhooks")
async def webhook(
request: Request,
x_eden_signature: Optional[str] = Header(None)
):
if not x_eden_signature:
raise HTTPException(status_code=401, detail="Missing signature")
# Get raw body bytes
body = await request.body()
if not verify_webhook_signature(body, x_eden_signature, WEBHOOK_SECRET):
print('❌ Invalid signature')
raise HTTPException(status_code=401, detail="Invalid signature")
# Signature valid - safe to process
event = await request.json()
print(f'✅ Signature verified: {event["type"]}')
# Process event...
return {"received": True}
Ruby with Sinatra
require 'sinatra'
require 'openssl'
require 'json'
WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
def verify_webhook_signature(body, signature, secret)
expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
Rack::Utils.secure_compare(signature, expected_signature)
end
post '/api/webhooks' do
signature = request.env['HTTP_X_EDEN_SIGNATURE']
unless signature
halt 401, JSON.generate({ error: 'Missing signature' })
end
# Get raw body
request.body.rewind
body = request.body.read
unless verify_webhook_signature(body, signature, WEBHOOK_SECRET)
puts '❌ Invalid signature'
halt 401, JSON.generate({ error: 'Invalid signature' })
end
# Signature valid - safe to process
event = JSON.parse(body)
puts "✅ Signature verified: #{event['type']}"
# Process event...
status 200
JSON.generate({ received: true })
end
PHP
<?php
function verifyWebhookSignature($body, $signature, $secret) {
$expectedSignature = hash_hmac('sha256', $body, $secret);
// Use hash_equals for constant-time comparison
return hash_equals($signature, $expectedSignature);
}
// Get raw POST body
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EDEN_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (!$signature) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Missing signature']);
exit;
}
if (!verifyWebhookSignature($body, $signature, $secret)) {
error_log('❌ Invalid signature');
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Signature valid - safe to process
$event = json_decode($body, true);
error_log("✅ Signature verified: {$event['type']}");
// Process event...
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
?>
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
)
var webhookSecret = os.Getenv("WEBHOOK_SECRET")
func verifyWebhookSignature(body []byte, signature string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Use constant-time comparison
return subtle.ConstantTimeCompare(
[]byte(signature),
[]byte(expectedSignature),
) == 1
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("x-eden-signature")
if signature == "" {
http.Error(w, `{"error":"Missing signature"}`, http.StatusUnauthorized)
return
}
// Read raw body
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"Error reading body"}`, http.StatusBadRequest)
return
}
if !verifyWebhookSignature(body, signature, webhookSecret) {
log.Println("❌ Invalid signature")
http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
return
}
// Signature valid - safe to process
var event map[string]interface{}
json.Unmarshal(body, &event)
log.Printf("✅ Signature verified: %s\n", event["type"])
// Process event...
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received":true}`))
}
func main() {
http.HandleFunc("/api/webhooks", webhookHandler)
fmt.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Critical Implementation Notes
1. Use Raw Request Body
You must verify the signature against the raw request body, not the parsed JSON.
Correct (Express):
// Use express.raw() to preserve raw body
app.post('/webhooks', express.raw({type: 'application/json'}), handler);
Incorrect (Express):
// express.json() parses the body - signature will fail!
app.post('/webhooks', express.json(), handler);
2. Use Constant-Time Comparison
Always use constant-time comparison functions to prevent timing attacks:
Language | Constant-Time Function |
---|---|
Node.js | crypto.timingSafeEqual() |
Python | hmac.compare_digest() |
Ruby | Rack::Utils.secure_compare() |
PHP | hash_equals() |
Go | subtle.ConstantTimeCompare() |
3. Store Secrets Securely
Store webhook secrets in environment variables or a secrets manager like AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault.
Good:
# .env file (never commit to git!)
WEBHOOK_SECRET=whsec_abc123def456...
// Access in code
const secret = process.env.WEBHOOK_SECRET;
Bad:
// NEVER hardcode secrets!
const secret = "whsec_abc123def456...";
Testing Signature Verification
Using the Dashboard
The easiest way to test:
- Navigate to Accept Payments → Webhooks
- Click Test button on your endpoint
- Check your server logs:
- ✅ You should see "Signature verified"
- ✅ HTTP 200 response
- ❌ If you see "Invalid signature", check your implementation
Manual Testing with cURL
Generate a test signature:
#!/bin/bash
# Your webhook secret
SECRET="whsec_your_secret_here"
# Test payload
BODY='{"id":"evt_test","type":"payment.confirmed","createdAt":"2025-01-15T10:00:00Z","data":{"paymentId":"pay_test","amount":"100.00"}}'
# Generate signature
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
# Send request
curl -X POST https://your-domain.com/api/webhooks \
-H "Content-Type: application/json" \
-H "x-eden-signature: $SIGNATURE" \
-d "$BODY"
Expected output: {"received":true}
with HTTP 200 status.
Local Development Testing
Use ngrok or localtunnel to test locally:
# Start your server
npm start
# In another terminal, create tunnel
ngrok http 3000
# Output: Forwarding https://abc123.ngrok.io -> http://localhost:3000
# Use the ngrok HTTPS URL in BlockEden dashboard
# Example: https://abc123.ngrok.io/api/webhooks
Troubleshooting
"Invalid Signature" Errors
Problem: Signature verification always fails.
Common Causes & Solutions:
-
Body was parsed before verification
- ✅ Solution: Use raw body middleware (
express.raw()
for Express)
- ✅ Solution: Use raw body middleware (
-
Using wrong secret
- ✅ Solution: Copy secret from dashboard when creating endpoint
- ✅ Check environment variable is set correctly
-
Modifying body before verification
- ✅ Solution: Verify signature BEFORE any body manipulation
-
Whitespace or encoding issues
- ✅ Solution: Don't trim or modify the body string
-
Wrong HMAC algorithm
- ✅ Solution: Use SHA-256, not SHA-1 or MD5
Debugging Tips
Add detailed logging:
function verifyWebhookSignature(body, signature, secret) {
console.log('Body length:', body.length);
console.log('Received signature:', signature);
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
console.log('Expected signature:', expectedSignature);
console.log('Signatures match:', signature === expectedSignature);
return signature === expectedSignature;
}
Test with Known Values
Verify your implementation with these test values:
// Test inputs
const secret = 'whsec_test_secret';
const body = '{"id":"evt_test","type":"webhook.test.event"}';
// Expected signature
const expectedSignature = 'c8d5e0e3e0f0b0a8d7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a39382716';
// Verify your implementation produces the same signature
Security Best Practices
- ✅ Always verify signatures - Never skip verification, even in development
- ✅ Use HTTPS only - BlockEden only sends webhooks to HTTPS endpoints
- ✅ Rotate secrets regularly - Use dashboard's "Rotate Secret" button every 90 days
- ✅ Return 200 quickly - Acknowledge receipt within 30 seconds
- ✅ Log failed verifications - Monitor for potential attack attempts
- ✅ Rate limit your endpoint - Prevent abuse and DDoS attacks
- ✅ Validate event structure - Check event types and required fields
- ✅ Handle idempotency - Same event may be delivered multiple times
Webhook Secret Format
BlockEden webhook secrets follow this format:
whsec_[48 hexadecimal characters]
Example: whsec_1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x
- Prefix:
whsec_
(6 characters) - Secret: 48 random hex characters (0-9, a-f)
- Total length: 54 characters
Rotating Secrets
Rotate your webhook secret if:
- Secret may have been compromised
- As a security best practice (every 90 days recommended)
- When offboarding team members who had access
How to rotate:
- Navigate to Accept Payments → Webhooks
- Click Rotate Secret button on your endpoint
- Copy the new secret from the modal
- Update your environment variable:
WEBHOOK_SECRET=new_secret
- Deploy the change to your server
- Click "Test" to verify the new secret works
After rotating, the old secret becomes invalid immediately. Update your server before rotating to avoid downtime.
Next Steps
- 📚 Event Types: Read the Event Reference for available webhook events
- 🚀 Get Started: Follow the Quick Start Guide
- 🛠️ Debug Issues: Check the Troubleshooting Guide
Need Help?
- Forum: https://blockeden.xyz/forum
- Discord: https://discord.gg/eWZvE4RSBw
- Support: support@blockeden.xyz