Pular para o conteúdo principal

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 PaymentsWebhooks
  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 PaymentsWebhooks
  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
atenção

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

Next Steps

Need Help?