Skip to main content

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:

  1. Read the raw request body (before parsing)
  2. Compute the expected signature using your secret
  3. Compare it with the received signature
  4. 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โ€‹

Common Mistake

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:

LanguageConstant-Time Function
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
RubyRack::Utils.secure_compare()
PHPhash_equals()
Gosubtle.ConstantTimeCompare()

3. Store Secrets Securelyโ€‹

Best Practice

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:

  1. Navigate to Accept Payments โ†’ Webhooks
  2. Click Test button on your endpoint
  3. 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:

  1. Body was parsed before verification

    • โœ… Solution: Use raw body middleware (express.raw() for Express)
  2. Using wrong secret

    • โœ… Solution: Copy secret from dashboard when creating endpoint
    • โœ… Check environment variable is set correctly
  3. Modifying body before verification

    • โœ… Solution: Verify signature BEFORE any body manipulation
  4. Whitespace or encoding issues

    • โœ… Solution: Don't trim or modify the body string
  5. 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โ€‹

  1. โœ… Always verify signatures - Never skip verification, even in development
  2. โœ… Use HTTPS only - BlockEden only sends webhooks to HTTPS endpoints
  3. โœ… Rotate secrets regularly - Use dashboard's "Rotate Secret" button every 90 days
  4. โœ… Return 200 quickly - Acknowledge receipt within 30 seconds
  5. โœ… Log failed verifications - Monitor for potential attack attempts
  6. โœ… Rate limit your endpoint - Prevent abuse and DDoS attacks
  7. โœ… Validate event structure - Check event types and required fields
  8. โœ… 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:

  1. Navigate to Accept Payments โ†’ Webhooks
  2. Click Rotate Secret button on your endpoint
  3. Copy the new secret from the modal
  4. Update your environment variable: WEBHOOK_SECRET=new_secret
  5. Deploy the change to your server
  6. Click "Test" to verify the new secret works
warning

After rotating, the old secret becomes invalid immediately. Update your server before rotating to avoid downtime.

Next Stepsโ€‹

Need Help?โ€‹