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
- Overview
- Architectural Decision: Single Contact Collection
- CRM OAuth System
- Human Call Processing
- Phone-to-Phone Call Processing
- AI Agent Call Processing
- Webhook Delivery to CRM
- Contact Record Storage
- API Reference
- Data Flow Diagrams
Overview
The Voice backend provides three call types for CRM integration:
| Call Type | Endpoint | Contact UID Format | Campaign UID Format | callType Field |
|---|---|---|---|---|
| Human Call | POST /api/crm/calls/human | crm_hcall_{nanoid16} | crm_human_{crmOrgId} | human |
| Phone-to-Phone | POST /api/crm/calls/phone-to-phone | crm_p2pcall_{nanoid16} | crm_p2p_{crmOrgId} | phone_to_phone |
| AI Agent Call | POST /api/v1/crm/calls | api_{nanoid21} | api_call_{nanoid21} | ai_agent |
All call types:
- Store data in the
Contactcollection (single source of truth) - Use
callTypefield to distinguish call types - Include CRM metadata for correlation
- Trigger
call.completedwebhooks 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, andai_callrespectively (requireFeatureAccess(...)layered abovecrm.view). A CRM-only customer with no call feature can work leads but cannot place calls. For back-compat, holding the legacyvoice_campaignfeature impliesai_callat entitlement-resolution time (no DB migration). SeeFEATURE-ENTITLEMENTS-AND-BUNDLES.mdandCRM-CALLING-ARCHITECTURE.md.
Architectural Decision: Single Contact Collection
IMPORTANT: All CRM-initiated calls are now stored in the Contact collection only.
Key Points
- No separate collections -
HumanCallandPhoneToPhoneCallcollections are deprecated - callType field - Distinguishes between
ai_agent,human, andphone_to_phone - CRM fields on Contact - New fields added to Contact schema:
callType- Enum:ai_agent|human|phone_to_phonecrmConnectionId- CRM OAuth connection IDcrmOrganizationId- CRM organization identifiercrmUserId- CRM user who initiated the callcrmLeadId- CRM lead referenceagentPhone- Agent's phone number (for phone-to-phone calls)transcriptionStatus- Processing status:pending|processing|completed|failedanalysisStatus- Processing status:pending|processing|completed|failed
Benefits
- Unified call history - Single collection for all call types
- Simplified queries - Use
callTypefilter for type-specific queries - Consistent webhook payload - Same
call.completedevent 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:
// 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.pyruns 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 viaPOST /transcription-sync+POST /analyse/conversation(bridge_post_call.py). Theegress_endedhandler 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:
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.pyruns 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 viaPOST /transcription-sync+POST /analyse/conversation(bridge_post_call.py). Theegress_endedhandler 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-phone | Human callback (inbound) | |
|---|---|---|
| Person 1 (initiator) | CRM agent's phone | Inbound lead's phone |
| Person 2 (callee) | Customer's phone | Assigned member / human-agent phone |
Sets callAnsweredAt | POST /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 fromcallAnsweredAt(person 2 answered) to call end. This is what pricing uses. IfcallAnsweredAtwas 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→callStartedAtfallback) 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), andcallStatus: "not_answered"— the UI keys offcallStatusand shows "Not picked up" instead of the misleading "Failed".
Disconnect reasons distinguish who didn't answer:
| Call type | Reason when unanswered | History label |
|---|---|---|
| Phone-to-phone | customer_no_answer, customer_unavailable, customer_rejected | "Not picked up" / "Customer …" |
| Human callback | agent_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
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
| Aspect | Human Call | Phone-to-Phone Call |
|---|---|---|
| Agent Connection | Browser (WebRTC) | Phone (SIP) |
| Agent's Phone Needed | No | Yes |
| Browser Required | Yes (for agent) | No |
| callType | human | phone_to_phone |
| Contact UID | crm_hcall_* | crm_p2pcall_* |
| Campaign UID | crm_human_{orgId} | crm_p2p_{orgId} |
| Recording | Yes | Yes |
| Transcription | Yes | Yes |
| Analysis | Yes | Yes |
| Audio Mixing Mode | DUAL_CHANNEL_AGENT | DUAL_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_ALTERNATEaudio mixing mode for LiveKit Egress recording - This mode interleaves audio from both SIP participants into separate channels
- Human calls use
DUAL_CHANNEL_AGENTwhich 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:
statusisdoneorfailed- Recording exists (
recordingUrlors3RecordingKey) - Transcription was attempted (
transcriptionStatusiscompletedorfailed) - Analysis is optional - may fail for short calls with insufficient content
// 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 deliveryAI 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:
// 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
{
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:
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
// 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
{
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
{
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
{
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
// 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
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/crm/oauth/token | Client credentials | Exchange code for tokens |
| POST | /api/crm/oauth/refresh | Client credentials | Refresh access token |
Call Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/crm/calls/human | Bearer token | Initiate human call (WebRTC) |
| POST | /api/crm/calls/phone-to-phone | Bearer token | Initiate phone-to-phone call (SIP-to-SIP) |
| POST | /api/v1/crm/calls | Bearer/API token | Initiate AI agent call (requires outboundNumber) |
| GET | /api/crm/phone-numbers | Bearer token | List the customer's outbound numbers (caller ID options) |
| GET | /api/crm/calls/:callId | Bearer token | Get call details |
| POST | /api/crm/calls/:callId/end | Bearer token | End active call |
| GET | /api/crm/calls/history | Bearer token | Get call history |
Resource Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/crm/agents | Bearer token | List available agents |
| GET | /api/crm/credits | Bearer token | Get credit balance |
Human Call Request
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
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
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:
- Member allocation an active assignment with
allocatedToMemberUID === memberUID(outbound/both). Set when a team member places the call (the native/P2P path passesctx.actorUIDasmemberUID). - Customer an active assignment for
customerUID(outbound/both). - 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.
| Error | HTTP | Cause |
|---|---|---|
outbound_number_required | 400 | Field missing or blank |
invalid_outbound_number | 400 | Not an active outbound assignment for this customer |
List Outbound Numbers
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
| Code | HTTP Status | Description |
|---|---|---|
CONNECTION_NOT_FOUND | 403 | CRM connection not found |
CONNECTION_INACTIVE | 403 | CRM connection not active |
INVALID_PHONE | 400 | Phone not in E.164 format |
ORG_NOT_FOUND | 500 | Oktivo organization not found |
ROOM_CREATION_FAILED | 500 | LiveKit room creation failed |
AGENT_NOT_FOUND | 404 | Agent not found or not owned |
INSUFFICIENT_CREDITS | 402 | Not enough credits |
CONCURRENCY_LIMIT | 429 | At max concurrent calls |
DISPATCH_FAILED | 500 | Call dispatch failed |
TELEPHONY_NOT_CONFIGURED | 503 | SIP trunk not configured |
AGENT_CALL_FAILED | 500 | Failed to call agent's phone (Phone-to-Phone) |
CUSTOMER_CALL_FAILED | 500 | Failed to call customer's phone (Phone-to-Phone) |
SIP_CONFIG_MISSING | 500 | Missing SIP configuration for call |
duplicate_call | 409 | Agent already has an active P2P call in progress |
rate_limited | 429 | Same 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)
// 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
// 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 inContactwithcallType: "human"PhoneToPhoneCall- All phone-to-phone data is now inContactwithcallType: "phone_to_phone"
Query Migration
Before (deprecated):
// 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):
// 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:
handleAgentAnsweredsetscallAnsweredAtwhen the member picks up;handleCallEndedbills only fromcallAnsweredAt, setscallStatus: "not_answered"+ reasonagent_no_answerwhen 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 forPhoneToPhoneCallService.endCall: unanswered → 0 billable duration +callStatus: "not_answered"; answered → billable duration measured strictly fromcallAnsweredAt; no over-bill fallback whencallAnsweredAtis missing; idempotent re-end.src/__tests__/p2p-fee-billing.integration.test.ts,src/__tests__/p2p-fee-rate-resolution.test.ts— P2P fee pricing.
cd backend/client_backend
node node_modules/vitest/vitest.mjs run src/__tests__/p2p-call-answer-timing.test.tsRelated Documentation
- WEBHOOKS.md - Webhook system documentation
- CRM-API-INTEGRATION.md - CRM API documentation
- RECORDING-FLOW.md - Recording processing
- CRM Backend: CRM-VOICE-CALL-INTEGRATION.md