Skip to content

CRM API Integration ​

REST API that allows external CRM systems to trigger outbound calls, manage API tokens, and receive call analysis data via webhooks with per-call billing, retry logic, concurrency control, and agent-specific webhook filtering.


Architecture Overview ​


How It Works ​

1. Token Authentication ​

Customers generate API tokens (prefixed okt_) from Settings → API Tokens in the dashboard. The backend stores only the SHA-256 hash.

Middleware: apiTokenAuth.middleware.ts

  • Extracts Bearer token from Authorization header
  • If token starts with okt_: hash it, look up in ApiToken collection, verify not expired/deleted, load user context
  • Otherwise: falls through to standard JWT auth

2. Rate Limiting ​

Middleware: rateLimiter.middleware.ts → createCrmApiRateLimiter()

  • 60 requests per 60-second window per customer
  • Redis-backed with key rl:crm_api:{userId}
  • skipFailedRequests: true 4xx responses don't count
  • Fail-open: if Redis unavailable, allows request through with warning log

3. Call Trigger Orchestration ​

Service: crmApi.service.ts → triggerCall()

StepActionFailure Response
1Validate agent exists and belongs to customer404
2Off-hours validation via isCallingAllowedForUser()403
3Credit pre-check via creditService.checkBalance()402
4Concurrency check via Redis Lua script (atomic SCARD + SADD)429
5Resolve max concurrent: Math.min(customer, org) or default 10
6Create Contact record (contactUID, campaignUID = api_call_*)500
7Resolve telephony config (SIP trunk)500
8Dispatch call via createDispatch() (same as campaign calls)500
9On success: add to Redis active sets, return contactUID
10On failure: mark Contact failed, rollback concurrency500

Note: Step 2 (off-hours validation) checks the user's calling window configuration. Calls outside the allowed window return 403 with error code OUTSIDE_CALLING_WINDOW. See DYNAMIC-CALL-WINDOW.md for details.

4. Retry Logic ​

Service: crmApiRetry.service.ts

  • BullMQ queue crm-api-retry with delayed jobs
  • scheduleRetry(contactUID): checks retryConfig.retryDelays[attempts-1], schedules delayed job
  • Worker pre-checks credits before each retry attempt
  • On insufficient credits: cancels all pending retries, marks contact failed
  • On success (call answered): cancels remaining retries
  • Hooked into participant_left handler for API-triggered contacts (campaignUID.startsWith("api_call_"))

5. Per-Call Billing ​

  • API calls set isTestCall: false → flows through standard billing path
  • serviceRef pattern: api_call_{contactUID} (idempotent via findByServiceRef)
  • Billing: Math.ceil(billingDuration / 60) → round up to nearest minute
  • On billing failure: logs error, stores metadata on Contact, does NOT retry

6. Webhook Integration ​

CRM API calls use the unified enqueueEvent pipeline for webhook delivery. Key behaviors for CRM-triggered calls:

  • Multi-agent filtering: WebhookEndpoint schema has agentIds array endpoints with non-empty agentIds only receive events from matching agents. Endpoints with empty agentIds ([]) receive all events (backward compatible).
  • Campaign condition skipping: CRM API calls generate campaignUID starting with "api_call_". These contacts skip CampaignWebhook condition overrides and use only the endpoint-level conditions. This ensures CRM integrations behave predictably without campaign managers accidentally blocking delivery.
  • Presigned recording URLs: The recordingUrl field in webhook payloads is now a presigned S3 URL with 1-year validity. CRM systems can download recordings directly without additional authentication no need to proxy through the Oktivo backend.
  • Custom payload mode: Endpoints configured with payloadMode: "custom" deliver interpolated HTTP requests (configurable method, headers, body template) instead of the standard HMAC-signed payload. This works identically for CRM-triggered calls.

File Reference ​

FileRoleKey Functions
models/ApiToken.tsMongoose schema token storagetokenId, tokenHash, prefix, expiresAt, deletedAt
services/apiToken.service.tsToken CRUD create, list, delete, rotatecreateToken(), listTokens(), deleteToken(), rotateToken()
middleware/apiTokenAuth.middleware.tsDual-auth middlewareverifyApiTokenOrJwt
middleware/rateLimiter.middleware.tsRate limiter factorycreateCrmApiRateLimiter()
services/crmApi.service.tsCall trigger orchestrationtriggerCall(), triggerCallForRetry(), buildAndDispatchCall() (shared helper)
services/crmApiRetry.service.tsBullMQ retry schedulingscheduleRetry(), cancelRetries(), handleApiCallRetry()
controllers/crmApi.controller.tsHTTP handlers callstriggerCallHandler, getCallStatusHandler, cancelCallHandler
controllers/apiToken.controller.tsHTTP handlers tokenscreateTokenHandler, listTokensHandler, deleteTokenHandler, rotateTokenHandler
routes/crm-api.routes.tsRoute definitionsAll CRM API routes
routes/script.routes.tsAdmin script endpointsCleanup stale active calls
scripts/cleanup-stale-active-calls.tsRedis cleanup for stale concurrency entriescleanupStaleActiveCalls()
models/WebhookEndpoint.tsUpdated uses agentIds array + payloadModeMulti-agent filtering, custom payload mode
services/webhook.service.tsUpdated enqueueEvent() filters by agentIds, evaluates conditions, routes by payloadMode
controllers/webhook.controller.tsUpdated calls handleApiCallRetry(), safety net fallback for API callsRetry hook
models/Contact.tsUpdated added retryConfig, metadata fields

Data Models ​

ApiToken Schema ​

typescript
{
  tokenId: string;        // Unique ID (tok_...)
  customerUID: string;    // Owner (indexed)
  orgUID: string;         // Organization (indexed)
  name: string;           // Display name (1-128 chars)
  tokenHash: string;      // SHA-256 hex (unique index)
  prefix: string;         // First 8 chars for display
  expiresAt: Date;        // Expiry timestamp
  lastUsedAt: Date | null;
  deletedAt: Date | null; // Soft-delete
  createdAt: Date;
  updatedAt: Date;
}
// Indexes: { tokenHash: 1 } unique, { customerUID: 1, deletedAt: 1 }, { expiresAt: 1 }

Contact Schema Updates ​

typescript
{
  // ... existing fields ...
  retryConfig: {
    retryCount: number;   // 1-5
    retryDelays: number[]; // minutes per retry
  } | null;
  metadata: Record<string, any> | null; // Billing failure info, etc.
}

WebhookEndpoint Schema Update ​

typescript
{
  // ... existing fields ...
  agentIds: string[]; // If non-empty, only receives events from these agents (max 50)
  payloadMode: "standard" | "custom"; // Delivery mode
}

API Endpoints ​

Token Management (JWT auth only) ​

Create Token ​

  • Method: POST
  • Path: /api/v1/crm/tokens
  • Auth: verifyJwt → requireFeatureAccess("crm_api")
  • Body:
    json
    { "name": "CRM Production", "expiresInDays": 90 }
  • Response: 201
    json
    {
      "success": true,
      "data": {
        "tokenId": "tok_abc123",
        "plaintext": "okt_a1b2c3d4...",
        "name": "CRM Production",
        "prefix": "okt_a1b2",
        "expiresAt": "2026-09-14T00:00:00.000Z"
      }
    }

List Tokens ​

  • Method: GET
  • Path: /api/v1/crm/tokens
  • Response: 200 with array of token metadata (no plaintext)

Delete Token ​

  • Method: DELETE
  • Path: /api/v1/crm/tokens/:tokenId
  • Response: 200 { "success": true, "message": "Token deleted successfully" }

Rotate Token ​

  • Method: POST
  • Path: /api/v1/crm/tokens/:tokenId/rotate
  • Response: 200 with new token (same structure as Create)

Call Operations (API token or JWT) ​

Trigger Call ​

  • Method: POST
  • Path: /api/v1/crm/calls
  • Auth: verifyApiTokenOrJwt → createCrmApiRateLimiter() → requireFeatureAccess("crm_api")
  • Body:
    json
    {
      "phone": "+919876543210",
      "name": "John Doe",
      "agentId": "SZ24gF9tGk-o",
      "dynamicVariables": { "company": "Acme", "product": "Widget" },
      "retryConfig": { "retryCount": 3, "retryDelays": [5, 10, 15] }
    }
  • Validation:
    • phone: required, E.164 format (/^\+[1-9]\d{1,14}$/)
    • name: required, max 128 chars
    • agentId: required, must belong to the authenticated customer
    • dynamicVariables: optional, max 20 entries, key max 64 chars, value max 512 chars, all values must be strings
    • retryConfig: optional, retryCount 1-5, retryDelays array length must equal retryCount, each delay 1-60 minutes
  • Response: 201
    json
    { "success": true, "data": { "contactUID": "abc123", "campaignUID": "api_call_xyz789" } }
  • Error Codes: 400 (validation), 402 (credits), 404 (agent), 429 (rate/concurrency), 503 (Redis down)

Get Call Status ​

  • Method: GET
  • Path: /api/v1/crm/calls/:contactUID
  • Response: 200 with full contact details (status, callStatus, duration, attempts, etc.)

Cancel Call ​

  • Method: DELETE
  • Path: /api/v1/crm/calls/:contactUID
  • Response: 200 { "success": true, "message": "Call cancelled successfully" }
  • Error: 409 if contact is already in terminal state (done, failed)

Redis Keys ​

Key PatternPurpose
customer:{customerUID}:active_callsSET of active phone numbers per customer
org:{orgUID}:active_callsSET of active phone numbers per org
global:active_callsSET of all active phone numbers
campaign:{campaignUID}:activeHASH of active call data per campaign
rl:crm_api:{userId}Rate limiter window counter

Feature Gate ​

The crm_api feature must be present in:

  1. Organization's plan.features array
  2. Customer's features array

Both must include "crm_api" for the API to be accessible. Without it, all CRM API endpoints return 403.


How to Test ​

Prerequisites ​

  • Backend running locally (npm run dev)
  • MongoDB and Redis connected
  • Customer account with crm_api feature enabled
  • At least one agent created for the customer

Step 1: Create API Token (via UI) ​

  1. Login as customer → Settings → API Tokens tab
  2. Click "Create Token" → set name and expiry → Create
  3. Copy the token (shown once)

Step 2: Trigger a Call ​

bash
curl -X POST http://localhost:3000/api/v1/crm/calls \
  -H "Authorization: Bearer okt_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+919876543210",
    "name": "Test Contact",
    "agentId": "your_agent_id"
  }'

Step 3: Check Call Status ​

bash
curl http://localhost:3000/api/v1/crm/calls/CONTACT_UID \
  -H "Authorization: Bearer okt_your_token_here"

Step 4: Verify Billing ​

After call completes, check the customer's credit balance decreased.

Step 5: Verify Webhook Delivery ​

Create a webhook endpoint with the agent's ID → trigger a call → check delivery logs.


Troubleshooting ​

SymptomCauseFix
401 on API callToken invalid, expired, or deletedCreate new token or check expiry
403 on API callcrm_api feature not enabledAdd to org plan AND customer features
403 OUTSIDE_CALLING_WINDOWCall attempted outside allowed windowCheck user's callWindowConfig or wait for allowed window (default: 9 AM - 9 PM IST). See DYNAMIC-CALL-WINDOW.md
404 agent not foundAgent doesn't belong to authenticated customerVerify agentId and ownership
402 insufficient creditsCustomer credit balance too lowAdd credits
429 rate limit>60 requests in 60 secondsWait and retry
429 concurrency limitAll concurrent call slots in useWait for calls to complete
429 concurrency after retry failureStale Redis entry or zombie Contact stuck in in_progressRun cleanup script (see below)
503 service unavailableRedis down can't enforce concurrencyCheck Redis connectivity
Webhook not receivedEndpoint has agentIds filter that doesn't include the call's agentCheck endpoint's agentIds array setting
Retry not schedulingContact has no retryConfigInclude retryConfig in trigger request
Token shows undefined when copiedFrontend using wrong field nameEnsure plaintext field (not plainToken)

Admin Scripts ​

Cleanup Stale Active Calls ​

Fixes "Concurrency limit reached" errors caused by orphaned Redis entries or zombie contacts stuck in in_progress after failed retries.

Endpoint: POST /internal/scripts/cleanup-stale-active-callsAuth: x-api-secret header (SUPERADMIN_API_SECRET)

Options (JSON body):

FieldTypeDefaultDescription
phonestringClean a specific phone number from all Redis sets
customerUIDstringClean all stale entries for a specific customer
forcebooleanfalseForce-clean even if an in_progress contact exists (marks it as failed)
dryRunbooleanfalsePreview what would be cleaned without making changes
staleMinutesnumber5Consider in_progress contacts stale after N minutes

If no phone or customerUID is provided, scans ALL customer active sets.

Examples:

bash
# Dry run preview stale entries
curl -X POST http://localhost:3000/internal/scripts/cleanup-stale-active-calls \
  -H "x-api-secret: YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"dryRun": true}'

# Force-clean a specific stuck phone number
curl -X POST http://localhost:3000/internal/scripts/cleanup-stale-active-calls \
  -H "x-api-secret: YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"phone": "+919876543210", "force": true}'

# Clean all stale entries for a customer
curl -X POST http://localhost:3000/internal/scripts/cleanup-stale-active-calls \
  -H "x-api-secret: YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"customerUID": "cust_abc123"}'

Response:

json
{
  "success": true,
  "dryRun": false,
  "scanned": 1,
  "staleFound": 0,
  "zombieFound": 1,
  "removed": 1,
  "details": [
    {
      "phone": "+919876543210",
      "customerUID": "cust_abc123",
      "reason": "zombie_contact_force_cleaned",
      "contactUID": "abc123xyz",
      "removedFrom": ["customer:cust_abc123:active_calls", "global:active_calls"]
    }
  ]
}

Recorded Message Agent Support ​

The CRM API (POST /api/v1/crm/calls) supports both voice agents and recorded message agents through the same endpoint. The system automatically detects the agent type and dispatches accordingly.

How It Works ​

When triggerCall() validates the agent, it checks agentDoc.agentType:

  • "recorded_message" → dispatches with mode: "recorded_message" (lightweight playback, no AI)
  • "voice_agent" or undefined/null → dispatches with mode: "call" (full STT/LLM/TTS pipeline)

No changes to the API request or response the same POST /api/v1/crm/calls body works for both agent types. The differentiation happens internally based on the agent's configuration.

Request (identical for both agent types) ​

bash
curl -X POST http://localhost:3000/api/v1/crm/calls \
  -H "Authorization: Bearer okt_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+919876543210",
    "name": "Rahul Sharma",
    "agentId": "rec_agent_id",
    "dynamicVariables": {
      "appointment_date": "Aug 10",
      "doctor_name": "Dr. Singh"
    }
  }'

What's Different Internally ​

AspectVoice Agent (mode: "call")Recorded Message (mode: "recorded_message")
Agent handlerCallHandler + VoiceAssistantRecordedMessageHandler
STT/LLM/TTS pipelineFull (Deepgram, OpenAI/Groq, ElevenLabs/etc)None
Dynamic tools / MCPFetched and passedSkipped
IVR actionsExtracted and passedSkipped
RecordingLiveKit egress (via /start-recording)No egress
Transcription syncYes (POST /transcription-sync)No
Analysis triggerYes (POST /analyse/conversation)No
call.completed webhookYes (when all 4 data points arrive)No
call.ended webhookYesYes (via participant_left)
Billing/creditsYesYes
RetriesYes (same retryConfig support)Yes
Concurrency checkYesYes
Calling windowYesYes

Message Types ​

Recorded message agents have two modes configured via agent.messageType:

messageTypeBehaviorKey Fields
"audio"Plays a pre-recorded audio file from S3audioFileUrl
"tts"Synthesizes speech from text templatettsMessage, tts_voice, tts_language, tts_provider

TTS Dynamic Variables ​

For TTS agents, dynamicVariables in the API request are used for {{variable}} interpolation in the ttsMessage:

Agent's ttsMessage: "Hello {{name}}, your appointment with {{doctor_name}} is on {{appointment_date}}."
API dynamicVariables: { "name": "Rahul", "doctor_name": "Dr. Singh", "appointment_date": "Aug 10" }
Result: "Hello Rahul, your appointment with Dr. Singh is on Aug 10."

Post-Call Lifecycle ​

Since recorded message calls have no transcription or analysis, they follow a simpler lifecycle:

  1. Call dispatched → Contact status: in_progress
  2. Phone answered → Agent calls /start-recording with skipRecording: true → callAnsweredAt set (no egress started)
  3. Audio played N times → Agent hangs up
  4. participant_left webhook → Contact status: done, duration calculated from callAnsweredAt
  5. call.ended webhook event fired
  6. Credit deducted based on billingDuration

Important: The billing duration is calculated from callAnsweredAt to participantLeftAt. The /start-recording endpoint must be called to set callAnsweredAt even though no recording is needed. This is handled automatically by passing skipRecording: true in the request.

The contact will NOT reach call.completed status (since isContactComplete() requires transcription + analysis + recording, none of which exist for recorded message calls).

Implementation ​

File: services/crmApi.service.ts → buildAndDispatchCall()

The recorded message branch is inserted early in buildAndDispatchCall(), after shared setup (contact update, name resolution, dynamic values merge) but before voice-agent-specific logic (fetching tools, MCP, IVR, preset resolution). It builds the lightweight recorded_message metadata and returns early.

Backward Compatibility ​

  • Agents without an agentType field (legacy agents) are treated as "voice_agent" no behavior change
  • The API request body validation is unchanged same fields, same constraints
  • Existing CRM integrations using voice agents are completely unaffected
  • The retry flow (triggerCallForRetry) uses the same buildAndDispatchCall() and benefits automatically

Last updated: