Retell AI events

Endpoint

POST /api/webhooks/event-logger

Dispatcher

WebhookEventDispatcher in lib/events/event-handler.ts

Event type Handler Status

call_analyzed

RetellCallCompletedHandler (lib/events/handlers/call-ended.ts)

Registered

call_started

RetellCallStartedHandler (lib/events/handlers/call-started.ts)

Not registered — the line is commented out

Two implementations of RetellCallCompletedHandler exist:

  • lib/events/handlers/call-ended.ts — a single class holding extraction, the state machine and CRM hand-off.

  • lib/events/handlers/call-ended/ — a decomposed version with CallDataParser, StateDecisionEngine, PrismaDatabaseAdapter and HubspotEligibilityChecker.

lib/events/handlers/index.ts re-exports "./call-ended", which resolves to the file, not the directory. The single-class implementation is therefore the one that runs; the decomposed version is reachable only through createRetellCallCompletedHandler(), which nothing currently calls.

The two differ in behaviour — notably the decomposed StateDecisionEngine implements an odd/even double-tap schedule that the live handler does not. Read call-ended.ts when reasoning about production behaviour.

Because call_started is unregistered, the dialing state and the Call row are created by initiateCall, and the attempt counter is incremented when the call ends.

call_analyzed handler

Correlation comes from Retell metadata: call.metadata.campaignId and call.metadata.campaignTargetId. Without both, the handler logs and returns — an inbound or manually-created Retell call has no campaign context.

1. Technical metrics

duration_ms, disconnection_reason, channel_type, call_cost.combined_cost, call_cost.total_duration_unit_price (stored as latencyMs), start_timestamp, end_timestamp.

2. AI analysis

From call.call_analysis: in_voicemail, call_screening_detected, ai_suspicion_detected, user_sentiment, detected_language, call_summary, call_successful.

3. Business extraction

From call_analysis.custom_analysis_data: call_disposition (or disposition), next_action, termination_reason, is_amazon_seller, meets_sales_threshold, is_decision_maker, callback_requested, proposed_callback_time, monthly_revenue, and the contact name.

The field vocabulary is defined by DEFAULT_POST_CALL_ANALYSIS in lib/retellai/post-call-analysis.ts — the shared contract between the caller agent, Retell and this handler.

4. State decision

Priority order; the first match wins.

Priority Rule

1

stopCalling → state = DNC, attempts += 999999

2

disposition invalid_destination → state = failed, attempts += 999999, lastError = "Invalid destination"

3

Recoverable — voicemail, screening, technical failure (dial_no_answer, dial_busy, dial_failed, inactivity, machine_detected), busy without a callback request, Hangup_Early, duration under 10 s, or a disposition in NEGATIVE_DISPOSITIONS_TO_RECALL. If attempts < (maxAttempts ?? 10) → state = in_progress, nextEarliestAttemptAt = now + waitHoursToRecall

4

Default → state = completed

The full cooling-off window is used for every recoverable outcome so the broadcast loop cannot race the Inngest double tap. If Inngest fails entirely, the cooling-off window is the fallback retry.

5. Database updates

CampaignTarget

state, attempts (increment), updatedAt, lastAttemptAt, nextEarliestAttemptAt, isAmazonSeller, amazonSeller (raw AI answer, defaulting to "Undetermined"), meetsMonthlyThreshold, stopCalling, disposition, lastCallRecordingUrl, nextAction, lastError, validConversation (durationSec >= 10), leaseExpiresAt = null.

Call

updated by (providerCallId, targetId) — status = ended, durations, cost, latency, channelType, disconnectionReason, retellDisposition and disposition (only when a disposition exists), the detection flags, callSuccessful, isDecisionMaker, nextAction, terminationReason, contactName, proposedCallbackTime, recordingUrl and transcript (only when present), userSentiment (defaulting to "Unknown"), summary, monthlyRevenue, meetsMonthlyThreshold, startTimestamp, endTimestamp.

Conditional spreads are used so a webhook that omits a field does not overwrite a good value with null.

6. CRM hand-off

Eligible when durationSec >= 10, not voicemail, not screened, the disposition is in HUBSPOT_WHITE_LISTED_DISPOSITIONS, and the target is not a test target.

When eligible, the handler publishes Crm.DialerCallEnded to the CRM SQS queue with the workspace id as messageGroupId:

{
  "event_type": "Crm.DialerCallEnded",
  "source": "dialer",
  "subscription_id": "uuid",
  "call_id": "uuid",
  "provider_call_id": "retell_call_id",
  "campaign_id": "uuid",
  "campaign_name": "Campaign Name",
  "target_id": "uuid",
  "disposition": "Qualified_Lead",
  "duration_sec": 120
}

The direct HubSpot API sync (hubSync.syncCall) remains in the source, commented out. CRM writes are now the downstream consumer’s responsibility. Using the workspace id as the message group preserves per-tenant ordering on a FIFO queue.

7. Emit call-target/call.ended

Fire-and-forget, with the failure logged:

{
  callId, providerCallId, campaignId, campaignName, subscriptionId,
  targetId, targetPhoneNumber, targetPayload,
  disposition, disconnectionReason, durationSec,
  inVoicemail, callScreeningDetected, newState,
}

This decouples post-call work from the webhook. callEndedAnalysis consumes it — see callEndedAnalysis.

call_started handler (dormant)

If re-registered, it would validate campaignId and campaignTargetId, confirm the direction is outbound, set the target to dialing, increment attempts, and set the Call to dialing with its start timestamp. Re-registering it without also removing the increment in call-ended.ts would double-count attempts.