Bug Fix

Fix duplicate call webhooks and normalize call event payloads

A single dialer call could previously emit several callsUpdated webhooks in quick succession as Twilio sent the call status, recording, AMD result, transcript, and AI enrichment in separate writes. The same call could also leak Twilio’s dial-time ring duration into “contacted” metrics. This release addresses both:

  • One event per meaningful change. callsUpdated now fires only when a field included in the public payload actually changes (phone, note, isIncoming, duration, personId, callStatus, outcome, occurredAt). Internal enrichment writes (recording, transcript, sentiment, AMD, AI summary) no longer trigger duplicate events.
  • callStatus is a pure lifecycle value. Twilio terminal outcomes (no-answer, busy, failed, canceled) are surfaced through outcome, while callStatus reports completed. Any user-set outcome (e.g. “left voicemail”) still takes precedence. This is a payload-shape change — partner integrations that branch on callStatus == "no-answer" should switch to checking outcome.
  • Unanswered Twilio calls no longer report ring time as duration. When a Twilio call ends with a terminal outcome and no user-set outcome, duration is reported as 0. Partner-API-logged calls keep the integrator-supplied duration.
  • Contact metrics ignore unanswered calls. last_contacted_at and last_conversation_at no longer move forward on no-answer/busy/failed/canceled calls.
  • Timestamps are UTC ISO8601. occurredAt and createdAt on the call payload and eventCreated on every webhook envelope (eventCreated now includes millisecond precision: 2026-05-14T17:39:00.123Z).