---
title: "Webhook Signature Verification"
description: "Learn how to verify webhook authenticity using HMAC-SHA256 signatures to secure your integration."
sidebar_position: 4
last_updated: 2026-08-20
---

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

## How It Works

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

```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

:::danger 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](https://en.wikipedia.org/wiki/Timing_attack):

| 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

:::tip 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 Payments** → **Webhooks**
2. Click **Test** button on your endpoint
3. Check your server logs:
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> You should see "Signature verified"
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> HTTP 200 response
   - <ion-icon name="close-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#ef4444"}}></ion-icon> 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](https://ngrok.com/) or [localtunnel](https://localtunnel.me/) 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**
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Solution: Use raw body middleware (`express.raw()` for Express)

2. **Using wrong secret**
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Solution: Copy secret from dashboard when creating endpoint
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Check environment variable is set correctly

3. **Modifying body before verification**
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Solution: Verify signature BEFORE any body manipulation

4. **Whitespace or encoding issues**
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> Solution: Don't trim or modify the body string

5. **Wrong HMAC algorithm**
   - <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> 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. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Always verify signatures** - Never skip verification, even in development
2. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Use HTTPS only** - bex.co only sends webhooks to HTTPS endpoints
3. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Rotate secrets regularly** - Use dashboard's "Rotate Secret" button every 90 days
4. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Return 200 quickly** - Acknowledge receipt within 30 seconds
5. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Log failed verifications** - Monitor for potential attack attempts
6. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Rate limit your endpoint** - Prevent abuse and DDoS attacks
7. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Validate event structure** - Check event types and required fields
8. <ion-icon name="checkmark-circle" style={{verticalAlign: "middle", fontSize: "1.2em", color: "#10b981"}}></ion-icon> **Handle idempotency** - Same event may be delivered multiple times

## Webhook Secret Format

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

- 📚 **Event Types**: Read the [Event Reference](./events.md) for available webhook events
- 🚀 **Get Started**: Follow the [Quick Start Guide](./quickstart.md)
- 🛠️ **Debug Issues**: Check the [Troubleshooting Guide](./troubleshooting.md)

## Need Help?

- **Forum**: [https://bex.co/forum](https://bex.co/forum)
- **Discord**: [https://discord.gg/eWZvE4RSBw](https://discord.gg/eWZvE4RSBw)
- **Support**: hello@bex.co
