From elevenlabs-pack
Verifies HMAC signatures and handles ElevenLabs webhook events for transcription completion, call audio, initiation failures, and Conversational AI post-call data.
npx claudepluginhub jeremylongshore/claude-code-plugins-plus-skills --plugin elevenlabs-packThis skill is limited to using the following tools:
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.
Implements ElevenLabs security: API key management with git hooks and env vars, TypeScript config validation, and webhook HMAC signature verification using Node crypto.
Implements AssemblyAI webhook handlers for transcription completion in Express.js, with auth verification and transcript fetching.
Implements Deepgram callbacks and webhooks for async audio transcription, including SDK submission, Express server with HMAC verification, and result processing.
Share bugs, ideas, or general feedback.
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.
| Event Type | Payload | When Triggered |
|---|---|---|
post_call_transcription | Full conversation transcript, analysis, metadata | After Conversational AI call ends |
post_call_audio | Base64-encoded call audio, minimal metadata | After call ends (if audio recording enabled) |
call_initiation_failure | Failure reason, metadata | When an outbound call fails to connect |
speech_to_text.completed | Transcription result, word timestamps | Async STT job completes |
# 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
// 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" };
}
}
// 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);
}
}
// 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
}
// 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));
}
}
# 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"}}'
| Behavior | Detail |
|---|---|
| Retry policy | ElevenLabs retries failed deliveries |
| Auto-disable | After 10 consecutive failures AND 7+ days since last success |
| Timeout | Your endpoint must respond within a few seconds |
| Re-enable | Manually re-enable in dashboard after fixing the endpoint |
| Authentication | HMAC-SHA256 via ElevenLabs-Signature header |
| Issue | Cause | Solution |
|---|---|---|
| Signature mismatch | Wrong secret or body parsing | Use express.raw(), verify secret matches dashboard |
| Webhook auto-disabled | 10+ consecutive failures | Fix endpoint, re-enable in dashboard |
| Duplicate events | Retried delivery | Implement idempotency with event ID tracking |
| Handler timeout | Slow processing | Return 200 immediately, process async |
| Replay attack | Old timestamp reused | Check timestamp age (reject > 5 min) |
For performance optimization, see elevenlabs-performance-tuning.