Communications — WhatsApp¶
WhatsApp is the platform's templated outbound + inbound messaging channel. It powers three distinct audiences from one shared service layer: Mr. Hire recruiters messaging candidates (and automated interview/session reminders), the Sales CRM nurturing leads with approved templates, and a WhatsApp Manager admin console for managing the Meta WhatsApp Business Account (WABA) — templates, phone numbers, credentials, and a failed-send recovery portal. The integration supports two interchangeable providers (Meta Cloud API and AiSensy) selected by configuration, persists every send/receive for auditability, and ingests Meta/AiSensy webhooks for delivery-status and inbound message tracking.
Status: documented from source on this branch.
Overview¶
What this domain does:
- Outbound templated messaging to candidates and leads via WhatsApp Business templates (or raw text / media for in-session windows), through either Meta Cloud API or AiSensy.
- Inbound capture of candidate/lead replies via provider webhooks (currently logged, surfaced in conversation threads).
- Delivery-status tracking (
sent → delivered → read, orfailed) via webhook status callbacks. - WhatsApp Manager admin tooling — read the WABA, list/create/update/delete message templates, send a template preview, store encrypted Meta fallback credentials, and run a failures portal that re-POSTs failed messages verbatim.
- Async fan-out — interview/session reminders are scheduled onto a BullMQ
whatsappQueueand sent by a worker at the right time.
Who uses it (roles / personas):
| Persona | Surface | What they do |
|---|---|---|
| EXPERT / HR recruiter | /api/hr/whatsapp/* |
Register their WhatsApp number, send to candidates, read their inbox/conversations |
| ADMIN / SUPERADMIN | /api/admin/whatsapp/* + /api/admin/mas/whatsapp-manager/* |
Manage HR configs, view all messages, manage WABA templates + credentials, retry failures |
| SALES / SALES head / community managers | /api/sales/crm/:id/whatsapp |
Send approved templates to CRM leads; replies/sends show in the lead timeline |
| Student / candidate (recipient) | — | Receives templated session/interview reminders; can reply (inbound capture) |
| Meta / AiSensy (system) | /api/whatsapp/webhook |
Push delivery statuses + inbound messages |
Where it sits in the suite: WhatsApp is a cross-cutting communications capability inside
mr-mentor-backend. It is consumed by the Mr. Hire recruitment flows, the Sales CRM
(rawLead.controller + WorkflowEngineService), and the application meeting/interview reminder
pipeline. It shares the system_configs table for credentials and writes to both the default DB
schema (HR/application messages) and the mas_crm schema (lead logs).
Key concepts & entities¶
Glossary:
- Provider — the upstream WhatsApp gateway. Two are supported:
meta_cloud(Meta Graph API,graph.facebook.com) andaisensy(backend.aisensy.com). Chosen byWHATSAPP_PROVIDER(env or system config), else auto-detected (Meta if Meta credentials exist, otherwise AiSensy). SeegetWhatsAppProviderinsrc/services/WhatsAppService.ts. - WABA — WhatsApp Business Account on Meta, identified by
businessAccountId. The WhatsApp Manager reads/writes templates and phone numbers against it. - Template — a pre-approved Meta message template (name + language + category + components). Used for the first/cold message; required outside the 24-hour customer-service window.
- Components / templateParams — variable substitutions for a template.
componentsis the rich Meta shape (header/body/button params);templateParamsis the simpler positional-body shorthand the service converts into a body component. - Sender config (per-HR) — each HR registers their own WhatsApp number (
WhatsAppConfig). Required for AiSensy sends; optional for Meta Cloud (which sends from the WABA phone number). - Meta Cloud fallback credentials — Meta access token / phone number id / business account id /
graph version / webhook verify token, stored encrypted in
system_configsunderWHATSAPP_META_MANAGER_CREDENTIALS, used when env vars are absent. - Failures portal — admin tooling that lists
failedoutbound rows and replays the storedrequestPayloadagainst Meta Cloud. Only rows that captured a payload are retryable.
Main TypeORM entities:
| Entity | File | Table / schema | Purpose |
|---|---|---|---|
WhatsAppConfig |
src/entities/WhatsAppConfig.entity.ts |
whatsapp_configs |
Per-HR sender number, status, default campaign |
WhatsAppMessage |
src/entities/WhatsAppMessage.entity.ts |
whatsapp_messages |
All HR/candidate + CRM sends + inbound replies; carries requestPayload for retry |
ApplicationWhatsAppMessage |
src/entities/ApplicationWhatsAppMessage.entity.ts |
application_whatsapp_messages |
Application-scoped Meta-only sends (e.g. session reminders) |
LeadWhatsAppLog |
src/entities/LeadWhatsAppLog.ts |
mas_crm.lead_whatsapp_logs |
CRM-side audit row per lead send (mirrors LeadEmailLog) |
SystemConfig (shared) |
src/entities/SystemConfig.ts |
system_configs |
Stores AISENSY_API_KEY, WHATSAPP_*, encrypted Meta creds |
Supporting services/controllers:
src/services/WhatsAppService.ts(~2090 lines) — the engine: provider selection, send paths, Meta template CRUD, webhook handling, failures portal.src/services/LeadWhatsAppService.ts— thin persistence forLeadWhatsAppLog.src/controllers/whatsapp.controller.ts— HR + admin config/messaging + public webhook.src/controllers/whatsappManager.controller.ts— WhatsApp Manager (WABA templates, credentials, failures).src/controllers/rawLead.controller.ts— CRM lead send (sendWhatsApp,listWhatsApps).src/services/WorkflowEngineService.ts— automationsendWhatsAppworkflow node.src/services/ApplicationMeetingReminderService.ts— schedules + sends reminder templates.src/workers/whatsapp.worker.ts— BullMQwhatsappQueueconsumer.
Architecture¶
flowchart TD
subgraph Clients["Callers"]
HR["HR / Recruiter UI"]
ADM["Admin / WhatsApp Manager UI"]
SALES["Sales CRM UI"]
WF["Workflow Automation Engine"]
REM["Reminder scheduler"]
PROV["Meta / AiSensy (webhooks)"]
end
subgraph Routes["Express routes"]
WR["whatsapp.routes (/api)"]
AR["admin.routes (/api/admin)"]
RR["rawLead.routes (/api)"]
end
subgraph Controllers["Controllers"]
WC["WhatsAppController"]
WMC["WhatsAppManagerController"]
RLC["RawLeadController"]
end
subgraph Svc["Service layer"]
WAS["WhatsAppService"]
LWS["LeadWhatsAppService"]
WFE["WorkflowEngineService"]
AMR["ApplicationMeetingReminderService"]
end
subgraph Async["Async"]
Q["BullMQ whatsappQueue"]
WW["whatsapp.worker"]
end
subgraph Stores["Persistence"]
CFG["whatsapp_configs"]
MSG["whatsapp_messages"]
AMSG["application_whatsapp_messages"]
LLOG["mas_crm.lead_whatsapp_logs"]
SC["system_configs (creds)"]
RDS["Redis (Meta metadata cache)"]
end
subgraph Ext["External"]
META["Meta Graph API graph.facebook.com"]
AIS["AiSensy backend.aisensy.com"]
end
HR --> WR --> WC --> WAS
ADM --> AR --> WMC --> WAS
SALES --> RR --> RLC --> WAS
RLC --> LWS --> LLOG
WF --> WFE --> WAS
WFE --> LWS
REM --> AMR --> Q --> WW --> AMR
AMR --> WAS
PROV --> WR --> WC --> WAS
WAS --> CFG
WAS --> MSG
WAS --> AMSG
WAS --> SC
WAS --> RDS
WAS --> META
WAS --> AIS
Data model¶
erDiagram
USER ||--o| WHATSAPP_CONFIG : "registers"
USER ||--o{ WHATSAPP_MESSAGE : "sends or receives"
JOB_APPLICATION ||--o{ WHATSAPP_MESSAGE : "linked to"
APPLICATION ||--o{ APPLICATION_WHATSAPP_MESSAGE : "has"
USER ||--o{ APPLICATION_WHATSAPP_MESSAGE : "sends"
RAW_LEAD ||--o{ LEAD_WHATSAPP_LOG : "logged for"
USER ||--o{ LEAD_WHATSAPP_LOG : "sent by"
SYSTEM_CONFIG ||--o{ WHATSAPP_MESSAGE : "credentials drive sends"
WHATSAPP_CONFIG {
uuid id PK
uuid userId UK
string whatsappNumber
string displayName
string status "pending|active|inactive|suspended"
string defaultCampaignName
boolean isActive
}
WHATSAPP_MESSAGE {
uuid id PK
uuid sentByUserId FK
string candidatePhone
string candidateName
string fromNumber
string direction "outbound|inbound"
string campaignName
jsonb templateParams
text messageBody
string status
string externalMessageId
uuid applicationId FK
text failureReason
string provider "meta_cloud|aisensy"
jsonb requestPayload
int retryCount
timestamp lastRetryAt
timestamp deliveredAt
timestamp readAt
}
APPLICATION_WHATSAPP_MESSAGE {
uuid id PK
uuid applicationId FK
uuid userId FK
string candidatePhone
string fromNumber "meta-cloud"
string direction
text messageBody
string status
string externalMessageId
text failureReason
string provider
jsonb requestPayload
int retryCount
timestamp deliveredAt
timestamp readAt
}
LEAD_WHATSAPP_LOG {
uuid id PK
uuid leadId FK
uuid sentBy FK
string templateName
string languageCode
jsonb templateParams
string toPhone
enum status "queued|sent|failed"
string externalMessageId
text errorMessage
timestamp sentAt
}
Notable status fields / enums:
WhatsAppMessageStatus=queued | submitted | sent | delivered | read | failed | replied(repliedis defined in the type but not currently set by the send/webhook code).ApplicationWhatsAppMessageStatus=queued | submitted | sent | delivered | read | failed.LeadWhatsAppStatus(a real Postgres enumlead_whatsapp_status) =queued | sent | failed.WhatsAppConfig.status=pending | active | inactive | suspended.
Note on provider: outbound rows record which gateway sent them. A retry only replays Meta Cloud
rows (requestPayload present and provider meta_cloud).
API surface¶
whatsapp.routes is mounted at /api (src/routes/index.ts), admin.routes at /api/admin, and
rawLead.routes at /api. Paths below are the full external paths.
HR + candidate messaging (whatsapp.routes)¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| GET | /api/whatsapp/webhook |
Public | Meta webhook verification (hub.challenge echo, verify-token check) |
| POST | /api/whatsapp/webhook |
Public | Inbound messages + delivery status callbacks (Meta + AiSensy) |
| GET | /api/admin/whatsapp/configs |
authMiddleware (admin via UI) |
List all HR WhatsApp sender configs |
| POST | /api/admin/whatsapp/configs |
authMiddleware |
Create/update an HR config (userId + number) |
| PATCH | /api/admin/whatsapp/configs/:userId/status |
authMiddleware |
Set config status |
| DELETE | /api/admin/whatsapp/configs/:userId |
authMiddleware |
Remove an HR config |
| GET | /api/admin/whatsapp/messages |
authMiddleware |
Paginated all-messages view (merges both message tables) |
| GET | /api/hr/whatsapp/config |
authMiddleware |
HR reads own sender config |
| PUT | /api/hr/whatsapp/config |
authMiddleware |
HR registers/updates own number |
| POST | /api/hr/whatsapp/send |
authMiddleware |
HR sends to a candidate |
| GET | /api/hr/whatsapp/messages |
authMiddleware |
HR inbox (own messages, paginated) |
| GET | /api/hr/whatsapp/conversation/:phone |
authMiddleware |
Thread with one candidate phone |
| GET | /api/hr/whatsapp/application/:applicationId |
authMiddleware |
All messages for a job application |
Note: the config/admin routes use only
authMiddleware; the admin role gate is enforced at the frontend (per the route comments), not byadminMiddlewareon these specific endpoints.
WhatsApp Manager (admin.routes, mounted at /api/admin)¶
All require authMiddleware + adminMiddleware.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/mas/whatsapp-manager/overview |
WABA details + phone numbers + templates (cacheable) |
| GET | /api/admin/mas/whatsapp-manager/credentials |
Masked credential summary + source diagnostics |
| POST | /api/admin/mas/whatsapp-manager/credentials |
Upsert encrypted Meta fallback credentials |
| DELETE | /api/admin/mas/whatsapp-manager/credentials |
Delete stored fallback credentials |
| GET | /api/admin/mas/whatsapp-manager/templates |
List templates (optional ?name=) |
| GET | /api/admin/mas/whatsapp-manager/templates/:templateId |
Template by id |
| POST | /api/admin/mas/whatsapp-manager/templates |
Create template |
| POST | /api/admin/mas/whatsapp-manager/templates/send-preview |
Send a template to a test number |
| POST | /api/admin/mas/whatsapp-manager/templates/:templateId |
Update template |
| DELETE | /api/admin/mas/whatsapp-manager/templates?name= |
Delete template by name |
| GET | /api/admin/mas/whatsapp-manager/failures/stats |
Failed/total/retryable counts |
| GET | /api/admin/mas/whatsapp-manager/failures |
Paginated failed messages (?scope=&page=&limit=) |
| POST | /api/admin/mas/whatsapp-manager/failures/retry-all |
Retry every retryable failure ({ scope }) |
| POST | /api/admin/mas/whatsapp-manager/failures/:scope/:id/retry |
Retry a single failed message |
CRM lead messaging (rawLead.routes, mounted at /api)¶
| Method | Path | Auth/role | Purpose |
|---|---|---|---|
| POST | /api/sales/crm/:id/whatsapp |
authMiddleware + salesCrmAccessMiddleware |
Send template/text/media to a lead |
| GET | /api/sales/crm/:id/whatsapp-messages |
authMiddleware + salesCrmAccessMiddleware |
Lead WhatsApp timeline |
| GET | /api/admin/raw-leads/:id/whatsapp-messages |
authMiddleware + salesCrmAccessMiddleware |
Lead WhatsApp timeline (admin side) |
User journeys¶
1. Configure a WhatsApp account / provider¶
There are two configuration surfaces: per-HR sender registration (AiSensy path) and the Meta Cloud fallback credentials managed by the WhatsApp Manager. Provider selection is computed at send time.
sequenceDiagram
participant ADM as Admin
participant AR as admin.routes
participant WMC as WhatsAppManagerController
participant WAS as WhatsAppService
participant SC as system_configs
participant RDS as Redis
Note over ADM,RDS: Store Meta Cloud fallback credentials
ADM->>AR: POST /api/admin/mas/whatsapp-manager/credentials
AR->>WMC: upsertCredentials
WMC->>WMC: require phoneNumberId and businessAccountId
alt accessToken missing and no stored token
WMC-->>ADM: 400 accessToken required
else ok
WMC->>WAS: upsertMetaManagerCredentials
WAS->>WAS: encryptSecret of JSON blob
WAS->>SC: save WHATSAPP_META_MANAGER_CREDENTIALS
WAS->>RDS: clearMetaManagerCache
WMC->>WAS: getMetaManagerCredentialSummary
WAS-->>WMC: masked summary plus diagnostics
WMC-->>ADM: 200 saved
end
Note over ADM,RDS: HR registers their own sender number
ADM->>AR: POST /api/admin/whatsapp/configs body userId and number
AR->>WMC: handled by WhatsAppController upsertConfig
WMC->>WAS: upsertConfig sets status active
WAS->>SC: persisted to whatsapp_configs
WMC-->>ADM: 200 config saved
Credential resolution order (per getMetaCloudConfig): env var → encrypted system config →
plaintext system config → default (graph version only). AiSensy uses AISENSY_API_KEY from
system_configs.
2. Send a templated WhatsApp message (HR to candidate)¶
The send path normalizes the phone, picks the provider, writes a queued row, then dispatches.
sequenceDiagram
participant HR as HR UI
participant WR as whatsapp.routes
participant WC as WhatsAppController
participant WAS as WhatsAppService
participant DB as whatsapp_messages
participant META as Meta Graph API
participant AIS as AiSensy
HR->>WR: POST /api/hr/whatsapp/send
WR->>WC: sendMessage
WC->>WC: require candidatePhone and one of campaignName messageBody templateName media
WC->>WAS: sendMessage params
WAS->>WAS: normalizeIndianPhoneNumber then getWhatsAppProvider
WAS->>WAS: getSenderConfig allowMissing when meta_cloud
WAS->>DB: insert row status queued
alt provider is meta_cloud
WAS->>WAS: ensureDocumentHeader for template with document header
WAS->>META: POST phone_number_id messages with template payload
META-->>WAS: 200 with wamid
WAS->>DB: status submitted plus externalMessageId
else provider is aisensy
WAS->>WAS: require campaignName plus AISENSY_API_KEY plus sender config
WAS->>AIS: POST campaign t1 api v2
AIS-->>WAS: 200 with messageId
WAS->>DB: status sent plus externalMessageId
end
WAS-->>WC: saved message
WC-->>HR: 200 message sent
Note over WAS,META: Missing-translation fallback
alt Meta returns code 132001
WAS->>WAS: getPrimaryLanguageFallbackPayload strip region code
WAS->>META: retry with primary language
end
On Meta failure the row is set failed with failureReason, and requestPayload is captured so the
failures portal can replay it later.
3. Send a templated message to a CRM lead¶
Sales reps message leads via approved templates. The same WhatsAppService.sendMessage powers it,
plus a CRM-side audit row in mas_crm.
sequenceDiagram
participant S as Sales rep
participant RR as rawLead.routes
participant RLC as RawLeadController
participant WAS as WhatsAppService
participant LWS as LeadWhatsAppService
participant LLOG as lead_whatsapp_logs
S->>RR: POST /api/sales/crm/:id/whatsapp
RR->>RLC: sendWhatsApp after salesCrmAccessMiddleware
RLC->>RLC: assertLeadInScope then require template text or media
RLC->>RLC: load lead then require lead.phone
RLC->>WAS: sendMessage with lead phone and template
alt send succeeds
WAS-->>RLC: message with status
RLC->>LWS: recordSend status SENT or FAILED
LWS->>LLOG: insert audit row
RLC-->>S: 200 data message
else send throws
RLC->>LWS: recordSend status FAILED with errorMessage
LWS->>LLOG: insert failure row
RLC-->>S: error surfaced
end
A successful provider call maps to LeadWhatsAppStatus.SENT unless the message row itself came back
failed. Free-text sends are logged with synthetic template name __free_text__; media-only with
__media__, so the timeline row is never blank.
4. Automated nurture via the workflow engine¶
A workflow sendWhatsApp node fires the same service from WorkflowEngineService, resolving lead
field tokens into template params before sending.
sequenceDiagram
participant WFE as WorkflowEngineService
participant WAS as WhatsAppService
participant LWS as LeadWhatsAppService
participant META as Meta Graph API
WFE->>WFE: runSendWhatsApp resolves templateName and params
WFE->>WFE: resolveWaParam substitutes lead fields
WFE->>WAS: sendMessage userId is workflow author
WAS->>META: send template
META-->>WAS: result
WAS-->>WFE: message
WFE->>LWS: recordSend with leadWhatsAppLogId
Note over WFE: empty params are guarded since Meta rejects blanks
5. Scheduled session / interview reminder (queued via worker)¶
Reminders are enqueued onto whatsappQueue with a delay, then sent by the worker at send time as an
application-scoped Meta Cloud template.
sequenceDiagram
participant AMR as ReminderService
participant Q as whatsappQueue
participant WW as whatsapp.worker
participant WAS as WhatsAppService
participant META as Meta Graph API
participant AMSG as application_whatsapp_messages
AMR->>Q: addWhatsAppJob type APPLICATION_MEETING_REMINDER with delay
Note over Q: jobId is application-meeting-reminder slotId
Q-->>WW: job becomes ready at send time
WW->>AMR: processReminderJob slotId
AMR->>AMR: load slot then require CONFIRMED and phone
AMR->>AMR: skip if student opted out of WhatsApp alerts
AMR->>AMR: build login token and reminder button suffix
AMR->>WAS: sendApplicationMessage Meta only with template
WAS->>AMSG: insert row status queued
WAS->>META: POST messages template payload
META-->>WAS: 200 with wamid
WAS->>AMSG: status submitted plus externalMessageId
WAS-->>AMR: saved
WW-->>Q: job completed
sendApplicationMessage throws if the provider is not Meta Cloud — application messages always go
through Meta.
6. Inbound message handling¶
A candidate/lead reply arrives via the provider webhook. The service detects shape (Meta vs AiSensy)
and persists an inbound row, de-duplicating on externalMessageId.
sequenceDiagram
participant PROV as Meta or AiSensy
participant WR as whatsapp.routes
participant WC as WhatsAppController
participant WAS as WhatsAppService
participant DB as whatsapp_messages
PROV->>WR: POST /api/whatsapp/webhook
WR->>WC: handleWebhook
WC->>WAS: handleWebhook payload
alt payload has entry array
WAS->>WAS: handleMetaWebhook iterate changes
loop each incoming message
WAS->>DB: find by externalMessageId
alt already exists
WAS->>WAS: skip duplicate
else new
WAS->>WAS: findConfigForMetaWebhook by display number
WAS->>WAS: getWebhookText extract body
WAS->>DB: insert inbound status delivered
end
end
else AiSensy event type message or incoming
WAS->>WAS: handleIncomingAiSensyMessage
WAS->>DB: insert inbound status delivered
end
WC-->>PROV: always 200 to prevent retries
The controller always returns 200 (even on error) so the provider does not retry the webhook.
7. Delivery-status callbacks¶
Status updates (sent, delivered, read, failed) arrive on the same webhook endpoint and update
matching outbound rows by externalMessageId across both message tables.
sequenceDiagram
participant PROV as Meta or AiSensy
participant WC as WhatsAppController
participant WAS as WhatsAppService
participant DB as whatsapp_messages
participant AMSG as application_whatsapp_messages
PROV->>WC: POST /api/whatsapp/webhook status payload
WC->>WAS: handleWebhook
alt Meta statuses array
WAS->>WAS: handleMetaStatusUpdates map status
loop each status
WAS->>DB: find by externalMessageId then set status and timestamps
WAS->>AMSG: find by externalMessageId then set status and timestamps
end
else AiSensy status or delivery
WAS->>WAS: handleAiSensyStatusUpdate
WAS->>DB: set status deliveredAt readAt or failureReason
end
WC-->>PROV: 200
delivered sets deliveredAt, read sets readAt, failed sets failureReason (from the status
errors array via getStatusFailureReason).
8. Recover a failed send (failures portal)¶
Admins retry failed Meta Cloud sends by replaying the stored payload verbatim.
sequenceDiagram
participant ADM as Admin
participant AR as admin.routes
participant WMC as WhatsAppManagerController
participant WAS as WhatsAppService
participant DB as message tables
participant META as Meta Graph API
ADM->>AR: GET /api/admin/mas/whatsapp-manager/failures
AR->>WMC: getFailures
WMC->>WAS: listFailedMessages scope page limit
WAS->>DB: query failed outbound rows
WAS-->>WMC: rows with retryable flag
WMC-->>ADM: list
ADM->>AR: POST failures scope id retry
AR->>WMC: retryFailure
WMC->>WAS: retryFailedMessage
alt no stored payload or non meta_cloud
WAS-->>WMC: throw MESSAGE_NOT_RETRYABLE
WMC-->>ADM: 422 re-trigger from source
else retryable
WAS->>WAS: increment retryCount and set lastRetryAt
WAS->>META: postMetaCloudPayload requestPayload
META-->>WAS: new wamid
WAS->>DB: status submitted and clear failureReason
WMC-->>ADM: 200 re-sent
end
retry-all iterates every retryable failed row per scope and never throws per-row — it returns a
summary of attempted / succeeded / failed / skipped.
Background jobs & async¶
| Mechanism | Detail |
|---|---|
| Queue | whatsappQueue (BullMQ). Producer QueueService.addWhatsAppJob adds job name sendWhatsApp. |
| Worker | src/workers/whatsapp.worker.ts, concurrency 5, Redis-backed. Imported at boot in src/index.ts. |
| Job types | Only APPLICATION_MEETING_REMINDER is handled (requires slotId); any other type throws Unsupported WhatsApp job type. |
| Scheduling | ApplicationMeetingReminderService.scheduleReminder computes delay from slot start minus lead minutes, uses jobId = application-meeting-reminder-<slotId> (idempotent), removeOnComplete/Fail = 100. |
| Worker init | The worker creates its own ApplicationMeetingReminderService after the DB initializes; jobs throw if the service is not ready yet. |
Webhooks (not BullMQ but async ingress):
GET /api/whatsapp/webhook— Meta verification handshake (hub.mode,hub.verify_token,hub.challenge). The verify token is checked against the configured Meta webhook verify token.POST /api/whatsapp/webhook— inbound messages + delivery statuses, always answered200.
There are no Socket.IO events in this domain.
External integrations¶
Meta Cloud API (graph.facebook.com)¶
- Base:
https://graph.facebook.com/<graphApiVersion>/..., default versionv17.0. - Used for: sending messages, media upload (
/<phoneNumberId>/media), template CRUD on the WABA, reading WABA details + phone numbers. - Auth:
Authorization: Bearer <accessToken>.
AiSensy (backend.aisensy.com/campaign/t1/api/v2)¶
- Used for: campaign-based template sends keyed by
campaignName+ per-HR sender number. - Auth:
apiKeyin the JSON body (stripped before persistingrequestPayload). - Requires
WhatsAppConfigfor the sending HR andAISENSY_API_KEYinsystem_configs.
Provider selection & feature flags¶
| Config | Where | Effect |
|---|---|---|
WHATSAPP_PROVIDER |
env or system_configs |
meta/meta_cloud or aisensy; else auto-detect |
WHATSAPP_ACCESS_TOKEN |
env / system_config / encrypted | Meta access token |
WHATSAPP_PHONE_NUMBER_ID |
env / system_config / encrypted | Meta sender phone id |
WHATSAPP_BUSINESS_ACCOUNT_ID |
env / system_config / encrypted | WABA id (required for Manager) |
WHATSAPP_GRAPH_API_VERSION |
env / system_config / encrypted | Graph API version (default v17.0) |
WHATSAPP_WEBHOOK_VERIFY_TOKEN |
env / system_config / encrypted | Meta webhook verification |
WHATSAPP_META_MANAGER_CREDENTIALS |
system_configs (encrypted JSON) |
Fallback Meta creds bundle |
WHATSAPP_CREDENTIALS_ENCRYPTION_KEY / PLATFORM_ENCRYPTION_KEY |
env | Key for encrypt/decrypt of stored creds |
AISENSY_API_KEY |
system_configs |
AiSensy auth |
Failure / fallback behavior¶
- Provider auto-fallback: if no explicit
WHATSAPP_PROVIDER, Meta is used when Meta credentials resolve, else AiSensy. - Missing-translation fallback: on Meta error code
132001(template translation missing), the service retries with the primary language code (strips the region suffix, e.g.en_UStoen). - Document-header safety: for templates with a
DOCUMENTheader,ensureDocumentHeaderstrips non-downloadable URLs (rzp.ioshort links,scontent.whatsapp.netexample URLs, Razorpay HTML pages) and, if needed, generates a placeholder invoice PDF and uploads it to Meta. - Send-preview retry: if Meta complains a 0-param template got components, the preview retries without components.
- Webhook safety: webhook endpoint always returns 200 to suppress provider retries; inbound
de-dupes on
externalMessageId. - Redis cache: WhatsApp Manager metadata (WABA, phone numbers, templates) is cached in Redis for
24h under
whatsapp:meta-manager:*. Cache is cleared on credential/template mutation, and callers can pass?bypassCache=1or?clearCache=1.
Compliance note (strategy doc)¶
WHATSAPP_LEADS_STRATEGY.md documents that the WABA received a Meta spam warning after bulk-sending to
cold vendor leads. Key rules: a number in the DB is not consent; Meta judges purely on
recipient block/report rates (no consent/source field is ever sent); cold vendor data must be worked
by phone/SMS first and only WhatsApp-messaged after a real opt-in. WhatsApp is the follow-up reward
for converted leads, not a cold-blast channel.
Status lifecycles¶
Outbound message (whatsapp_messages / application_whatsapp_messages)¶
stateDiagram-v2
[*] --> queued: row created
queued --> submitted: Meta Cloud accepted
queued --> sent: AiSensy accepted
queued --> failed: provider error at send
submitted --> sent: webhook status sent
sent --> delivered: webhook status delivered
submitted --> delivered: webhook status delivered
delivered --> read: webhook status read
submitted --> failed: webhook status failed
sent --> failed: webhook status failed
failed --> submitted: manual retry replays payload
read --> [*]
delivered --> [*]
Inbound message¶
stateDiagram-v2
[*] --> delivered: inbound captured from webhook
delivered --> [*]
CRM lead log (lead_whatsapp_logs)¶
stateDiagram-v2
[*] --> queued: default
queued --> sent: provider accepted
queued --> failed: send threw or returned failed
sent --> [*]
failed --> [*]
HR sender config¶
stateDiagram-v2
[*] --> pending: default on create
pending --> active
active --> inactive
active --> suspended
inactive --> active
suspended --> active
note right of active
upsertConfig creates new rows as active
sends are blocked unless status is active
end note
Edge cases, limits & gotchas¶
- Auth gating split: HR/admin config endpoints (
/api/admin/whatsapp/*,/api/hr/whatsapp/*) use onlyauthMiddleware— the admin role gate is at the frontend. The WhatsApp Manager endpoints (/api/admin/mas/whatsapp-manager/*) do enforceadminMiddleware. CRM endpoints addsalesCrmAccessMiddlewarefor lead-scope isolation. userIdsource mismatch:WhatsAppControllerreads(req as any).user?.id, so it depends onauthMiddlewareattaching auserobject (not justreq.userId).- Application messages are Meta-only:
sendApplicationMessagethrows if the provider is notmeta_cloud. Reminders therefore require Meta credentials. - AiSensy requires both a campaign and a sender config: missing
campaignNameorWhatsAppConfigthrows; Meta Cloud can send with no per-HR config (uses the WABA number). - Retry only replays captured payloads: rows sent before payload-capture shipped (no
requestPayload) and non-Meta rows are reportedMESSAGE_NOT_RETRYABLE(HTTP 422) — the operator must re-trigger from source.retryAllFailedMessagesskips non-Meta rows. - Inbound idempotency: Meta inbound de-dupes on
externalMessageId; AiSensy inbound does not de-dupe (it always inserts). Status callbacks update byexternalMessageIdonly — unknown ids are silently ignored. - Webhook always 200: errors inside webhook handling are swallowed and still answered 200, so a malformed payload will not surface as an error to the provider (check logs).
- Phone normalization is India-centric:
normalizeIndianPhoneNumberassumes+91for bare national numbers; non-Indian numbers must already carry a+prefix. Meta destination strips to digits-only; AiSensy keeps the+. repliedstatus is dead: defined in the type union but never assigned by current code.- CRM logs live in a separate schema:
mas_crm.lead_whatsapp_logsintentionally does not reusewhatsapp_messages(which has noleadId), keeping cross-schema CRM queries simple. - Credential precedence can surprise: env vars always win over stored encrypted creds and
plaintext system config. The Manager
credentialssummary exposes per-field source diagnostics to debug which layer is in effect. - Encryption key fallback: stored Meta creds are encrypted with
WHATSAPP_CREDENTIALS_ENCRYPTION_KEYor, if unset,PLATFORM_ENCRYPTION_KEY; an empty key will make decrypt fail silently and fall back to env/plaintext config. - Known production constraints (strategy doc): the WABA had a test
+1 555number that can only message 5 pre-verified numbers, a system-user token with a near-term expiry, and an unsubscribedmessageswebhook — all of which block real inbound/broadcast until fixed. Runscripts/whatsapp-diagnose.jsto check account health.