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), andADMIN/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 themas_crmschema store the history. - Channel siblings: SMS logging deliberately mirrors
LeadWhatsAppLogso the lead timeline can render voice, SMS, and WhatsApp the same way. Voice calls also sharelead_call_logswith the Aarya / ElevenLabs AI-calling feature (distinguished byCallSource).
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.jsonAPI 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), orexotel(click-to-call). - Self-disable / fallback: when Exotel env is unset,
isConfigured()returns false; CRM endpoints return 503 and the UI usestel:links instead.
Main TypeORM entities
LeadCallLog(src/entities/LeadCallLog.ts) — one row per call attempt. Tablemas_crm.lead_call_logs. Holdsoutcome(enumCallOutcome),source(enumCallSource),durationSec,notes,calledAt,exotelCallSid,recordingUrl, and Aarya fields (aaryaBatchId,aaryaConversationId). FKlead_id→mas_crm.raw_leads, FKcalled_by→public.users.LeadSmsLog(src/entities/LeadSmsLog.ts) — one row per SMS. Tablemas_crm.lead_sms_logs. HoldstoPhone,body,status(queued | sent | delivered | failed),exotelSmsSid,errorMessage,sentAt. FKlead_id→mas_crm.raw_leads, FKsent_by→public.users.RawLead(src/entities/RawLead.ts) — the CRM lead. Telephony readslead.phoneand bumpscallCount(call_count) +lastContactedAton each click-to-call.User(src/entities/User.ts) — the salesperson;User.phoneis 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 (
/smsPOST/GET) are wired under the admin/raw-leads family inrawLead.routes.ts; the/sales/crmfamily 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 withStatusCallbackEvents[0]=terminal, i.e. fired once at call end) andsms-statusto our backend. We always respond200even on internal failure so Exotel does not retry forever (src/routes/exotelWebhook.routes.ts). - Recording streaming is a synchronous pass-through proxy (
axiosresponseType: '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 authenticatedGETof 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-statusreturnsenabled: false→ the frontend uses plaintel:links and hides SMS.exotel-callandsmsendpoints short-circuit with HTTP 503 before touching Exotel.- Internally
assertConfigured()throws a503-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_SUBDOMAINmust 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 comparesreq.query.tokento the env value viaverifyWebhookToken(). There is no HMAC/payload signing — the token must be kept secret and the callback served over HTTPS. A missing/emptyEXOTEL_WEBHOOK_TOKENmakes verification always fail (returns403). - Webhooks always return 200 on internal error. Both webhook handlers catch exceptions and respond
200withsuccess: falseso Exotel does not retry indefinitely on our bugs. Genuinely unknownCallSid/SmsSidreturn200 handled:false— calls/SMS placed outside the CRM are silently ignored. - Body parsing covers both content types.
Calls/connect.jsonregistersStatusCallbackContentType=application/json, but Exotel may also post form-encoded. The app mounts bothexpress.json()andexpress.urlencoded({ extended: true })(src/app.ts), soreq.bodyworks 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; onlydelivered/sent/failed-style transitions are applied. - Agent must have a phone.
callLeadthrows400if the calling user'sUser.phoneis empty — there is no default. Validation insetAgentPhonestrips non-digits and enforces 10–15 digits with an optional leading+. - Recording proxy is an allow-list, not an open proxy.
fetchRecordingStreamrejects any URL whose host does not end in.exotel.com(400), preventing SSRF through the recording endpoint. The browser only ever receives a boolean inlistCallsand must stream through the auth'd/recordingroute. - Scope checks.
assertLeadInScopeensures a sales head can only call/SMS/play recordings for leads in their pool; out-of-scope leads return404(not403, to avoid leaking existence). RegularSALESusers are auto-scoped to their assigned leads viasalesCrmAccessMiddleware. - SMS length limit: body is trimmed and must be 1–1000 characters (
400otherwise). - Multi-platform: the feature is not branched on the
x-platformheader — it is a single Exotel account per environment. Cluster selection is purely viaEXOTEL_SUBDOMAIN. - Shared table with Aarya.
lead_call_logsmixesmanual,aarya, andexotelrows; always filter bysourceandexotelCallSidwhen reasoning about click-to-call specifically. The webhook resolver matches byexotelCallSid, so it cannot accidentally touch Aarya rows. call_count/last_contacted_atonraw_leadsare bumped only on click-to-call dispatch (inside the same transaction as the log insert), not on SMS sends.
Related docs¶
- Sales CRM — Leads — the raw-leads CRM that hosts these endpoints, lead scoping, and the lead timeline these logs render on.
- Communications — WhatsApp — sibling channel;
LeadSmsLogmirrorsLeadWhatsAppLog. - Sales CRM — Aarya AI Calling — the other producer of
lead_call_logsrows. - Architecture — Routing & Middleware — auth, role, and sales-head page-permission middleware referenced here.
- Data Model — CRM schema — the
mas_crmschema tables.