ElevenLabs post-call events

ElevenLabs posts a webhook after each agent conversation ends. The route verifies the signature, decodes the JSON body, and stores it. There is no dispatcher and no handler; nothing else happens.

Endpoint

POST /api/webhooks/elevenlabs

Helpers

lib/utils/elevenlabs/webhook.ts

Persistence

WebhookEventRepository.record() in lib/repositories/webhook-event-repository.ts

Configure the URL as the ElevenLabs workspace post-call webhook (ElevenAgents settings).

Route flow

  1. Require ELEVENLABS_WEBHOOK_SECRET. When it is unset, return 503.

  2. Verify the ElevenLabs-Signature header, t=<unix seconds>,v0=<hex>, where v0 is HMAC-SHA256 of {t}.{rawBody} keyed with the secret. Reject with 401 when the header is missing or malformed, the timestamp is more than 30 minutes from now, or no v0 matches. Only the reason (missing_headers, malformed_header, stale_timestamp, invalid_signature) is logged.

  3. Parse the body as JSON. Invalid JSON or a non-object returns 400 { error: "Invalid body" }.

  4. Upsert a WebhookEvent row with source = "ElevenLabs", idempotent on the event id.

  5. Return 200 { received: true, event_type }.

A database failure returns 500, which ElevenLabs retries when retries are enabled on the webhook. ElevenLabs never retries a 4xx.

Raw bodies are never logged. Transcription events contain the full conversation transcript, dynamic variables, and phone details. The route logs only the event type, event id, and conversation id.

Event types

eventType is the top-level type, or unknown when it is missing.

type data contents

post_call_transcription

agent_id, conversation_id, status, transcript, metadata (timing, cost, phone details), analysis (summary, evaluation and data collection results), conversation_initiation_client_data, and the has_audio flags

post_call_audio

agent_id, conversation_id, and full_audio, a base64 MP3 of the whole call

call_initiation_failure

agent_id, conversation_id, failure_reason (busy, no-answer, unknown), and metadata `{ type: "sip"

Audio

post_call_audio is stored without its audio. data.full_audio is replaced by full_audio_omitted: true and full_audio_base64_length, so the row records that audio was sent and how large it was.

Leave "Send audio data" off in ElevenLabs unless audio is needed. Vercel rejects request bodies over about 4.5 MB with 413 before the route runs, so audio for longer calls never arrives. ElevenLabs does not retry 4xx, and repeated failures can disable the webhook automatically.

Correlation and idempotency

providerCallId is data.conversation_id.

ElevenLabs sends no event id. A conversation produces at most one event of each type, so eventId is {type}:{conversation_id}, for example post_call_transcription:abc. A retry of the same event dedupes on it. When type or conversation_id is missing, eventId is sha256:<hex of the raw body> and eventIdGenerated is true.

Example

{
  "type": "call_initiation_failure",
  "event_timestamp": 1759931652,
  "data": {
    "agent_id": "xyz",
    "conversation_id": "abc",
    "failure_reason": "busy",
    "metadata": {
      "type": "sip",
      "body": { "sip_status_code": 486, "error_reason": "Busy here", "call_sid": "d8e7f6a5-..." }
    }
  }
}

Stored with eventId = "call_initiation_failure:abc" and providerCallId = "abc".