Skip to content

Communications — Telephony (Exotel Click-to-Call & SMS)

Exotel powers the outbound voice and SMS channel for the Sales CRM. A salesperson (or sales head / admin) can click a button next to a lead to place a click-to-call — Exotel rings the salesperson's own phone first, then bridges in the lead, so the conversation happens on real phones while the ExoPhone virtual number masks both sides. The same integration sends transactional SMS to a lead. Every call and SMS is recorded as a row on the lead timeline, and Exotel's asynchronous status callbacks (webhooks) resolve the final outcome, duration, recording URL, and SMS delivery state. The whole feature is self-disabling: when the EXOTEL_* environment variables are not set, the backend reports enabled: false and the frontend falls back to plain tel: links and hides SMS.

Status: documented from source on this branch.


Overview

  • What it does: outbound click-to-call + outbound transactional SMS for CRM leads, plus authenticated proxying/playback of Exotel call recordings, all logged onto the lead's activity timeline.
  • Who uses it: the Sales CRM personas — SALES (auto-scoped to their own assigned leads), SALES_HEAD (scoped to their pool), and ADMIN / SUPERADMIN (full access). See sales-crm-leads.
  • Where it sits: it is a thin, optional add-on layered on top of the raw-leads CRM. The CRM-facing endpoints live in the raw-leads controller/routes; the Exotel REST client and webhook receiver are separate modules. Two dedicated tables (lead_call_logs, lead_sms_logs) in the mas_crm schema store the history.
  • Channel siblings: SMS logging deliberately mirrors LeadWhatsAppLog so the lead timeline can render voice, SMS, and WhatsApp the same way. Voice calls also share lead_call_logs with the Aarya / ElevenLabs AI-calling feature (distinguished by CallSource).

Source map

Concern File
Exotel REST client (connect call, send SMS, recording proxy) src/services/ExotelService.ts
CRM orchestration over Exotel (call/SMS/webhook apply) src/services/ExotelLeadService.ts
Webhook receiver (status callbacks) src/routes/exotelWebhook.routes.ts
CRM-facing controller handlers src/controllers/rawLead.controller.ts
CRM-facing route wiring src/routes/rawLead.routes.ts
Call log entity + enums src/entities/LeadCallLog.ts
SMS log entity src/entities/LeadSmsLog.ts
Lead entity (call counters) src/entities/RawLead.ts
Route mount points src/routes/index.ts

Key concepts & entities

Glossary

  • Click-to-call (connect two legs): Exotel's Calls/connect.json API dials the agent (salesperson) first; when the agent answers, Exotel dials the lead and bridges the two legs. Neither party sees the other's real number — both see the ExoPhone (EXOTEL_CALLER_ID).
  • ExoPhone / CallerId: the Exotel-provisioned virtual number presented to both parties.
  • CallSid / SmsSid: Exotel's identifiers for a call/SMS, stored on our log rows so the status webhook can find the right row to update.
  • Status callback (webhook): an asynchronous HTTP POST from Exotel to our backend when a call/SMS reaches a terminal (or notable) state. Guarded by a shared-secret ?token= query param.
  • Agent phone / "My call number": the caller's own phone, stored on User.phone. This is what Exotel rings first on a click-to-call. Each user can set/change it from the CRM.
  • CallSource: which initiator produced a call log — manual (logged by hand), aarya (ElevenLabs AI agent), or exotel (click-to-call).
  • Self-disable / fallback: when Exotel env is unset, isConfigured() returns false; CRM endpoints return 503 and the UI uses tel: links instead.

Main TypeORM entities

  • LeadCallLog (src/entities/LeadCallLog.ts) — one row per call attempt. Table mas_crm.lead_call_logs. Holds outcome (enum CallOutcome), source (enum CallSource), durationSec, notes, calledAt, exotelCallSid, recordingUrl, and Aarya fields (aaryaBatchId, aaryaConversationId). FK lead_idmas_crm.raw_leads, FK called_bypublic.users.
  • LeadSmsLog (src/entities/LeadSmsLog.ts) — one row per SMS. Table mas_crm.lead_sms_logs. Holds toPhone, body, status (queued | sent | delivered | failed), exotelSmsSid, errorMessage, sentAt. FK lead_idmas_crm.raw_leads, FK sent_bypublic.users.
  • RawLead (src/entities/RawLead.ts) — the CRM lead. Telephony reads lead.phone and bumps callCount (call_count) + lastContactedAt on each click-to-call.
  • User (src/entities/User.ts) — the salesperson; User.phone is the agent's click-to-call number.

Architecture

flowchart TD
    subgraph FE["Sales CRM Frontend"]
        UI["Lead detail panel: Call / SMS buttons + recording player"]
    end

    subgraph API["mr-mentor-backend (Express)"]
        RC["RawLeadController (exotelCall, sendSms, streamRecording, listSms, listCalls, exotelStatus, setMyCallNumber)"]
        WR["ExotelWebhookRoutes (/api/exotel)"]
        ELS["ExotelLeadService"]
        ES["ExotelService (REST client)"]
    end

    subgraph DB["PostgreSQL (mas_crm schema)"]
        CL["lead_call_logs"]
        SL["lead_sms_logs"]
        RL["raw_leads"]
        U["public.users"]
    end

    EX["Exotel REST API + recording host"]

    UI -->|"authenticated HTTPS"| RC
    RC --> ELS
    ELS --> ES
    ES -->|"connect call / send SMS / fetch recording"| EX
    ELS --> CL
    ELS --> SL
    ELS --> RL
    ELS --> U
    EX -.->|"status callback POST with token"| WR
    WR --> ELS
    RC -->|"recording stream proxied"| UI

The frontend never talks to Exotel directly. The browser hits authenticated CRM endpoints; the backend holds the Exotel basic-auth credentials and the webhook shared secret. Recordings are streamed back through an authenticated proxy so the raw, credential-protected Exotel URL is never exposed to the browser.


Data model

erDiagram
    RAW_LEAD ||--o{ LEAD_CALL_LOG : "has"
    RAW_LEAD ||--o{ LEAD_SMS_LOG : "has"
    USER ||--o{ LEAD_CALL_LOG : "called_by"
    USER ||--o{ LEAD_SMS_LOG : "sent_by"

    RAW_LEAD {
        uuid id PK
        string name
        string phone
        int call_count
        timestamp last_contacted_at
        uuid owning_sales_head_id
        uuid assigned_to
    }

    LEAD_CALL_LOG {
        uuid id PK
        uuid lead_id FK
        uuid called_by FK
        enum outcome
        enum source
        int duration_sec
        text notes
        string exotel_call_sid
        text recording_url
        timestamp called_at
    }

    LEAD_SMS_LOG {
        uuid id PK
        uuid lead_id FK
        uuid sent_by FK
        string to_phone
        text body
        string status
        string exotel_sms_sid
        text error_message
        timestamp sent_at
    }

    USER {
        uuid id PK
        string phone
        string fullName
        string email
    }

Notable enums / status fields

CallOutcome (src/entities/LeadCallLog.ts) — shared across manual, Aarya, and Exotel sources:

Value Meaning
picked Lead picked up (manual log)
not_picked No answer / busy
connected Conversation happened (Exotel terminal completed)
disconnected Failed / canceled
wrong_number, invalid, switched_off, out_of_service, callback_requested Manual outcomes
aarya_dispatched, aarya_completed, aarya_failed Aarya AI-calling lifecycle
exotel_dispatched Click-to-call started; the webhook will resolve a terminal outcome

CallSource: manual | aarya | exotel.

LeadSmsStatus (src/entities/LeadSmsLog.ts): queued | sent | delivered | failed.


API surface

All CRM-facing routes are mounted under /api (in src/routes/index.ts via this.router.use('/api', this.rawLeadRoutes.router)), so the raw-leads paths below resolve to /api/admin/raw-leads/... and /api/sales/crm/.... The two route families share the same controller handlers; they differ only in their access middleware. The webhook routes are mounted at /api/exotel.

Admin / Sales-Head CRM routes (/api/admin/raw-leads)

Auth: authMiddleware + rawLeadAccessMiddleware (ADMIN / SUPERADMIN / SALES_HEAD) + requireHeadPagePermission (the salesPage bundle for the :id action routes).

Method Path Auth/role Purpose
GET /api/admin/raw-leads/exotel-status ADMIN/SH Feature flag (enabled) + caller's myPhone
PATCH /api/admin/raw-leads/my-call-number ADMIN/SH Set/change caller's own click-to-call number
POST /api/admin/raw-leads/:id/exotel-call ADMIN/SH (scoped) Start click-to-call for lead :id
POST /api/admin/raw-leads/:id/sms ADMIN/SH (scoped) Send SMS to lead :id (body { body })
GET /api/admin/raw-leads/:id/sms ADMIN/SH (scoped) List SMS history for the lead
GET /api/admin/raw-leads/:id/calls ADMIN/SH (scoped) List call logs (recording exposed as boolean)
POST /api/admin/raw-leads/:id/calls ADMIN/SH (scoped) Manually log a call outcome
GET /api/admin/raw-leads/:id/calls/:callId/recording ADMIN/SH (scoped) Stream proxied recording audio

Sales CRM routes (/api/sales/crm)

Auth: authMiddleware + salesCrmAccessMiddleware (adds the SALES role; auto-scoped to the user's leads).

Method Path Auth/role Purpose
GET /api/sales/crm/exotel-status SALES/SH/ADMIN Feature flag + caller's myPhone
PATCH /api/sales/crm/my-call-number SALES/SH/ADMIN Set/change caller's own click-to-call number
POST /api/sales/crm/:id/exotel-call SALES/SH/ADMIN (scoped) Start click-to-call for lead :id
GET /api/sales/crm/:id/calls SALES/SH/ADMIN (scoped) List call logs
POST /api/sales/crm/:id/calls SALES/SH/ADMIN (scoped) Manually log a call outcome
GET /api/sales/crm/:id/calls/:callId/recording SALES/SH/ADMIN (scoped) Stream proxied recording audio

Note: the SMS endpoints (/sms POST/GET) are wired under the admin/raw-leads family in rawLead.routes.ts; the /sales/crm family wires the call + recording handlers but not the SMS handlers (inferred from the route file as read).

Webhook routes (/api/exotel)

Public (no auth middleware) — guarded by a shared-secret ?token= query param verified inside the route.

Method Path Auth/role Purpose
POST /api/exotel/call-status ?token= shared secret Terminal call status callback → resolve call log
POST /api/exotel/sms-status ?token= shared secret SMS delivery callback → update SMS log

User journeys

Journey 1 — Salesperson clicks to call a lead

The headline flow. The agent's own phone rings first; on answer, Exotel bridges the lead. A exotel_dispatched log row is written immediately and the lead's call counter is bumped in the same transaction. The real outcome arrives later via the webhook (Journey 3).

sequenceDiagram
    participant FE as Sales CRM Frontend
    participant RC as RawLeadController
    participant ELS as ExotelLeadService
    participant ES as ExotelService
    participant EX as Exotel API
    participant DB as Postgres mas_crm

    FE->>RC: POST /sales/crm/:id/exotel-call
    Note over RC: authMiddleware then salesCrmAccessMiddleware then assertLeadInScope
    RC->>ELS: callLead leadId and agentUserId
    alt Exotel not configured
        RC-->>FE: 503 Exotel is not configured
    end
    ELS->>DB: load lead and load agent user
    alt Agent has no phone on profile
        ELS-->>RC: 400 ask admin to add a phone number
        RC-->>FE: 400 error message
    end
    ELS->>ES: connectCall agentPhone and leadPhone
    ES->>EX: POST Calls connect.json with CallerId and Record true and StatusCallback
    EX-->>ES: Call Sid and Status
    ES-->>ELS: callSid and status
    ELS->>DB: save call log outcome exotel_dispatched source exotel with callSid
    ELS->>DB: bump raw_leads call_count and set last_contacted_at in same transaction
    ELS-->>RC: saved LeadCallLog
    RC-->>FE: 201 created with the dispatched log
    Note over EX: Exotel now rings the agent then bridges the lead

Journey 2 — Salesperson sends an SMS to a lead

sequenceDiagram
    participant FE as Sales CRM Frontend
    participant RC as RawLeadController
    participant ELS as ExotelLeadService
    participant ES as ExotelService
    participant EX as Exotel API
    participant DB as Postgres mas_crm

    FE->>RC: POST /admin/raw-leads/:id/sms with body text
    Note over RC: authMiddleware then rawLeadAccessMiddleware then assertLeadInScope
    alt Exotel not configured
        RC-->>FE: 503 Exotel is not configured
    end
    alt Body missing or empty
        RC-->>FE: 400 SMS body is required
    end
    RC->>ELS: sendSms leadId and body and senderUserId
    ELS->>DB: load lead
    Note over ELS: trim body and enforce 1 to 1000 chars
    ELS->>ES: sendSms leadPhone and body
    ES->>EX: POST Sms send.json with From sender and StatusCallback
    EX-->>ES: SMSMessage Sid and Status
    ES-->>ELS: smsSid and lowercased status
    ELS->>DB: save lead_sms_log status queued or sent with smsSid
    ELS-->>RC: saved LeadSmsLog
    RC-->>FE: 201 created with the SMS log
    Note over EX: delivery confirmation arrives later via sms-status webhook

Journey 3 — Exotel call-status webhook resolves the dispatched log

When the call ends, Exotel POSTs a terminal status callback to the URL we registered (which carries our shared-secret token). We map the Exotel status to a CallOutcome, record duration, and store the recording URL.

sequenceDiagram
    participant EX as Exotel
    participant WR as ExotelWebhookRoutes
    participant ELS as ExotelLeadService
    participant DB as Postgres mas_crm

    EX->>WR: POST /api/exotel/call-status?token=secret with CallSid and Status
    Note over WR: verifyWebhookToken on the token query param
    alt Token missing or wrong
        WR-->>EX: 403 Invalid webhook token
    end
    WR->>ELS: handleCallStatus payload
    ELS->>DB: find lead_call_log by exotel_call_sid
    alt CallSid unknown
        ELS-->>WR: false call made outside the CRM
        WR-->>EX: 200 handled false
    end
    Note over ELS: map completed to connected and no-answer or busy to not_picked and failed or canceled to disconnected
    ELS->>DB: save outcome and durationSec from ConversationDuration and recordingUrl
    ELS-->>WR: true
    WR-->>EX: 200 handled true

Journey 4 — Exotel SMS-status webhook updates delivery state

sequenceDiagram
    participant EX as Exotel
    participant WR as ExotelWebhookRoutes
    participant ELS as ExotelLeadService
    participant DB as Postgres mas_crm

    EX->>WR: POST /api/exotel/sms-status?token=secret with SmsSid and Status
    Note over WR: verifyWebhookToken on the token query param
    alt Token missing or wrong
        WR-->>EX: 403 Invalid webhook token
    end
    WR->>ELS: handleSmsStatus payload
    ELS->>DB: find lead_sms_log by exotel_sms_sid
    alt SmsSid unknown
        ELS-->>WR: false
        WR-->>EX: 200 handled false
    end
    alt Status delivered or sent
        ELS->>DB: set status to delivered or sent
    else Status failed or undelivered
        ELS->>DB: set status failed and store error message from DetailedStatus
    end
    ELS-->>WR: true
    WR-->>EX: 200 handled true

Journey 5 — Playing a call recording (authenticated proxy)

The browser cannot fetch the Exotel-hosted mp3 directly because it sits behind the same basic auth as the REST API. The list endpoint exposes only a boolean for the recording; playback streams through an authenticated, scope-checked proxy.

sequenceDiagram
    participant FE as Sales CRM Frontend
    participant RC as RawLeadController
    participant ELS as ExotelLeadService
    participant ES as ExotelService
    participant EX as Exotel recording host

    FE->>RC: GET /sales/crm/:id/calls/:callId/recording
    Note over RC: authMiddleware then access middleware then assertLeadInScope
    RC->>ELS: getRecordingStream leadId and callId
    ELS->>ELS: load call log and verify it belongs to the lead and has a recordingUrl
    alt No recording or wrong lead
        ELS-->>RC: null
        RC-->>FE: 404 Recording not found
    end
    ELS->>ES: fetchRecordingStream recordingUrl
    Note over ES: reject any host that is not an exotel.com host
    ES->>EX: GET recording with basic auth as a stream
    EX-->>ES: audio stream
    ES-->>ELS: upstream stream response
    ELS-->>RC: upstream stream
    RC-->>FE: pipe audio with content type and private cache header

Journey 6 — Setting "My call number" + reading the feature flag

Before a salesperson can click-to-call, their User.phone must hold a valid number (Exotel rings it first). The UI reads exotel-status on load to know whether to render call/SMS buttons or fall back to tel: links.

sequenceDiagram
    participant FE as Sales CRM Frontend
    participant RC as RawLeadController
    participant ELS as ExotelLeadService
    participant DB as Postgres

    FE->>RC: GET /sales/crm/exotel-status
    RC->>ELS: isConfigured and getAgentPhone userId
    ELS->>DB: read user phone
    ELS-->>RC: enabled flag and myPhone
    RC-->>FE: 200 enabled and myPhone
    Note over FE: if enabled false render tel links and hide SMS
    FE->>RC: PATCH /sales/crm/my-call-number with phone
    RC->>ELS: setAgentPhone userId and phone
    Note over ELS: strip non digits then validate 10 to 15 digits optional plus
    alt Invalid number
        ELS-->>RC: 400 enter a valid phone number
        RC-->>FE: 400 error
    end
    ELS->>DB: update user phone
    ELS-->>RC: saved digits
    RC-->>FE: 200 phone saved

Background jobs & async

  • No BullMQ queue or worker is involved in the Exotel telephony path — calls and SMS are placed synchronously during the request; only their resolution is asynchronous (via Exotel webhooks).
  • No Socket.IO events are emitted by this feature.
  • Webhooks are the async mechanism. Exotel POSTs call-status (registered with StatusCallbackEvents[0]=terminal, i.e. fired once at call end) and sms-status to our backend. We always respond 200 even on internal failure so Exotel does not retry forever (src/routes/exotelWebhook.routes.ts).
  • Recording streaming is a synchronous pass-through proxy (axios responseType: 'stream' piped to the Express response), not a background job.
  • The unrelated Aarya / ElevenLabs AI-calling feature does use background sync to backfill lead_call_logs (src/services/AaryaCallSyncService.ts); it shares the table but is out of scope for this doc.

External integrations

Exotel v1 REST API (src/services/ExotelService.ts):

  • Base URL: https://${EXOTEL_SUBDOMAIN}/v1/Accounts/${EXOTEL_SID}.
  • Auth: HTTP basic with EXOTEL_API_KEY (username) / EXOTEL_API_TOKEN (password).
  • Endpoints used: POST /Calls/connect.json (click-to-call, Record=true), POST /Sms/send.json (SMS), and authenticated GET of recording URLs (proxied).
  • Timeouts: 15s for connect/SMS, 30s for recording streams.

Environment variables (see backend CLAUDE.md / .env.example):

Var Purpose
EXOTEL_SID Account SID (in the dashboard URL)
EXOTEL_API_KEY API key (basic-auth username)
EXOTEL_API_TOKEN API token (basic-auth password)
EXOTEL_SUBDOMAIN api.exotel.com (Singapore) or api.in.exotel.com (Mumbai) — must match the account's cluster
EXOTEL_CALLER_ID The ExoPhone virtual number shown to both parties
EXOTEL_SMS_SENDER_ID DLT-approved SMS sender id; falls back to EXOTEL_CALLER_ID
EXOTEL_WEBHOOK_TOKEN Shared secret appended to callback URLs and required on inbound webhooks
BACKEND_PUBLIC_URL Public base used to build the status-callback URLs

Feature flag / fallback: ExotelService.isConfigured() requires EXOTEL_SID, EXOTEL_API_KEY, EXOTEL_API_TOKEN, and EXOTEL_CALLER_ID to all be present. When any is missing:

  • exotel-status returns enabled: false → the frontend uses plain tel: links and hides SMS.
  • exotel-call and sms endpoints short-circuit with HTTP 503 before touching Exotel.
  • Internally assertConfigured() throws a 503-tagged error if a call/SMS is attempted while unconfigured.

Deployment note (per project memory): dev uses the Singapore account (api.exotel.com); prod uses a separate Mumbai account (api.in.exotel.com). EXOTEL_SUBDOMAIN must match the cluster or auth fails with 401. This is operational context, not in the source.


Status lifecycles

Call log (LeadCallLog.outcome) for Exotel-sourced calls

stateDiagram-v2
    [*] --> exotel_dispatched : click-to-call placed
    exotel_dispatched --> connected : webhook status completed
    exotel_dispatched --> not_picked : webhook status no-answer or busy
    exotel_dispatched --> disconnected : webhook status failed or canceled
    connected --> [*]
    not_picked --> [*]
    disconnected --> [*]
    note right of exotel_dispatched
        Stays here until the terminal
        status webhook arrives. Unknown
        CallSid leaves the row untouched.
    end note

The webhook also writes durationSec (from ConversationDuration) and recordingUrl (when Exotel returns an http recording URL) when resolving to a terminal outcome.

SMS log (LeadSmsLog.status)

stateDiagram-v2
    [*] --> queued : send returns no terminal status
    [*] --> sent : send returns sent
    queued --> sent : webhook status sent
    queued --> delivered : webhook status delivered
    sent --> delivered : webhook status delivered
    queued --> failed : webhook failed or undelivered
    sent --> failed : webhook failed or undelivered
    delivered --> [*]
    failed --> [*]
    note right of failed
        errorMessage captured from
        DetailedStatus on the webhook
    end note

The initial status comes from Exotel's Sms/send.json response (lowercased); if it is not one of queued / sent / delivered / failed the row defaults to queued.


Edge cases, limits & gotchas

  • Webhook auth is a query-string shared secret, not a signature. ExotelService.webhookUrl() appends ?token=<EXOTEL_WEBHOOK_TOKEN> to the callback URL; the receiver compares req.query.token to the env value via verifyWebhookToken(). There is no HMAC/payload signing — the token must be kept secret and the callback served over HTTPS. A missing/empty EXOTEL_WEBHOOK_TOKEN makes verification always fail (returns 403).
  • Webhooks always return 200 on internal error. Both webhook handlers catch exceptions and respond 200 with success: false so Exotel does not retry indefinitely on our bugs. Genuinely unknown CallSid/SmsSid return 200 handled:false — calls/SMS placed outside the CRM are silently ignored.
  • Body parsing covers both content types. Calls/connect.json registers StatusCallbackContentType=application/json, but Exotel may also post form-encoded. The app mounts both express.json() and express.urlencoded({ extended: true }) (src/app.ts), so req.body works either way.
  • No idempotency key. A webhook re-delivery re-runs handleCallStatus / handleSmsStatus. These are effectively idempotent (they overwrite the same fields), but a later non-terminal SMS callback could in principle move a status backward; only delivered/sent/failed-style transitions are applied.
  • Agent must have a phone. callLead throws 400 if the calling user's User.phone is empty — there is no default. Validation in setAgentPhone strips non-digits and enforces 10–15 digits with an optional leading +.
  • Recording proxy is an allow-list, not an open proxy. fetchRecordingStream rejects any URL whose host does not end in .exotel.com (400), preventing SSRF through the recording endpoint. The browser only ever receives a boolean in listCalls and must stream through the auth'd /recording route.
  • Scope checks. assertLeadInScope ensures a sales head can only call/SMS/play recordings for leads in their pool; out-of-scope leads return 404 (not 403, to avoid leaking existence). Regular SALES users are auto-scoped to their assigned leads via salesCrmAccessMiddleware.
  • SMS length limit: body is trimmed and must be 1–1000 characters (400 otherwise).
  • Multi-platform: the feature is not branched on the x-platform header — it is a single Exotel account per environment. Cluster selection is purely via EXOTEL_SUBDOMAIN.
  • Shared table with Aarya. lead_call_logs mixes manual, aarya, and exotel rows; always filter by source and exotelCallSid when reasoning about click-to-call specifically. The webhook resolver matches by exotelCallSid, so it cannot accidentally touch Aarya rows.
  • call_count / last_contacted_at on raw_leads are bumped only on click-to-call dispatch (inside the same transaction as the log insert), not on SMS sends.