Skip to main content

Webhook Signature Verification

Learn how to verify webhook authenticity using HMAC-SHA256 signatures to secure your integration.

Share
Last updated on August 20, 2026

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 bex.co.

How It Works

bex.co signs every webhook request using HMAC-SHA256:

text
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

javascript
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('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Missing signature header');
      return res.status(401).send('Missing signature');
    }
 
    if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
      console.error('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Invalid signature');
      return res.status(401).send('Invalid signature');
    }
 
    // Signature valid - safe to process
    const event = JSON.parse(req.body.toString());
    console.log('<ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Signature verified for event:', event.type);
 
    // Process event...
 
    res.status(200).json({ received: true });
  }
);
 
app.listen(3000);

Next.js App Router

typescript
// 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('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Invalid signature');
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    );
  }
 
  // Signature valid - safe to process
  const event = JSON.parse(body);
  console.log('<ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Signature verified:', event.type);
 
  // Process event...
 
  return NextResponse.json({ received: true });
}

Python with Flask

python
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('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Invalid signature')
        return jsonify({'error': 'Invalid signature'}), 401
 
    # Signature valid - safe to process
    event = request.get_json()
    print(f'<ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Signature verified: {event["type"]}')
 
    # Process event...
 
    return jsonify({'received': True}), 200
 
if __name__ == '__main__':
    app.run(port=3000)

Python with FastAPI

python
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('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Invalid signature')
        raise HTTPException(status_code=401, detail="Invalid signature")
 
    # Signature valid - safe to process
    event = await request.json()
    print(f'<ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Signature verified: {event["type"]}')
 
    # Process event...
 
    return {"received": True}

Ruby with Sinatra

ruby
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 '<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> Invalid signature'
    halt 401, JSON.generate({ error: 'Invalid signature' })
  end
 
  # Signature valid - safe to process
  event = JSON.parse(body)
  puts "<ion-icon name=\"checkmark-circle\" style={{verticalAlign: \"middle\", fontSize: \"1.2em\", color: \"#10b981\"}}></ion-icon> Signature verified: #{event['type']}"
 
  # Process event...
 
  status 200
  JSON.generate({ received: true })
end

PHP

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('<ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> 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("<ion-icon name=\"checkmark-circle\" style={{verticalAlign: \"middle\", fontSize: \"1.2em\", color: \"#10b981\"}}></ion-icon> Signature verified: {$event['type']}");
 
// Process event...
 
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
?>

Go

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("<ion-icon name=\"close-circle\" style={{verticalAlign: \"middle\", fontSize: \"1.2em\", color: \"#ef4444\"}}></ion-icon> 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("<ion-icon name=\"checkmark-circle\" style={{verticalAlign: \"middle\", fontSize: \"1.2em\", color: \"#10b981\"}}></ion-icon> 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):

javascript
// Use express.raw() to preserve raw body
app.post('/webhooks', express.raw({type: 'application/json'}), handler);

Incorrect (Express):

javascript
// 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:

bash
# .env file (never commit to git!)
WEBHOOK_SECRET=whsec_abc123def456...
javascript
// Access in code
const secret = process.env.WEBHOOK_SECRET;

Bad:

javascript
// 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:

bash
#!/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:

bash
# 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 bex.co 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:

javascript
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:

javascript
// 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 - bex.co 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

bex.co webhook secrets follow this format:

text
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
warning

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

Next Steps

Need Help?

Accept crypto payments

EdenPay handles the payment events, webhooks, and signatures so you don't have to build them.

Read the EdenPay docs