The Need for Secure Webhooks

ElevenLabs offers a powerful post-call webhook feature that delivers valuable data such as call transcripts, sentiment analysis, and cost breakdowns directly to your configured endpoint. This data is crucial for post-call analytics, CRM updates, or triggering follow-up actions. However, as with any webhook integration, security is paramount. You need to ensure that the incoming data is genuinely from ElevenLabs and hasn't been tampered with in transit.

ElevenLabs signs its webhooks using a signature that is sent in the X-ElevenLabs-Signature header. This signature is generated using the webhook secret and the raw request body. To verify this signature, you would typically use ElevenLabs' SDK. However, for developers working within environments like Cloudflare Workers, which have a limited Node.js compatibility and can be sensitive to large dependencies, relying on an external SDK might not be ideal. This article outlines how to verify these signatures directly within a Cloudflare Worker, without needing the ElevenLabs SDK.

Understanding the Signature Verification Process

The core of webhook security lies in cryptographic verification. ElevenLabs uses a standard signing mechanism where a secret key (your webhook secret) is combined with the request payload to produce a unique signature. When your server receives a webhook, it performs the same operation using its copy of the secret and the received payload. If the generated signature matches the signature provided by ElevenLabs in the header, you can be confident in the authenticity and integrity of the data.

The signature is generated using the HMAC-SHA256 algorithm. The process involves:

  1. Retrieving the raw request body.
  2. Retrieving the timestamp from the X-ElevenLabs-Timestamp header.
  3. Concatenating the timestamp and the raw request body.
  4. Hashing this concatenated string using HMAC-SHA256 with your webhook secret as the key.
  5. Comparing the resulting hash (hex-encoded) with the signature provided in the X-ElevenLabs-Signature header.

A critical aspect of this verification is the timestamp. Webhooks should ideally be processed within a reasonable time window to mitigate replay attacks. If a webhook arrives with a timestamp that is too old, it should be rejected, even if the signature is valid. This ensures that stale requests cannot be re-submitted and acted upon.

Implementing Verification in Cloudflare Workers

Cloudflare Workers operate in a V8 isolates environment, which offers excellent performance but has some constraints, particularly regarding Node.js compatibility. Fortunately, the Web Crypto API, which is available in Workers, provides the necessary cryptographic primitives for HMAC-SHA256 hashing.

Here's a step-by-step approach to implement this verification:

1. Retrieve Necessary Headers and Body

When your Cloudflare Worker receives a request to its webhook endpoint, you need to capture the incoming headers and the raw request body. The relevant headers are X-ElevenLabs-Signature and X-ElevenLabs-Timestamp. The raw body is essential for the hashing process.

const signature = request.headers.get('X-ElevenLabs-Signature');
const timestamp = request.headers.get('X-ElevenLabs-Timestamp');
const rawBody = await request.text();

2. Define Your Webhook Secret and Time Tolerance

Your ElevenLabs webhook secret is a sensitive piece of information. It should be stored securely, ideally as an environment variable within your Cloudflare Worker settings. You also need to define a time tolerance (e.g., 5 minutes) to ensure webhook requests are not too stale.

const webhookSecret = env.ELEVENLABS_WEBHOOK_SECRET; // Assuming stored in environment variables
const timeTolerance = 5 * 60 * 1000; // 5 minutes in milliseconds

3. Verify the Timestamp

Before proceeding to signature verification, check if the timestamp is within your acceptable tolerance. This is a quick check that can reject potentially malicious or stale requests early.

const requestTimestamp = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);

if (Math.abs(currentTime - requestTimestamp) > timeTolerance / 1000) {
    return new Response('Timestamp is too old', { status: 400 });
}

4. Generate the Signature

Now, use the Web Crypto API to compute the HMAC-SHA256 hash. The process involves creating a CryptoKey for the secret and then using the subtle.sign method.

// Convert the webhook secret to an ArrayBuffer
const secretKeyData = new TextEncoder().encode(webhookSecret);

// Import the secret key for HMAC-SHA256
const key = await crypto.subtle.importKey(
    'raw',
    secretKeyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign', 'verify']
);

// Concatenate timestamp and raw body
const dataToSign = `${timestamp}${rawBody}`;

// Sign the data using HMAC-SHA256
const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(dataToSign)
);

// Convert the signature buffer to a hex string
const generatedSignature = Array.from(
    new Uint8Array(signatureBuffer)
)
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');

5. Compare Signatures

Finally, compare the generatedSignature with the signature received in the header. For security, use a constant-time comparison function to prevent timing attacks, although for webhook verification, a direct string comparison is often sufficient in practice, especially given the other checks in place.

if (generatedSignature !== signature) {
    return new Response('Invalid signature', { status: 401 });
}

Putting It All Together in a Worker

The complete logic can be encapsulated within your Worker's fetch handler. Remember to handle potential errors and ensure your webhook secret is securely managed.

export default {
    async fetch(request, env, ctx) {
        const webhookSecret = env.ELEVENLABS_WEBHOOK_SECRET;
        if (!webhookSecret) {
            return new Response('Webhook secret not configured', { status: 500 });
        }

        const signature = request.headers.get('X-ElevenLabs-Signature');
        const timestamp = request.headers.get('X-ElevenLabs-Timestamp');
        const rawBody = await request.text();

        if (!signature || !timestamp) {
            return new Response('Missing signature or timestamp headers', { status: 400 });
        }

        const timeTolerance = 5 * 60 * 1000; // 5 minutes
        const requestTimestamp = parseInt(timestamp, 10);
        const currentTime = Math.floor(Date.now() / 1000);

        if (Math.abs(currentTime - requestTimestamp) > timeTolerance / 1000) {
            return new Response('Timestamp is too old', { status: 400 });
        }

        try {
            const secretKeyData = new TextEncoder().encode(webhookSecret);
            const key = await crypto.subtle.importKey(
                'raw',
                secretKeyData,
                { name: 'HMAC', hash: 'SHA-256' },
                false,
                ['sign', 'verify']
            );

            const dataToSign = `${timestamp}${rawBody}`;
            const signatureBuffer = await crypto.subtle.sign(
                'HMAC',
                key,
                new TextEncoder().encode(dataToSign)
            );

            const generatedSignature = Array.from(
                new Uint8Array(signatureBuffer)
            )
            .map(byte => byte.toString(16).padStart(2, '0'))
            .join('');

            if (generatedSignature !== signature) {
                return new Response('Invalid signature', { status: 401 });
            }

            // If verification passes, process the webhook payload
            const payload = JSON.parse(rawBody);
            console.log('Webhook verified and payload received:', payload);

            // Your webhook processing logic here...

            return new Response('Webhook received successfully', { status: 200 });

        } catch (error) {
            console.error('Error verifying webhook:', error);
            return new Response('Internal server error during verification', { status: 500 });
        }
    }
};