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()inlib/repositories/webhook-event-repository.ts
Configure the URL as the ElevenLabs workspace post-call webhook (ElevenAgents settings).
Route flow
-
Require
ELEVENLABS_WEBHOOK_SECRET. When it is unset, return503. -
Verify the
ElevenLabs-Signatureheader,t=<unix seconds>,v0=<hex>, wherev0is HMAC-SHA256 of{t}.{rawBody}keyed with the secret. Reject with401when the header is missing or malformed, the timestamp is more than 30 minutes from now, or nov0matches. Only the reason (missing_headers,malformed_header,stale_timestamp,invalid_signature) is logged. -
Parse the body as JSON. Invalid JSON or a non-object returns
400 { error: "Invalid body" }. -
Upsert a
WebhookEventrow withsource = "ElevenLabs", idempotent on the event id. -
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 |
|---|---|
|
|
|
|
|
|
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".