daimon.email
Webhooks

Verifying HMAC Signatures

Secure webhook payload verification

Verifying HMAC Signatures

Every webhook request from daimon.email includes an HMAC-SHA256 signature in the X-Daimon-Signature header. You must verify this signature to ensure the webhook payload is authentic and hasn't been tampered with.

Warning

Never skip signature verification. Without it, attackers can send fake webhook events to your endpoint, potentially triggering unintended actions in your system.

Why HMAC Verification?

Authenticity

Proves the webhook came from daimon.email, not an attacker.

Integrity

Ensures the payload hasn't been modified in transit.

Replay Protection

Combined with timestamp checks, prevents replay attacks.

Zero Trust Security

Never trust incoming data without cryptographic proof.

How HMAC Verification Works

sequenceDiagram
    daimon API->>daimon API: Compute HMAC-SHA256(body, secret)
    daimon API->>Your Endpoint: POST with X-Daimon-Signature header
    Your Endpoint->>Your Endpoint: Compute HMAC-SHA256(body, secret)
    Your Endpoint->>Your Endpoint: Compare signatures (timing-safe)
    alt Signatures match
        Your Endpoint->>daimon API: 200 OK
    else Signatures don't match
        Your Endpoint->>daimon API: 401 Unauthorized
    end

Signature Format

X-Daimon-Signature: sha256=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6

The signature is a hex-encoded HMAC-SHA256 hash of the raw request body using your webhook secret as the key.

Verification Steps

Extract the signature

Get the X-Daimon-Signature header from the request:

const signature = req.headers['x-daimon-signature'] as string;

Get your webhook secret

Retrieve the webhook secret from your secrets manager or environment variables:

const secret = process.env.WEBHOOK_SECRET!;

Warning

Never hardcode the webhook secret. Store it securely in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).

Compute the expected signature

Calculate HMAC-SHA256 of the raw request body using the webhook secret:

import crypto from 'crypto';

const expectedSignature = crypto
  .createHmac('sha256', secret)
  .update(req.body) // Must be raw body, not parsed JSON
  .digest('hex');

Note

The signature must be computed on the raw request body, not the parsed JSON object. Most frameworks require special configuration to access the raw body.

Compare signatures (timing-safe)

Use a timing-safe comparison to prevent timing attacks:

if (!crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
)) {
  return res.status(401).send('Invalid signature');
}

Warning

Never use === or == for signature comparison. These are vulnerable to timing attacks. Always use crypto.timingSafeEqual() or equivalent.

Process the webhook

Only if the signature is valid, parse and process the webhook payload:

const payload = JSON.parse(req.body.toString());

if (payload.event === 'message.received') {
  // Process the message
}

Code Examples

Node.js (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();

// IMPORTANT: Use express.raw() to get raw body, not express.json()
app.post('/webhook/daimon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-daimon-signature'] as string;
  const secret = process.env.WEBHOOK_SECRET!;

  if (!signature) {
    console.error('Missing X-Daimon-Signature header');
    return res.status(401).send('Unauthorized');
  }

  // Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(req.body) // Raw body as Buffer
    .digest('hex');

  // Timing-safe comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )) {
    console.error('Invalid signature');
    return res.status(401).send('Unauthorized');
  }

  // Signature is valid, parse payload
  const payload = JSON.parse(req.body.toString());

  // Process webhook
  console.log(`Received ${payload.event} event`);

  // Acknowledge receipt
  res.status(200).send('OK');
});

app.listen(3000);

Note

Key detail: Use express.raw({ type: 'application/json' }) instead of express.json(). This gives you the raw request body as a Buffer, which is required for signature verification.

Python (Flask)

from flask import Flask, request
import hmac
import hashlib
import os

app = Flask(__name__)

@app.route('/webhook/daimon', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Daimon-Signature')
    secret = os.environ['WEBHOOK_SECRET'].encode()

    if not signature:
        print('Missing X-Daimon-Signature header')
        return 'Unauthorized', 401

    # Compute expected signature
    expected_signature = hmac.new(
        secret,
        request.data,  # Raw body as bytes
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    if not hmac.compare_digest(signature, expected_signature):
        print('Invalid signature')
        return 'Unauthorized', 401

    # Signature is valid, parse payload
    payload = request.get_json()

    # Process webhook
    print(f"Received {payload['event']} event")

    # Acknowledge receipt
    return 'OK', 200

if __name__ == '__main__':
    app.run(port=3000)

Note

Key detail: Use request.data (raw bytes) for signature verification, not request.get_json() (parsed object). Use hmac.compare_digest() for timing-safe comparison.

Python (FastAPI)

from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import os

app = FastAPI()

@app.post('/webhook/daimon')
async def webhook(request: Request):
    signature = request.headers.get('x-daimon-signature')
    secret = os.environ['WEBHOOK_SECRET'].encode()

    if not signature:
        raise HTTPException(status_code=401, detail='Missing signature')

    # Read raw body
    body = await request.body()

    # Compute expected signature
    expected_signature = hmac.new(
        secret,
        body,  # Raw body as bytes
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    if not hmac.compare_digest(signature, expected_signature):
        raise HTTPException(status_code=401, detail='Invalid signature')

    # Signature is valid, parse payload
    payload = await request.json()

    # Process webhook
    print(f"Received {payload['event']} event")

    # Acknowledge receipt
    return {'status': 'ok'}

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

type WebhookPayload struct {
    Event string `json:"event"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Daimon-Signature")
    secret := []byte(os.Getenv("WEBHOOK_SECRET"))

    if signature == "" {
        http.Error(w, "Missing signature", http.StatusUnauthorized)
        return
    }

    // Read raw body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusInternalServerError)
        return
    }

    // Compute expected signature
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Timing-safe comparison
    if subtle.ConstantTimeCompare(
        []byte(signature),
        []byte(expectedSignature),
    ) != 1 {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Signature is valid, parse payload
    var payload WebhookPayload
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Process webhook
    println("Received", payload.Event, "event")

    // Acknowledge receipt
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func main() {
    http.HandleFunc("/webhook/daimon", webhookHandler)
    http.ListenAndServe(":3000", nil)
}

Note

Key detail: Use subtle.ConstantTimeCompare() for timing-safe comparison, not ==. This prevents timing attacks.

Ruby (Sinatra)

require 'sinatra'
require 'openssl'

post '/webhook/daimon' do
  signature = request.env['HTTP_X_DAIMON_SIGNATURE']
  secret = ENV['WEBHOOK_SECRET']

  halt 401, 'Missing signature' if signature.nil?

  # Read raw body
  request.body.rewind
  body = request.body.read

  # Compute expected signature
  expected_signature = OpenSSL::HMAC.hexdigest(
    'SHA256',
    secret,
    body
  )

  # Timing-safe comparison
  unless Rack::Utils.secure_compare(signature, expected_signature)
    halt 401, 'Invalid signature'
  end

  # Signature is valid, parse payload
  payload = JSON.parse(body)

  # Process webhook
  puts "Received #{payload['event']} event"

  # Acknowledge receipt
  status 200
  body 'OK'
end

PHP

<?php

$signature = $_SERVER['HTTP_X_DAIMON_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');

if (empty($signature)) {
    http_response_code(401);
    echo 'Missing signature';
    exit;
}

// Read raw body
$body = file_get_contents('php://input');

// Compute expected signature
$expectedSignature = hash_hmac('sha256', $body, $secret);

// Timing-safe comparison
if (!hash_equals($signature, $expectedSignature)) {
    http_response_code(401);
    echo 'Invalid signature';
    exit;
}

// Signature is valid, parse payload
$payload = json_decode($body, true);

// Process webhook
error_log("Received {$payload['event']} event");

// Acknowledge receipt
http_response_code(200);
echo 'OK';

Note

Key detail: Use hash_equals() for timing-safe comparison. Use php://input to read the raw request body.

Common Mistakes

Parsing body before verification

Wrong:

app.use(express.json()); // Parses body BEFORE signature check

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-daimon-signature'];
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(req.body)) // ❌ Won't match
    .digest('hex');
});

Right:

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-daimon-signature'];
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(req.body) // ✅ Raw body as Buffer
    .digest('hex');
});
Using non-timing-safe comparison

Wrong:

if (signature === expectedSignature) { // ❌ Vulnerable to timing attacks
  // Process webhook
}

Right:

if (crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
)) { // ✅ Timing-safe
  // Process webhook
}
Forgetting to check for missing signature

Wrong:

const signature = req.headers['x-daimon-signature'];
// signature could be undefined
const expectedSignature = crypto.createHmac(...).digest('hex');
if (signature === expectedSignature) { // ❌ Crashes if signature is undefined
  // Process webhook
}

Right:

const signature = req.headers['x-daimon-signature'];

if (!signature) {
  return res.status(401).send('Missing signature');
}

const expectedSignature = crypto.createHmac(...).digest('hex');
if (crypto.timingSafeEqual(...)) { // ✅ Safe
  // Process webhook
}
Logging or exposing the secret

Wrong:

console.log('Using webhook secret:', secret); // ❌ Leaks secret to logs

Right:

// Never log the secret
// Store it in environment variables or secrets manager
const secret = process.env.WEBHOOK_SECRET!;

Testing Signature Verification

Manual Test

You can manually generate a test signature to verify your implementation:

#!/bin/bash

SECRET="your-webhook-secret"
PAYLOAD='{"event":"message.received","message":{"id":"msg_test"}}'

# Generate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

echo "Signature: $SIGNATURE"

# Send test webhook
curl -X POST http://localhost:3000/webhook/daimon \
  -H "Content-Type: application/json" \
  -H "X-Daimon-Signature: $SIGNATURE" \
  -d "$PAYLOAD"

Expected output: 200 OK

Unit Test Example

import { describe, it, expect } from 'vitest';
import crypto from 'crypto';

describe('HMAC Signature Verification', () => {
  it('should verify valid signatures', () => {
    const secret = 'test-secret';
    const body = JSON.stringify({ event: 'message.received' });

    const signature = crypto
      .createHmac('sha256', secret)
      .update(body)
      .digest('hex');

    // Simulate verification
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(body)
      .digest('hex');

    expect(
      crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expectedSignature)
      )
    ).toBe(true);
  });

  it('should reject invalid signatures', () => {
    const secret = 'test-secret';
    const body = JSON.stringify({ event: 'message.received' });

    const signature = 'invalid-signature';

    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(body)
      .digest('hex');

    expect(signature).not.toBe(expectedSignature);
  });
});

Replay Attack Protection

HMAC signatures prevent tampering but not replay attacks (attacker resends a valid webhook multiple times).

To prevent replay attacks, add timestamp validation:

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  // 1. Verify HMAC signature (as shown above)
  // ...

  // 2. Parse payload
  const payload = JSON.parse(req.body.toString());

  // 3. Check timestamp (reject events older than 5 minutes)
  const timestamp = new Date(payload.timestamp);
  const now = new Date();
  const ageInMinutes = (now.getTime() - timestamp.getTime()) / 1000 / 60;

  if (ageInMinutes > 5) {
    console.error('Event is too old (possible replay attack)');
    return res.status(401).send('Event expired');
  }

  // 4. Check for duplicate event_id
  const eventId = payload.event_id;
  const exists = await db.events.exists({ eventId });

  if (exists) {
    console.log('Duplicate event, already processed');
    return res.status(200).send('OK'); // Idempotent
  }

  // 5. Process event
  await processEvent(payload);

  // 6. Mark as processed
  await db.events.create({ eventId, processedAt: now });

  res.status(200).send('OK');
});

Rotating Webhook Secrets

If your webhook secret is compromised, rotate it immediately:

curl -X POST https://api.daimon.email/v1/webhooks/wh_abc123/rotate-secret \
  -H "Authorization: Bearer dm_live_account123..."

Response

{
  "result": {
    "webhook_id": "wh_abc123",
    "new_secret": "whsec_newSecretAbc123...",
    "old_secret_revoked_at": "2026-03-16T11:00:00Z"
  },
  "next_steps": [
    "Update your webhook handler with the new secret",
    "Old secret is immediately revoked"
  ]
}

Warning

The old secret is immediately revoked. Update your webhook handler with the new secret before rotating to avoid downtime.

Next Steps