Webhook Security

Verify webhook signatures with HMAC-SHA256 to ensure requests come from AlonChat

Webhook Security#

Protect your webhook endpoints from unauthorized access using HMAC-SHA256 signature verification. This ensures webhook requests actually come from AlonChat and have not been tampered with.


Why Verification Matters#

Without signature verification, anyone who discovers your webhook URL can send fake events to your endpoint. With HMAC verification:

  • Only authentic AlonChat requests are accepted
  • Tampered payloads are rejected
  • Combined with the delivery ID header, you can prevent replay attacks

HMAC-SHA256 Signature#

AlonChat signs every webhook payload with HMAC-SHA256 using your webhook's secret key.

How It Works#

  1. AlonChat takes the raw JSON request body
  2. Signs it with your webhook's secret key using HMAC-SHA256
  3. Includes the signature in the x-alonchat-signature header
  4. Your endpoint computes the same signature and compares

Signature Format#

Code
x-alonchat-signature: sha256=a3f5c8d9e2b1f4a7c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Components:

  • Prefix: sha256=
  • Algorithm: HMAC-SHA256
  • Encoding: Hexadecimal
  • Length: 64 hex characters (after the sha256= prefix)

Additional Headers#

HeaderPurpose
x-alonchat-signatureHMAC-SHA256 signature for verification
x-alonchat-delivery-idUnique ID for this delivery attempt (use for idempotency)
User-AgentAlonChat-Webhooks/1.0

Secret Key#

When you create a webhook, AlonChat generates a secret key:

Code
whsec_a3f5c8d9e2b1f4a7c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

This key is shown only once during webhook creation. Store it securely:

  • Use environment variables or a secrets manager
  • Never commit it to version control
  • Treat it like a password

If lost: Delete the webhook and create a new one to get a new secret.


Verification Examples#

Node.js / Next.js (App Router)#

typescript
// app/api/webhooks/alonchat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  try {
    // 1. Get raw body (before any parsing)
    const rawBody = await req.text();

    // 2. Get signature from header
    const signature = req.headers.get('x-alonchat-signature');
    if (!signature) {
      return NextResponse.json(
        { error: 'Missing signature' },
        { status: 401 }
      );
    }

    // 3. Compute expected signature
    const secret = process.env.ALONCHAT_WEBHOOK_SECRET;
    const expected = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    // 4. Timing-safe comparison
    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );

    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      );
    }

    // 5. Check for duplicate delivery
    const deliveryId = req.headers.get('x-alonchat-delivery-id');
    // Store and check deliveryId against your database/cache

    // 6. Parse and process event
    const event = JSON.parse(rawBody);

    if (event.event === 'leads.submit') {
      await handleLead(event.data);
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    console.error('Webhook error:', message);
    return NextResponse.json(
      { error: 'Internal error' },
      { status: 500 }
    );
  }
}

Node.js / Next.js (Pages Router)#

typescript
// pages/api/webhooks/alonchat.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
import getRawBody from 'raw-body';

export const config = {
  api: { bodyParser: false }
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    // 1. Get raw body
    const rawBody = await getRawBody(req);

    // 2. Get signature
    const signature = req.headers['x-alonchat-signature'] as string;
    if (!signature) {
      return res.status(401).json({ error: 'Missing signature' });
    }

    // 3. Compute expected signature
    const secret = process.env.ALONCHAT_WEBHOOK_SECRET;
    const expected = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    // 4. Timing-safe comparison
    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );

    if (!isValid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 5. Process event
    const event = JSON.parse(rawBody.toString());

    if (event.event === 'leads.submit') {
      await handleLead(event.data);
    }

    return res.status(200).json({ success: true });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    console.error('Webhook error:', message);
    return res.status(500).json({ error: 'Internal error' });
  }
}

Install dependency:

bash
npm install raw-body

Python (Flask)#

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

app = Flask(__name__)

@app.route('/webhooks/alonchat', methods=['POST'])
def alonchat_webhook():
    # 1. Get raw body
    raw_body = request.get_data()

    # 2. Get signature
    signature = request.headers.get('x-alonchat-signature')
    if not signature:
        return jsonify({'error': 'Missing signature'}), 401

    # 3. Compute expected signature
    secret = os.environ.get('ALONCHAT_WEBHOOK_SECRET').encode()
    digest = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    expected = f'sha256={digest}'

    # 4. Timing-safe comparison
    if not hmac.compare_digest(signature, expected):
        return jsonify({'error': 'Invalid signature'}), 401

    # 5. Process event
    event = request.get_json()

    if event['event'] == 'leads.submit':
        save_to_crm(event['data'])

    return jsonify({'success': True}), 200

PHP#

php
<?php
// 1. Get raw body
$rawBody = file_get_contents('php://input');

// 2. Get signature
$signature = $_SERVER['HTTP_X_ALONCHAT_SIGNATURE'] ?? '';
if (empty($signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Missing signature']);
    exit;
}

// 3. Compute expected signature
$secret = getenv('ALONCHAT_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

// 4. Timing-safe comparison
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// 5. Process event
$event = json_decode($rawBody, true);

if ($event['event'] === 'leads.submit') {
    save_to_crm($event['data']);
}

http_response_code(200);
echo json_encode(['success' => true]);

Ruby (Rails)#

ruby
# config/routes.rb
post '/webhooks/alonchat', to: 'webhooks#alonchat'

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def alonchat
    # 1. Get raw body
    raw_body = request.raw_post

    # 2. Get signature
    signature = request.headers['x-alonchat-signature']
    if signature.blank?
      render json: { error: 'Missing signature' }, status: :unauthorized
      return
    end

    # 3. Compute expected signature
    secret = ENV['ALONCHAT_WEBHOOK_SECRET']
    digest = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
    expected = "sha256=#{digest}"

    # 4. Timing-safe comparison
    unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
      render json: { error: 'Invalid signature' }, status: :unauthorized
      return
    end

    # 5. Process event
    event = JSON.parse(raw_body)

    if event['event'] == 'leads.submit'
      save_to_crm(event['data'])
    end

    render json: { success: true }, status: :ok
  end
end

Idempotency#

AlonChat uses at-least-once delivery, which means events may be delivered more than once during retries. Each delivery includes a unique x-alonchat-delivery-id header.

Use this ID to prevent processing the same event twice:

typescript
// Production: use a database or Redis
const processedDeliveries = new Set<string>();

function handleWebhook(req) {
  const deliveryId = req.headers['x-alonchat-delivery-id'];

  if (processedDeliveries.has(deliveryId)) {
    // Already processed, return success without re-processing
    return { success: true, duplicate: true };
  }

  // Process event...
  processedDeliveries.add(deliveryId);

  return { success: true };
}

For production systems, store delivery IDs in a database or Redis with a TTL (e.g., 24 hours) instead of an in-memory Set.


Security Best Practices#

1. Always Use the Raw Body#

Compute the signature from the raw request body, not a re-serialized JSON object. JSON.stringify() may format the payload differently than the original, causing signature mismatches.

javascript
// WRONG - re-serialized body may not match
const body = req.body;
const sig = crypto.createHmac('sha256', secret)
  .update(JSON.stringify(body))
  .digest('hex');

// CORRECT - use raw body before parsing
const rawBody = await req.text();
const sig = crypto.createHmac('sha256', secret)
  .update(rawBody)
  .digest('hex');

2. Use Timing-Safe Comparison#

A regular === comparison is vulnerable to timing attacks. Always use a constant-time comparison function:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()
  • Ruby: ActiveSupport::SecurityUtils.secure_compare()

3. Store Secrets Securely#

  • Use environment variables or a secrets manager (AWS Secrets Manager, Vercel Environment Variables)
  • Never hardcode secrets in source code
  • Never commit secrets to version control
  • Rotate secrets by deleting and recreating the webhook

4. Validate Payload Structure#

After verifying the signature, validate that the payload has the expected structure:

typescript
if (!event.event || !event.agent_id || !event.data) {
  return res.status(400).json({ error: 'Invalid payload structure' });
}

5. Respond Quickly#

Return a 200 status as soon as possible. If your processing takes time, acknowledge the webhook immediately and process the event asynchronously (e.g., with a queue).

6. Use HTTPS#

AlonChat requires HTTPS for webhook endpoints (except localhost during development). This prevents the payload and headers from being intercepted in transit.


Troubleshooting#

"Invalid signature" errors#

Common causes:

  1. Using parsed body instead of raw body — get the raw request body before any JSON parsing middleware runs.
  2. Wrong secret key — verify the environment variable matches the key shown at webhook creation time. Check for trailing whitespace or newlines.
  3. Body transformation by middleware — ensure no middleware modifies the request body before your verification logic runs.
  4. Encoding mismatch — use UTF-8 encoding consistently.

Debugging checklist:

typescript
console.log('Received:', signature);
console.log('Expected:', expected);
console.log('Raw body length:', rawBody.length);
console.log('Secret length:', secret.length);

Webhook logs show 401#

Check your webhook logs in the AlonChat dashboard:

  1. Go to Settings > Webhooks
  2. Click your webhook > View Logs
  3. Look at the response details

If the signature keeps failing, delete the webhook and create a new one to get a fresh secret key.


Testing Verification#

Generate a Test Signature#

javascript
const crypto = require('crypto');

const payload = '{"event":"leads.submit","agent_id":"test","data":{},"timestamp":"2026-03-15T10:30:00Z"}';
const secret = 'your-webhook-secret';
const signature = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

console.log(signature);

Send a Test Request#

bash
curl -X POST https://your-endpoint.com/webhooks/alonchat \
  -H "Content-Type: application/json" \
  -H "x-alonchat-signature: sha256=<generated-signature>" \
  -H "x-alonchat-delivery-id: test-001" \
  -d '{"event":"leads.submit","agent_id":"test","data":{},"timestamp":"2026-03-15T10:30:00Z"}'

Next Steps#