Skip to content

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, or failed) 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 whatsappQueue and 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) and aisensy (backend.aisensy.com). Chosen by WHATSAPP_PROVIDER (env or system config), else auto-detected (Meta if Meta credentials exist, otherwise AiSensy). See getWhatsAppProvider in src/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. components is the rich Meta shape (header/body/button params); templateParams is 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_configs under WHATSAPP_META_MANAGER_CREDENTIALS, used when env vars are absent.
  • Failures portal — admin tooling that lists failed outbound rows and replays the stored requestPayload against 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 for LeadWhatsAppLog.
  • 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 — automation sendWhatsApp workflow node.
  • src/services/ApplicationMeetingReminderService.ts — schedules + sends reminder templates.
  • src/workers/whatsapp.worker.ts — BullMQ whatsappQueue consumer.

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 (replied is defined in the type but not currently set by the send/webhook code).
  • ApplicationWhatsAppMessageStatus = queued | submitted | sent | delivered | read | failed.
  • LeadWhatsAppStatus (a real Postgres enum lead_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 by adminMiddleware on 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 answered 200.

There are no Socket.IO events in this domain.


External integrations

Meta Cloud API (graph.facebook.com)

  • Base: https://graph.facebook.com/<graphApiVersion>/..., default version v17.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: apiKey in the JSON body (stripped before persisting requestPayload).
  • Requires WhatsAppConfig for the sending HR and AISENSY_API_KEY in system_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_US to en).
  • Document-header safety: for templates with a DOCUMENT header, ensureDocumentHeader strips non-downloadable URLs (rzp.io short links, scontent.whatsapp.net example 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=1 or ?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 only authMiddleware — the admin role gate is at the frontend. The WhatsApp Manager endpoints (/api/admin/mas/whatsapp-manager/*) do enforce adminMiddleware. CRM endpoints add salesCrmAccessMiddleware for lead-scope isolation.
  • userId source mismatch: WhatsAppController reads (req as any).user?.id, so it depends on authMiddleware attaching a user object (not just req.userId).
  • Application messages are Meta-only: sendApplicationMessage throws if the provider is not meta_cloud. Reminders therefore require Meta credentials.
  • AiSensy requires both a campaign and a sender config: missing campaignName or WhatsAppConfig throws; 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 reported MESSAGE_NOT_RETRYABLE (HTTP 422) — the operator must re-trigger from source. retryAllFailedMessages skips non-Meta rows.
  • Inbound idempotency: Meta inbound de-dupes on externalMessageId; AiSensy inbound does not de-dupe (it always inserts). Status callbacks update by externalMessageId only — 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: normalizeIndianPhoneNumber assumes +91 for bare national numbers; non-Indian numbers must already carry a + prefix. Meta destination strips to digits-only; AiSensy keeps the +.
  • replied status is dead: defined in the type union but never assigned by current code.
  • CRM logs live in a separate schema: mas_crm.lead_whatsapp_logs intentionally does not reuse whatsapp_messages (which has no leadId), keeping cross-schema CRM queries simple.
  • Credential precedence can surprise: env vars always win over stored encrypted creds and plaintext system config. The Manager credentials summary exposes per-field source diagnostics to debug which layer is in effect.
  • Encryption key fallback: stored Meta creds are encrypted with WHATSAPP_CREDENTIALS_ENCRYPTION_KEY or, 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 555 number that can only message 5 pre-verified numbers, a system-user token with a near-term expiry, and an unsubscribed messages webhook — all of which block real inbound/broadcast until fixed. Run scripts/whatsapp-diagnose.js to check account health.