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 inApiTokencollection, 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: true4xx responses don't count- Fail-open: if Redis unavailable, allows request through with warning log
3. Call Trigger Orchestration
Service: crmApi.service.ts → triggerCall()
| Step | Action | Failure Response |
|---|---|---|
| 1 | Validate agent exists and belongs to customer | 404 |
| 2 | Off-hours validation via isCallingAllowedForUser() | 403 |
| 3 | Credit pre-check via creditService.checkBalance() | 402 |
| 4 | Concurrency check via Redis Lua script (atomic SCARD + SADD) | 429 |
| 5 | Resolve max concurrent: Math.min(customer, org) or default 10 | |
| 6 | Create Contact record (contactUID, campaignUID = api_call_*) | 500 |
| 7 | Resolve telephony config (SIP trunk) | 500 |
| 8 | Dispatch call via createDispatch() (same as campaign calls) | 500 |
| 9 | On success: add to Redis active sets, return contactUID | |
| 10 | On failure: mark Contact failed, rollback concurrency | 500 |
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-retrywith delayed jobs scheduleRetry(contactUID): checksretryConfig.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_lefthandler for API-triggered contacts (campaignUID.startsWith("api_call_"))
5. Per-Call Billing
- API calls set
isTestCall: false→ flows through standard billing path serviceRefpattern:api_call_{contactUID}(idempotent viafindByServiceRef)- 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:
WebhookEndpointschema hasagentIdsarray endpoints with non-emptyagentIdsonly receive events from matching agents. Endpoints with emptyagentIds([]) receive all events (backward compatible). - Campaign condition skipping: CRM API calls generate
campaignUIDstarting with"api_call_". These contacts skipCampaignWebhookcondition overrides and use only the endpoint-level conditions. This ensures CRM integrations behave predictably without campaign managers accidentally blocking delivery. - Presigned recording URLs: The
recordingUrlfield 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
| File | Role | Key Functions |
|---|---|---|
models/ApiToken.ts | Mongoose schema token storage | tokenId, tokenHash, prefix, expiresAt, deletedAt |
services/apiToken.service.ts | Token CRUD create, list, delete, rotate | createToken(), listTokens(), deleteToken(), rotateToken() |
middleware/apiTokenAuth.middleware.ts | Dual-auth middleware | verifyApiTokenOrJwt |
middleware/rateLimiter.middleware.ts | Rate limiter factory | createCrmApiRateLimiter() |
services/crmApi.service.ts | Call trigger orchestration | triggerCall(), triggerCallForRetry(), buildAndDispatchCall() (shared helper) |
services/crmApiRetry.service.ts | BullMQ retry scheduling | scheduleRetry(), cancelRetries(), handleApiCallRetry() |
controllers/crmApi.controller.ts | HTTP handlers calls | triggerCallHandler, getCallStatusHandler, cancelCallHandler |
controllers/apiToken.controller.ts | HTTP handlers tokens | createTokenHandler, listTokensHandler, deleteTokenHandler, rotateTokenHandler |
routes/crm-api.routes.ts | Route definitions | All CRM API routes |
routes/script.routes.ts | Admin script endpoints | Cleanup stale active calls |
scripts/cleanup-stale-active-calls.ts | Redis cleanup for stale concurrency entries | cleanupStaleActiveCalls() |
models/WebhookEndpoint.ts | Updated uses agentIds array + payloadMode | Multi-agent filtering, custom payload mode |
services/webhook.service.ts | Updated enqueueEvent() filters by agentIds, evaluates conditions, routes by payloadMode | |
controllers/webhook.controller.ts | Updated calls handleApiCallRetry(), safety net fallback for API calls | Retry hook |
models/Contact.ts | Updated added retryConfig, metadata fields |
Data Models
ApiToken Schema
{
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
{
// ... 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
{
// ... 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:
201json{ "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:
200with 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:
200with 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 charsagentId: required, must belong to the authenticated customerdynamicVariables: optional, max 20 entries, key max 64 chars, value max 512 chars, all values must be stringsretryConfig: optional,retryCount1-5,retryDelaysarray length must equalretryCount, each delay 1-60 minutes
- Response:
201json{ "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:
200with 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:
409if contact is already in terminal state (done, failed)
Redis Keys
| Key Pattern | Purpose |
|---|---|
customer:{customerUID}:active_calls | SET of active phone numbers per customer |
org:{orgUID}:active_calls | SET of active phone numbers per org |
global:active_calls | SET of all active phone numbers |
campaign:{campaignUID}:active | HASH of active call data per campaign |
rl:crm_api:{userId} | Rate limiter window counter |
Feature Gate
The crm_api feature must be present in:
- Organization's
plan.featuresarray - Customer's
featuresarray
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_apifeature enabled - At least one agent created for the customer
Step 1: Create API Token (via UI)
- Login as customer → Settings → API Tokens tab
- Click "Create Token" → set name and expiry → Create
- Copy the token (shown once)
Step 2: Trigger a Call
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
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
| Symptom | Cause | Fix |
|---|---|---|
| 401 on API call | Token invalid, expired, or deleted | Create new token or check expiry |
| 403 on API call | crm_api feature not enabled | Add to org plan AND customer features |
| 403 OUTSIDE_CALLING_WINDOW | Call attempted outside allowed window | Check user's callWindowConfig or wait for allowed window (default: 9 AM - 9 PM IST). See DYNAMIC-CALL-WINDOW.md |
| 404 agent not found | Agent doesn't belong to authenticated customer | Verify agentId and ownership |
| 402 insufficient credits | Customer credit balance too low | Add credits |
| 429 rate limit | >60 requests in 60 seconds | Wait and retry |
| 429 concurrency limit | All concurrent call slots in use | Wait for calls to complete |
| 429 concurrency after retry failure | Stale Redis entry or zombie Contact stuck in in_progress | Run cleanup script (see below) |
| 503 service unavailable | Redis down can't enforce concurrency | Check Redis connectivity |
| Webhook not received | Endpoint has agentIds filter that doesn't include the call's agent | Check endpoint's agentIds array setting |
| Retry not scheduling | Contact has no retryConfig | Include retryConfig in trigger request |
| Token shows undefined when copied | Frontend using wrong field name | Ensure 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):
| Field | Type | Default | Description |
|---|---|---|---|
phone | string | Clean a specific phone number from all Redis sets | |
customerUID | string | Clean all stale entries for a specific customer | |
force | boolean | false | Force-clean even if an in_progress contact exists (marks it as failed) |
dryRun | boolean | false | Preview what would be cleaned without making changes |
staleMinutes | number | 5 | Consider in_progress contacts stale after N minutes |
If no phone or customerUID is provided, scans ALL customer active sets.
Examples:
# 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:
{
"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 withmode: "recorded_message"(lightweight playback, no AI)"voice_agent"orundefined/null→ dispatches withmode: "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)
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
| Aspect | Voice Agent (mode: "call") | Recorded Message (mode: "recorded_message") |
|---|---|---|
| Agent handler | CallHandler + VoiceAssistant | RecordedMessageHandler |
| STT/LLM/TTS pipeline | Full (Deepgram, OpenAI/Groq, ElevenLabs/etc) | None |
| Dynamic tools / MCP | Fetched and passed | Skipped |
| IVR actions | Extracted and passed | Skipped |
| Recording | LiveKit egress (via /start-recording) | No egress |
| Transcription sync | Yes (POST /transcription-sync) | No |
| Analysis trigger | Yes (POST /analyse/conversation) | No |
call.completed webhook | Yes (when all 4 data points arrive) | No |
call.ended webhook | Yes | Yes (via participant_left) |
| Billing/credits | Yes | Yes |
| Retries | Yes (same retryConfig support) | Yes |
| Concurrency check | Yes | Yes |
| Calling window | Yes | Yes |
Message Types
Recorded message agents have two modes configured via agent.messageType:
messageType | Behavior | Key Fields |
|---|---|---|
"audio" | Plays a pre-recorded audio file from S3 | audioFileUrl |
"tts" | Synthesizes speech from text template | ttsMessage, 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:
- Call dispatched → Contact status:
in_progress - Phone answered → Agent calls
/start-recordingwithskipRecording: true→callAnsweredAtset (no egress started) - Audio played N times → Agent hangs up
participant_leftwebhook → Contact status:done, duration calculated fromcallAnsweredAtcall.endedwebhook event fired- 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
agentTypefield (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 samebuildAndDispatchCall()and benefits automatically