agentsclimarketplace

Elevenlabs webhooks events

Skill ComeOnOliver/skillshub/skills/jeremylongshore/claude-code-plugins-plus-skills/elevenlabs-webhooks-events

Implement ElevenLabs webhook HMAC signature verification and event handling. Use when setting up webhook endpoints for transcription completion, call recording, or agent conversation events from ElevenLabs. Trigger: "elevenlabs webhook", "elevenlabs events", "elevenlabs webhook signature", "handle elevenlabs notifications", "elevenlabs post-call webhook", "elevenlabs transcription webhook".From its SKILL.md

Install
npx -y skills add ComeOnOliver/skillshub --skill elevenlabs-webhooks-events

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

What its file declares

Copied from the file, not written here

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

9.3 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it

ElevenLabs Webhooks & Events

Overview

ElevenLabs webhooks send HTTP POST notifications when async operations complete. Supported event types include transcription completion, post-call data from Conversational AI agents, and call initiation failures. Webhooks use HMAC-SHA256 signatures for verification.

Prerequisites

  • ElevenLabs account (webhooks configured in Settings > Webhooks)
  • HTTPS endpoint accessible from the internet
  • Webhook secret (generated during webhook creation in dashboard)

Instructions

Step 1: Webhook Event Types

Event TypePayloadWhen Triggered
post_call_transcriptionFull conversation transcript, analysis, metadataAfter Conversational AI call ends
post_call_audioBase64-encoded call audio, minimal metadataAfter call ends (if audio recording enabled)
call_initiation_failureFailure reason, metadataWhen an outbound call fails to connect
speech_to_text.completedTranscription result, word timestampsAsync STT job completes

Step 2: Webhook Setup

# Create webhook in ElevenLabs dashboard:
# Settings > Webhooks > Create Webhook
# - URL: https://your-app.com/webhooks/elevenlabs
# - Select event types to subscribe to
# - Copy the generated HMAC secret

Step 3: HMAC Signature Verification

// src/elevenlabs/webhook-verify.ts
import crypto from "crypto";

/**
 * Verify the ElevenLabs-Signature header using HMAC-SHA256.
 *
 * Header format: t=<unix_timestamp>,v1=<hex_signature>
 * Signed payload: "<timestamp>.<raw_body>"
 */
export function verifyWebhookSignature(
  rawBody: string | Buffer,
  signatureHeader: string,
  secret: string
): { valid: boolean; reason?: string } {
  if (!signatureHeader || !secret) {
    return { valid: false, reason: "Missing signature header or secret" };
  }

  // Parse header: t=1234567890,v1=abcdef...
  const parts = new Map(
    signatureHeader.split(",").map(p => {
      const [key, ...val] = p.split("=");
      return [key, val.join("=")] as [string, string];
    })
  );

  const timestamp = parts.get("t");
  const signature = parts.get("v1");

  if (!timestamp || !signature) {
    return { valid: false, reason: "Malformed signature header" };
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 300) {
    return { valid: false, reason: `Timestamp too old: ${age}s` };
  }

  // Compute expected HMAC
  const signedPayload = `${timestamp}.${rawBody.toString()}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Timing-safe comparison
  try {
    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex")
    );
    return { valid: isValid };
  } catch {
    return { valid: false, reason: "Signature length mismatch" };
  }
}

Step 4: Express Webhook Handler

// src/api/webhooks/elevenlabs.ts
import express from "express";
import { verifyWebhookSignature } from "../../elevenlabs/webhook-verify";

const router = express.Router();

// CRITICAL: Use raw body parser for signature verification
router.post("/webhooks/elevenlabs",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["elevenlabs-signature"] as string;
    const secret = process.env.ELEVENLABS_WEBHOOK_SECRET!;

    const { valid, reason } = verifyWebhookSignature(req.body, signature, secret);

    if (!valid) {
      console.error("Webhook verification failed:", reason);
      return res.status(401).json({ error: "Invalid signature" });
    }

    // Return 200 immediately to prevent webhook auto-disable
    res.status(200).json({ received: true });

    // Process asynchronously
    const event = JSON.parse(req.body.toString());
    processEvent(event).catch(err =>
      console.error("Webhook processing failed:", err)
    );
  }
);

// Event routing
async function processEvent(event: any) {
  const eventType = event.type || event.event_type;

  switch (eventType) {
    case "post_call_transcription":
      await handleTranscription(event);
      break;
    case "post_call_audio":
      await handleCallAudio(event);
      break;
    case "call_initiation_failure":
      await handleCallFailure(event);
      break;
    case "speech_to_text.completed":
      await handleSTTCompleted(event);
      break;
    default:
      console.log("Unhandled event type:", eventType);
  }
}

Step 5: Event Handlers

// Conversational AI post-call transcript
async function handleTranscription(event: any) {
  const {
    conversation_id,
    transcript,       // Full conversation text
    analysis,         // AI analysis of the call
    metadata,         // Custom metadata from agent config
    recording_url,    // Audio recording URL (if enabled)
  } = event.data;

  console.log(`[Transcript] Conversation ${conversation_id}`);
  console.log(`Transcript: ${transcript?.substring(0, 200)}...`);

  // Store in your database
  // await db.conversations.upsert({ conversation_id, transcript, analysis });
}

// Post-call audio recording
async function handleCallAudio(event: any) {
  const {
    conversation_id,
    audio_base64,     // Base64-encoded audio of the full conversation
  } = event.data;

  if (audio_base64) {
    const audioBuffer = Buffer.from(audio_base64, "base64");
    console.log(`[Audio] Received ${audioBuffer.length} bytes for ${conversation_id}`);
    // Save audio: await fs.writeFile(`recordings/${conversation_id}.mp3`, audioBuffer);
  }
}

// Failed outbound call
async function handleCallFailure(event: any) {
  const {
    conversation_id,
    failure_reason,
    metadata,
  } = event.data;

  console.error(`[Call Failed] ${conversation_id}: ${failure_reason}`);
  // Alert: await alerting.notify("Call initiation failed", { conversation_id, failure_reason });
}

// Async Speech-to-Text completion
async function handleSTTCompleted(event: any) {
  const {
    transcription_id,
    text,
    words,           // Word-level timestamps
    language,
  } = event.data;

  console.log(`[STT Complete] ${transcription_id}: ${language}`);
  console.log(`Text: ${text?.substring(0, 200)}...`);
  // Process transcription results
}

Step 6: Idempotency Protection

// Prevent duplicate processing if ElevenLabs retries delivery
const processedEvents = new Set<string>();

async function withIdempotency(
  eventId: string,
  handler: () => Promise<void>
): Promise<void> {
  if (processedEvents.has(eventId)) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }

  await handler();
  processedEvents.add(eventId);

  // Clean up old entries (in production, use Redis with TTL)
  if (processedEvents.size > 10000) {
    const oldest = Array.from(processedEvents).slice(0, 5000);
    oldest.forEach(id => processedEvents.delete(id));
  }
}

Step 7: Local Testing with ngrok

# Expose local server to internet
ngrok http 3000

# Use the ngrok URL as webhook endpoint in ElevenLabs dashboard
# https://abc123.ngrok.io/webhooks/elevenlabs

# Test with curl (simulated event)
curl -X POST http://localhost:3000/webhooks/elevenlabs \
  -H "Content-Type: application/json" \
  -H "ElevenLabs-Signature: t=$(date +%s),v1=test" \
  -d '{"type":"speech_to_text.completed","data":{"text":"Hello world"}}'

Webhook Reliability

BehaviorDetail
Retry policyElevenLabs retries failed deliveries
Auto-disableAfter 10 consecutive failures AND 7+ days since last success
TimeoutYour endpoint must respond within a few seconds
Re-enableManually re-enable in dashboard after fixing the endpoint
AuthenticationHMAC-SHA256 via ElevenLabs-Signature header

Error Handling

IssueCauseSolution
Signature mismatchWrong secret or body parsingUse express.raw(), verify secret matches dashboard
Webhook auto-disabled10+ consecutive failuresFix endpoint, re-enable in dashboard
Duplicate eventsRetried deliveryImplement idempotency with event ID tracking
Handler timeoutSlow processingReturn 200 immediately, process async
Replay attackOld timestamp reusedCheck timestamp age (reject > 5 min)

Resources

Next Steps

For performance optimization, see elevenlabs-performance-tuning.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.