Skip to content

CRM Call Integration - Voice Backend Documentation ​

This document describes how Oktivo Voice backend handles calls initiated from Oktivo CRM, including Human Calls, Phone-to-Phone Calls, and AI Agent Calls.

Table of Contents ​

  1. Overview
  2. Architectural Decision: Single Contact Collection
  3. CRM OAuth System
  4. Human Call Processing
  5. Phone-to-Phone Call Processing
  6. AI Agent Call Processing
  7. Webhook Delivery to CRM
  8. Contact Record Storage
  9. API Reference
  10. Data Flow Diagrams

Overview ​

The Voice backend provides three call types for CRM integration:

Call TypeEndpointContact UID FormatCampaign UID FormatcallType Field
Human CallPOST /api/crm/calls/humancrm_hcall_{nanoid16}crm_human_{crmOrgId}human
Phone-to-PhonePOST /api/crm/calls/phone-to-phonecrm_p2pcall_{nanoid16}crm_p2p_{crmOrgId}phone_to_phone
AI Agent CallPOST /api/v1/crm/callsapi_{nanoid21}api_call_{nanoid21}ai_agent

All call types:

  • Store data in the Contact collection (single source of truth)
  • Use callType field to distinguish call types
  • Include CRM metadata for correlation
  • Trigger call.completed webhooks to CRM

Note (native in-app lead calls vs. these external-CRM endpoints). The endpoints above are the external-CRM OAuth integration. The native in-app lead-call endpoints — POST /api/leads/:leadId/calls/{web,phone-to-phone,agent} used from the Lead Detail → Call tab — are now gated by three separate paid entitlements: web_call, phone_to_phone_call, and ai_call respectively (requireFeatureAccess(...) layered above crm.view). A CRM-only customer with no call feature can work leads but cannot place calls. For back-compat, holding the legacy voice_campaign feature implies ai_call at entitlement-resolution time (no DB migration). See FEATURE-ENTITLEMENTS-AND-BUNDLES.md and CRM-CALLING-ARCHITECTURE.md.


Architectural Decision: Single Contact Collection ​

IMPORTANT: All CRM-initiated calls are now stored in the Contact collection only.

Key Points ​

  1. No separate collections - HumanCall and PhoneToPhoneCall collections are deprecated
  2. callType field - Distinguishes between ai_agent, human, and phone_to_phone
  3. CRM fields on Contact - New fields added to Contact schema:
    • callType - Enum: ai_agent | human | phone_to_phone
    • crmConnectionId - CRM OAuth connection ID
    • crmOrganizationId - CRM organization identifier
    • crmUserId - CRM user who initiated the call
    • crmLeadId - CRM lead reference
    • agentPhone - Agent's phone number (for phone-to-phone calls)
    • transcriptionStatus - Processing status: pending | processing | completed | failed
    • analysisStatus - Processing status: pending | processing | completed | failed

Benefits ​

  • Unified call history - Single collection for all call types
  • Simplified queries - Use callType filter for type-specific queries
  • Consistent webhook payload - Same call.completed event for all call types
  • Easier maintenance - No need to manage multiple call collections

CRM OAuth System ​

Architecture ​

Token Exchange Flow ​

Token Refresh Flow ​

Authentication Middleware ​

The crmAuth.middleware validates Bearer tokens and injects connection context:

typescript
// Request header
Authorization: Bearer <access_token>

// Injected into request
req.crmConnection = {
  connectionId: "conn_abc",
  oktivoOrgId: ObjectId("..."),
  crmOrganizationId: "crm_org_123",
  // ...
};
req.oktivoOrgId = ObjectId("...");

Human Call Processing ​

Human calls enable CRM users to place browser-to-phone calls via WebRTC + SIP bridging.

Human Call Architecture ​

Human Call State Machine ​

Human Call Processing Steps ​

Transcription & analysis are produced LIVE, not from the recording. The Agent's cloud_telephony_handler.py runs an in-room STT listener (RoomTranscriber, preset-driven from the human_agent) during the call and, on call end, syncs the transcript and triggers analysis via POST /transcription-sync + POST /analyse/conversation (bridge_post_call.py). The egress_ended handler above only saves the recording for playback it no longer calls /analyse/crm-recording. Recording-based STT (Sarvam) remains available only for manual retry / reanalyze (HumanCallService.reprocessCall).

Call Status Updates via Data Channel ​

The service broadcasts call status updates to the CRM frontend via LiveKit data channel:

typescript
interface CallStatusMessage {
  type: "call_status";
  status: "connecting" | "ringing" | "in_progress" | "ended" | "failed";
  duration: number | null;
  disconnectReason: string | null;
  timestamp: string;
}

Phone-to-Phone Call Processing ​

Phone-to-Phone calls enable CRM agents to call customers from their own phone number, with both parties connected via SIP bridging through LiveKit.

Phone-to-Phone Architecture ​

Phone-to-Phone Call State Machine ​

Phone-to-Phone Processing Steps ​

Transcription & analysis are produced LIVE, not from the recording. The Agent's phone_bridge_handler.py runs an in-room STT listener (RoomTranscriber, preset-driven from the human_agent) once both SIP participants connect and, on call end, syncs the transcript and triggers analysis via POST /transcription-sync + POST /analyse/conversation (bridge_post_call.py). The egress_ended handler above only saves the recording for playback it no longer calls /analyse/crm-recording. Recording-based STT (Sarvam) remains available only for manual retry / reanalyze (PhoneToPhoneCallService.reprocessCall).

Recording, answer time, and "not picked up" (2-participant calls) ​

Phone-to-phone and human-callback calls bridge two people — an initiator (person 1) and a callee (person 2). The callee's phone starts ringing while LiveKit has already created the SIP participant, so a bare "participant joined" event does not mean the callee answered. Getting the answer moment wrong over-bills the whole ring as talk time and records the ring tone.

The single authoritative answer signal is callAnsweredAt. It is set only by the Agent's POST /start-recording, which the Python bridge handler calls after create_sip_participant(wait_until_answered=True) returns — i.e. the exact instant person 2 picks up. Recording is also started there, not on join.

Phone-to-phoneHuman callback (inbound)
Person 1 (initiator)CRM agent's phoneInbound lead's phone
Person 2 (callee)Customer's phoneAssigned member / human-agent phone
Sets callAnsweredAtPOST /start-recording after customer answers (handleCustomerJoined only sets customer_ringing)handleAgentAnswered when the member picks up

Duration. Two numbers are stored:

  • callDuration / billingDuration — billable talk time, measured strictly from callAnsweredAt (person 2 answered) to call end. This is what pricing uses. If callAnsweredAt was never set, it is 0 (no fallback to person 1's join, which would include the ring).
  • totalDuration — full session from person 1's connect (participantJoinedAt → callStartedAt fallback) to end. Display only, never billed. Always ≥ billingDuration.

Status when the callee never answers. The call is not a failure — the callee just didn't pick up. endCall sets:

  • status: "failed" (no billable conversation), and
  • callStatus: "not_answered" — the UI keys off callStatus and shows "Not picked up" instead of the misleading "Failed".

Disconnect reasons distinguish who didn't answer:

Call typeReason when unansweredHistory label
Phone-to-phonecustomer_no_answer, customer_unavailable, customer_rejected"Not picked up" / "Customer …"
Human callbackagent_no_answer"Agent did not pick up"

Unanswered calls are not charged — endCall only invokes credit consumption when callWasAnswered && billingDuration > 0.

Phone-to-Phone Status Messages ​

typescript
interface PhoneToPhoneStatusMessage {
  type: "p2p_call_status";
  status: "connecting" | "calling_agent" | "agent_ringing" | "agent_answered" |
          "calling_customer" | "customer_ringing" | "in_progress" | "ended" | "failed";
  duration: number | null;
  disconnectReason: string | null;
  timestamp: string;
}

Human vs Phone-to-Phone Comparison ​

AspectHuman CallPhone-to-Phone Call
Agent ConnectionBrowser (WebRTC)Phone (SIP)
Agent's Phone NeededNoYes
Browser RequiredYes (for agent)No
callTypehumanphone_to_phone
Contact UIDcrm_hcall_*crm_p2pcall_*
Campaign UIDcrm_human_{orgId}crm_p2p_{orgId}
RecordingYesYes
TranscriptionYesYes
AnalysisYesYes
Audio Mixing ModeDUAL_CHANNEL_AGENTDUAL_CHANNEL_ALTERNATE

Phone-to-Phone Recording & Transcription ​

P2P calls have specific handling for recording and transcription due to the nature of SIP-to-SIP audio:

Audio Mixing Mode:

  • P2P calls use DUAL_CHANNEL_ALTERNATE audio mixing mode for LiveKit Egress recording
  • This mode interleaves audio from both SIP participants into separate channels
  • Human calls use DUAL_CHANNEL_AGENT which expects a LiveKit "agent" participant (WebRTC)

Transcription Processing:

  • Deepgram processes the stereo recording with multichannel mode enabled
  • Speaker diarization uses channel separation (Channel 0 = first speaker, Channel 1 = second speaker)
  • If one channel has content and the other is empty, the single-channel content is still used
  • Falls back to diarization mode only if both channels are empty or identical (mono recorded as stereo)

Webhook Eligibility (P2P-specific): P2P calls use more lenient completion criteria for webhook delivery:

  • status is done or failed
  • Recording exists (recordingUrl or s3RecordingKey)
  • Transcription was attempted (transcriptionStatus is completed or failed)
  • Analysis is optional - may fail for short calls with insufficient content
typescript
// P2P webhook eligibility check
const transcriptionDone = contact.transcriptionStatus === "completed" || contact.transcriptionStatus === "failed";
const isP2PComplete = contact && (
  (contact.status === "done" || contact.status === "failed") &&
  (contact.recordingUrl || contact.s3RecordingKey) &&
  transcriptionDone
);
// Note: analysisStatus is NOT required to be "completed" for P2P webhook delivery

AI Agent Call Processing ​

AI Agent calls dispatch an AI agent to call a lead automatically.

AI Agent Call Architecture ​

AI Agent Call Orchestration ​

CRM Metadata in Contact ​

When calls originate from CRM, the Contact record includes CRM identifiers for correlation:

typescript
// dynamicVariables from CRM
{
  crmOrganizationId: "org_abc",
  crmLeadId: "lead_xyz", 
  crmUserId: "user_123",
  // ... user-provided context
}

// Stored in Contact.metadata
{
  crmOrganizationId: "org_abc",
  crmLeadId: "lead_xyz",
  crmUserId: "user_123",
  source: "crm_ai_call"  // vs "crm_human_call"
}

Webhook Delivery to CRM ​

The Voice backend delivers call completion data to CRM via webhooks.

Webhook Delivery Flow ​

Contact Completion Criteria ​

A Contact is considered complete when ALL of these are present:

  • callStatus (not null)
  • transcription (not null)
  • analysis (not null)
  • recordingUrl (not null)

Webhook Payload for CRM ​

typescript
{
  id: "{contactUID}:call.completed",
  version: "1.0",
  type: "call.completed",
  timestamp: "2024-01-15T10:30:00Z",
  data: {
    contactUID: "crm_hcall_abc123" | "api_def456",
    phone: "+14155551234",
    campaignUID: "crm_human_org_123" | "api_call_xyz",
    agentId: "crm_user_456" | "agent_789",
    orgUID: "org_ABC",
    callStatus: "ended",
    disconnectReason: "prospect_hangup",
    billingDuration: 180,
    callDuration: 175,
    attempts: 1,
    transcription: {
      items: [
        { role: "user", content: "Hello?" },
        { role: "assistant", content: "Hi, this is..." }
      ]
    },
    analysis: {
      is_interested: true,
      sentiment: "positive",
      summary: "Customer showed interest in the premium plan..."
    },
    recordingUrl: "https://s3.../recording.mp4?X-Amz-Signature=..."
  }
}

Webhook Signature ​

X-Oktivo-Signature: <HMAC-SHA256 hex>
X-Oktivo-Timestamp: <Unix timestamp>
X-Oktivo-Event: call.completed
X-Oktivo-Delivery-Id: <unique delivery ID>

Signature = HMAC-SHA256(secret, "{timestamp}.{rawBody}")

Contact Record Storage ​

All CRM-initiated calls are stored in the Contact collection for unified history.

Contact Collection Schema for CRM Calls ​

The Contact collection now includes CRM-specific fields for all call types:

typescript
interface Contact {
  // ===== CORE IDENTIFIERS =====
  contactUID: string;           // Unique ID (format varies by call type)
  campaignUID: string;          // Campaign/batch identifier
  phone: string;                // Customer's phone (E.164)
  originalPhone: string;        // Original phone number
  name: string;                 // Customer's name
  AgentID: string;              // AI agent ID or "crm_user_{userId}"
  orgUID: string;               // Oktivo organization UID
  customerUID: string | null;   // Customer UID (Oktivo user)
  
  // ===== CALL TYPE FIELD (NEW) =====
  callType: "ai_agent" | "human" | "phone_to_phone" | "human_callback";  // Default: "ai_agent"
  
  // ===== CRM FIELDS (NEW) =====
  crmConnectionId: string | null;     // CRM OAuth connection ID
  crmOrganizationId: string | null;   // CRM organization ID
  crmUserId: string | null;           // CRM user who initiated call
  crmLeadId: string | null;           // CRM lead reference
  agentPhone: string | null;          // Agent's phone (phone_to_phone only)
  
  // ===== PROCESSING STATUS (NEW) =====
  transcriptionStatus: "pending" | "processing" | "completed" | "failed";
  analysisStatus: "pending" | "processing" | "completed" | "failed";
  
  // ===== CALL STATUS & TIMING =====
  status: "pending" | "in_progress" | "done" | "failed";
  callStatus: string | null;
  callDirection: "inbound" | "outbound";
  callStartedAt: Date | null;
  callEndedAt: Date | null;
  callAnsweredAt: Date | null;   // Person-2 answer moment (authoritative)
  callDuration: number | null;   // Billable talk time (from callAnsweredAt)
  billingDuration: number | null;// = callDuration; 0 if never answered
  totalDuration: number | null;  // Full session (person-1 side), display only
  disconnectReason: string | null;
  
  // ===== LIVEKIT DATA =====
  roomId: string | null;
  egressId: string | null;
  sipTrunkId: string | null;
  livekitData: {
    sipPhoneNumber: string;
    sipTrunkPhoneNumber: string;
    sipCallStatus: string;
    roomEvents: Array<{ event: string; timestamp: string }>;
  } | null;
  
  // ===== RECORDING & ANALYSIS =====
  recordingUrl: string | null;
  s3RecordingKey: string | null;
  transcription: { items: TranscriptionItem[] } | null;
  provider_transcription: { conversation: ConversationItem[] } | null;
  analysis: object | null;
  
  // ===== METADATA =====
  metadata: object | null;
  
  // ===== TIMESTAMPS =====
  createdAt: Date;
  updatedAt: Date;
}

Database Indexes for CRM Calls ​

typescript
// CRM call history queries
contactSchema.index({ crmConnectionId: 1, callStartedAt: -1 });
contactSchema.index({ crmOrganizationId: 1, callStartedAt: -1 });
contactSchema.index({ callType: 1, crmOrganizationId: 1, callStartedAt: -1 });

Contact Record Examples ​

Human Call Contact ​

typescript
{
  contactUID: "crm_hcall_abc123xyz",
  campaignUID: "crm_human_org_123",
  phone: "+919876543210",
  originalPhone: "+919876543210",
  name: "John Doe",
  AgentID: "crm_user_user123",      // Identifies human caller
  orgUID: "org_ABC",
  customerUID: "cust_XYZ",
  
  // Call type
  callType: "human",
  
  // CRM fields
  crmConnectionId: "conn_123",
  crmOrganizationId: "crm_org_123",
  crmUserId: "crm_user_456",
  crmLeadId: "lead_789",
  agentPhone: null,                 // Not used for human calls
  
  // Processing status
  transcriptionStatus: "completed",
  analysisStatus: "completed",
  
  // Call data
  status: "done",
  callStatus: "ended",
  callDirection: "outbound",
  roomId: "hcall-room-abc123",
  sipTrunkId: "trunk_123",
  callDuration: 180,
  billingDuration: 180,
  recordingUrl: "https://s3.../recording.mp4",
  s3RecordingKey: "recordings/org_ABC/crm_hcall_abc123xyz.mp4",
  transcription: { items: [...] },
  analysis: { sentiment: "positive", summary: "...", is_interested: true },
  
  metadata: {
    crmConnectionId: "conn_123",
    crmOrganizationId: "crm_org_123",
    crmUserId: "crm_user_456",
    crmLeadId: "lead_789",
    humanCallId: "hcall_abc123xyz",
    source: "crm_human_call"
  }
}

Phone-to-Phone Call Contact ​

typescript
{
  contactUID: "crm_p2pcall_def456uvw",
  campaignUID: "crm_p2p_org_123",
  phone: "+919876543210",           // Customer's phone
  originalPhone: "+919876543210",
  name: "Jane Smith",
  AgentID: "crm_user_user456",
  orgUID: "org_ABC",
  customerUID: "cust_XYZ",
  
  // Call type
  callType: "phone_to_phone",
  
  // CRM fields
  crmConnectionId: "conn_123",
  crmOrganizationId: "crm_org_123",
  crmUserId: "crm_user_456",
  crmLeadId: "lead_456",
  agentPhone: "+919123456789",      // Agent's phone number
  
  // Processing status
  transcriptionStatus: "completed",
  analysisStatus: "completed",
  
  // Call data
  status: "done",
  callStatus: "ended",
  callDirection: "outbound",
  roomId: "p2p-room-def456",
  sipTrunkId: "trunk_123",
  callDuration: 270,
  billingDuration: 270,
  disconnectReason: "customer_hangup",
  
  livekitData: {
    sipPhoneNumber: "+18001234567",
    sipTrunkPhoneNumber: "+18001234567",
    sipCallStatus: "completed",
    roomEvents: [
      { event: "call_initiated", timestamp: "2024-01-15T11:00:00Z" },
      { event: "agent_answered", timestamp: "2024-01-15T11:00:15Z" },
      { event: "customer_answered", timestamp: "2024-01-15T11:00:30Z" },
      { event: "call_ended", timestamp: "2024-01-15T11:05:00Z" }
    ]
  },
  
  recordingUrl: "https://s3.../recording.mp4",
  s3RecordingKey: "recordings/org_ABC/crm_p2pcall_def456uvw.mp4",
  transcription: { items: [...] },
  analysis: { sentiment: "positive", summary: "...", keyPoints: [...] },
  
  metadata: {
    oktivoOrgId: "org_id_abc",
    oktivoOrgUID: "org_ABC",
    sipTrunkId: "trunk_123",
    sipPhoneNumber: "+18001234567",
    phoneToPhoneCallId: "p2pcall_def456uvw",
    agentName: "Sales Rep",
    source: "crm_phone_to_phone_call"
  }
}

AI Agent Call Contact ​

typescript
{
  contactUID: "api_def456uvw",
  campaignUID: "api_call_ghi789jkl",
  phone: "+919876543210",
  name: "Jane Smith",
  AgentID: "SZ24gF9tGk-o",          // Actual AI agent ID
  orgUID: "org_ABC",
  customerUID: "cust_XYZ",
  
  // Call type
  callType: "ai_agent",
  
  // CRM fields (when initiated from CRM)
  crmConnectionId: null,            // May be null for direct API calls
  crmOrganizationId: "crm_org_123",
  crmUserId: "crm_user_789",
  crmLeadId: "lead_123",
  agentPhone: null,
  
  // Processing status
  transcriptionStatus: "completed",
  analysisStatus: "completed",
  
  // Call data
  status: "done",
  callStatus: "ended",
  callDirection: "outbound",
  callDuration: 260,
  billingDuration: 260,
  recordingUrl: "https://s3.../recording.mp4",
  transcription: { items: [...] },
  analysis: { sentiment: "positive", summary: "...", is_interested: true },
  
  metadata: {
    crmOrganizationId: "crm_org_123",
    crmLeadId: "lead_456",
    crmUserId: "crm_user_789",
    source: "crm_ai_call"
  }
}

Querying by Call Type ​

typescript
// Get all human calls for a CRM organization
const humanCalls = await Contact.find({
  callType: "human",
  crmOrganizationId: "crm_org_123"
}).sort({ callStartedAt: -1 });

// Get all phone-to-phone calls for a CRM connection
const p2pCalls = await Contact.find({
  callType: "phone_to_phone",
  crmConnectionId: "conn_123"
}).sort({ callStartedAt: -1 });

// Get all CRM-initiated calls (any type)
const allCrmCalls = await Contact.find({
  crmOrganizationId: "crm_org_123"
}).sort({ callStartedAt: -1 });

API Reference ​

OAuth Endpoints ​

MethodPathAuthDescription
POST/api/crm/oauth/tokenClient credentialsExchange code for tokens
POST/api/crm/oauth/refreshClient credentialsRefresh access token

Call Endpoints ​

MethodPathAuthDescription
POST/api/crm/calls/humanBearer tokenInitiate human call (WebRTC)
POST/api/crm/calls/phone-to-phoneBearer tokenInitiate phone-to-phone call (SIP-to-SIP)
POST/api/v1/crm/callsBearer/API tokenInitiate AI agent call (requires outboundNumber)
GET/api/crm/phone-numbersBearer tokenList the customer's outbound numbers (caller ID options)
GET/api/crm/calls/:callIdBearer tokenGet call details
POST/api/crm/calls/:callId/endBearer tokenEnd active call
GET/api/crm/calls/historyBearer tokenGet call history

Resource Endpoints ​

MethodPathAuthDescription
GET/api/crm/agentsBearer tokenList available agents
GET/api/crm/creditsBearer tokenGet credit balance

Human Call Request ​

typescript
POST /api/crm/calls/human
Authorization: Bearer <token>

{
  crmConnectionId: string;     // CRM connection ID
  crmOrganizationId: string;   // CRM organization ID
  crmUserId: string;           // CRM user ID
  crmLeadId: string;           // CRM lead ID
  phone: string;               // E.164 format
  leadName: string;            // 1-200 characters
}

// Response
{
  callId: string;              // hcall_{nanoid16}
  contactUID: string;          // crm_hcall_{nanoid16}
  roomName: string;
  roomToken: string;           // 2-hour TTL
  sipPhoneNumber: string;
}

Phone-to-Phone Call Request ​

typescript
POST /api/crm/calls/phone-to-phone
Authorization: Bearer <token>

{
  crmConnectionId: string;     // CRM connection ID
  crmOrganizationId: string;   // CRM organization ID
  crmUserId: string;           // CRM user ID
  crmLeadId?: string;          // CRM lead ID (optional)
  agentPhone: string;          // Agent's phone, E.164 format
  customerPhone: string;       // Customer's phone, E.164 format
  customerName: string;        // Customer's name for display
  agentName?: string;          // Agent's name for display (optional)
}

// Response
{
  callId: string;              // p2pcall_{nanoid16}
  contactUID: string;          // crm_p2pcall_{nanoid16}
  roomName: string;
  sipPhoneNumber: string;      // Outbound caller ID
  status: "connecting";
}

AI Agent Call Request ​

typescript
POST /api/v1/crm/calls
Authorization: Bearer <token>

{
  phone: string;               // E.164 format the lead to call
  name: string;                // Contact name
  agentId: string;             // AI agent ID
  outboundNumber: string;      // REQUIRED caller ID, must be owned by the customer
  dynamicVariables?: {         // Optional context
    [key: string]: string;     // max 20 entries
  };
}

// Response
{
  contactUID: string;          // api_*
  campaignUID: string;         // api_call_*
  status: "queued";
}

outboundNumber strict customer scope ​

outboundNumber is mandatory and validated against findOutboundByCustomerAndNumber(orgUID, connection.oktivoUserId, number), which matches only an active PhoneNumberAssignment owned by the customer account that authorized the connection, with direction of outbound or both.

There is no fallback to the organization's shared number. If the customer does not own the number, the request is rejected a CRM connection cannot place a call from a caller ID it does not own.

The validated number is passed through triggerCall as sipNumber into resolveCallConfig(orgUID, customerUID, sipNumber), pinning telephony resolution so the member→customer→org fallback inside resolveOutboundNumber is never reached on this path.

Campaigns, test calls, benchmarks and health checks are unchanged: they still use resolveOutboundNumber's customer→org fallback. Only the CRM AI-agent path is strict.

resolveOutboundNumber priority & determinism ​

resolveOutboundNumber(orgUID, customerUID?, sipNumber?, memberUID?) resolves the outbound caller ID in this order, returning the first match:

  1. Member allocation an active assignment with allocatedToMemberUID === memberUID (outbound/both). Set when a team member places the call (the native/P2P path passes ctx.actorUID as memberUID).
  2. Customer an active assignment for customerUID (outbound/both).
  3. Org an active org-level assignment (customerUID: null, outbound/both).

Each tier uses a deterministic .sort(...) (allocatedAt/assignedAt/_id) so the same number is chosen on every call. This fixed a bug where a member's phone-to-phone call would non-deterministically leave from one of the admin's numbers instead of the number the admin allocated to that member. Paths that do not pass memberUID (campaigns, benchmarks, health checks, verification) skip tier 1 and behave exactly as before.

ErrorHTTPCause
outbound_number_required400Field missing or blank
invalid_outbound_number400Not an active outbound assignment for this customer

List Outbound Numbers ​

typescript
GET /api/crm/phone-numbers
Authorization: Bearer <token>

// Response
{
  success: true,
  numbers: [
    { phoneUID: "phn_...", number: "+919429390801", direction: "both" }
  ];
}

An empty numbers array means the customer has no outbound number of their own and AI agent calls must be blocked by the caller not placed from another number.


Error Codes ​

CodeHTTP StatusDescription
CONNECTION_NOT_FOUND403CRM connection not found
CONNECTION_INACTIVE403CRM connection not active
INVALID_PHONE400Phone not in E.164 format
ORG_NOT_FOUND500Oktivo organization not found
ROOM_CREATION_FAILED500LiveKit room creation failed
AGENT_NOT_FOUND404Agent not found or not owned
INSUFFICIENT_CREDITS402Not enough credits
CONCURRENCY_LIMIT429At max concurrent calls
DISPATCH_FAILED500Call dispatch failed
TELEPHONY_NOT_CONFIGURED503SIP trunk not configured
AGENT_CALL_FAILED500Failed to call agent's phone (Phone-to-Phone)
CUSTOMER_CALL_FAILED500Failed to call customer's phone (Phone-to-Phone)
SIP_CONFIG_MISSING500Missing SIP configuration for call
duplicate_call409Agent already has an active P2P call in progress
rate_limited429Same customer called within last 30 seconds (P2P)

Phone-to-Phone Duplicate Call Prevention ​

The P2P call endpoint includes built-in protection against duplicate calls:

1. Active Agent Call Check (HTTP 409)

  • Before initiating a new P2P call, the system checks if the agent already has an active call
  • Checks for calls with status: connecting, calling_agent, agent_ringing, agent_answered, calling_customer, customer_ringing, in_progress
  • Only considers calls from the last 5 minutes (to avoid stale record blocking)
typescript
// Response when agent has active call
HTTP 409 Conflict
{
  "success": false,
  "message": "Agent already has an active call in progress. Please wait for the current call to end.",
  "error": "duplicate_call",
  "existingCallId": "crm_p2pcall_abc123",
  "existingCallStatus": "in_progress"
}

2. Recent Customer Call Check (HTTP 429)

  • Prevents rapid duplicate calls to the same customer
  • Checks if the customer was called within the last 30 seconds
typescript
// Response when customer was recently called
HTTP 429 Too Many Requests
{
  "success": false,
  "message": "A call to this customer was just initiated. Please wait before retrying.",
  "error": "rate_limited",
  "retryAfterSeconds": 30
}

Migration Notes ​

Deprecated Collections ​

The following collections are deprecated and should not be used for new code:

  • HumanCall - All human call data is now in Contact with callType: "human"
  • PhoneToPhoneCall - All phone-to-phone data is now in Contact with callType: "phone_to_phone"

Query Migration ​

Before (deprecated):

typescript
// Human calls
const humanCall = await HumanCall.findOne({ callId });
const contact = await Contact.findOne({ contactUID: `crm_hcall_${callId}` });

// Phone-to-phone calls
const p2pCall = await PhoneToPhoneCall.findOne({ callId });

After (current):

typescript
// Human calls - use Contact directly
const contact = await Contact.findOne({ 
  contactUID: `crm_hcall_${callId}`,
  callType: "human"
});

// Phone-to-phone calls - use Contact directly
const contact = await Contact.findOne({ 
  contactUID: `crm_p2pcall_${callId}`,
  callType: "phone_to_phone"
});

// Or query by room name
const contact = await Contact.findOne({ 
  roomId: roomName,
  callType: "phone_to_phone"
});

Human Callback (inbound → human agent) ​

callType: "human_callback" covers an inbound lead call that is bridged to a human agent (an assigned team member, or a number configured directly on the human_agent). humanAgentCallback.service.ts resolves the target and stamps the call's Contact.

  • Answer/timing/status follow the 2-participant rules above: handleAgentAnswered sets callAnsweredAt when the member picks up; handleCallEnded bills only from callAnsweredAt, sets callStatus: "not_answered" + reason agent_no_answer when the member never answers (UI: "Agent did not pick up"), and never charges an unanswered call.
  • The "To" number in history is the agent's redirected number, not the SIP trunk. The Contact stores agentPhone = the resolved member/human-agent phone; the history "To" column shows that number (or the agent name) for human-callback rows rather than the platform SIP number the lead dialed.
  • Last Contacted is stamped on the primary completion path here too (not just on analysis-ready), because a human callback may never produce a transcript/analysis. The stamp is forward-only and idempotent.

Tests ​

  • src/__tests__/p2p-call-answer-timing.test.ts — the 2-participant answer timing + "not picked up" rules for PhoneToPhoneCallService.endCall: unanswered → 0 billable duration + callStatus: "not_answered"; answered → billable duration measured strictly from callAnsweredAt; no over-bill fallback when callAnsweredAt is missing; idempotent re-end.
  • src/__tests__/p2p-fee-billing.integration.test.ts, src/__tests__/p2p-fee-rate-resolution.test.ts — P2P fee pricing.
bash
cd backend/client_backend
node node_modules/vitest/vitest.mjs run src/__tests__/p2p-call-answer-timing.test.ts

Last updated: