WhatsApp Module¶
One-line: The WhatsApp module owns everything from WABA onboarding (two provider paths — Gupshup and Meta TSP), template lifecycle, opt-in/opt-out compliance, catalogue + login helpers, through to the high-frequency send pipeline (
whatsapp_queue_*collections drained by 1-sec / 3-sec crons → Gupshup or Meta Graph API) and click / message-limit / retry bookkeeping.
Validation legend¶
| Symbol | Meaning |
|---|---|
| ✅ | Verified in code this review |
| 📄 | From PM Excel / business docs (not code) |
| 💻 | Code detail (function/route/field confirmed) |
| ♻️ | Duplicated / overlapping implementation |
| ⚰️ | Legacy / being deprecated |
| ⚠️ | Bug, risk, or footgun |
1. Overview¶
WhatsApp is the primary retention channel in Prism (📄 Communication Layer sheet). A brand must have a WABA (WhatsApp Business Account) and pre-approved templates before any marketing/utility message can be sent. Prism supports two provider integrations:
- Gupshup (BSP path) ✅ —
controllers/gupshupOnboardingController.js. Partner-API onboarding, template create/edit/delete, media handle upload, business-profile management, opt-in tracking.whatsapp_configuration_type = "gupshup". - Meta TSP (direct Cloud API path) ✅ —
controllers/metaTSP.js. OAuth access-token exchange, WABA subscribe, phone register, template create (TEXT/IMAGE/VIDEO/CAROUSEL), inbound webhook.whatsapp_configuration_type = "facebook". Roadmap B3.13 Meta TSP — In Progress 📄.
Supporting surfaces: catalogue (whatsappCatalogueController.js — WhatsApp ordering menu + FB product catalog) and WhatsApp login (whatsappLoginControler.js — wa.me deep links).
Green Tick / WABA / DLT context 📄: Green Tick is the WhatsApp verified-business badge; WABA is the messaging account; DLT is the Indian SMS registration (SMS-only, see SMS-Push). WhatsApp does not use DLT but does enforce per-number messaging-limit tiers (
waba_limit/meta_waba_limit).⚠️ Known bug E1.3 — "WhatsApp volume reports inaccurate" (P0) 📄 (Roadmap). Sent/delivered volume shown on dashboards does not reconcile with
whatsapp_queue_*process_statuscounts. Root causes suspected in this module: (a) delivery webhooks not fully reconciled (consumer-receiptsLambda is stubbed ⚠️ — see High-Level-Architecture §7); (b)process_status=3("sent, pending webhook") counted as delivered.
2. Business Flow¶
Example journey (marketer sends a WhatsApp campaign)¶
- Onboarding (one-time) 📄 — Client provides WABA + gets templates approved; Ops maps them in Prism. Go-Live checklist step 4 (Business-Flow §1).
- Marketer builds a campaign in Campaigns → audience + approved template + variables.
- Campaign engine enqueues one row per recipient into
whatsapp_queue_YYYYMMDDwithprocess_status:0. sendWhatsappMessageCron(marketing) polls every 3s, hands the batch toprocessWhatsappMsgData.- Per message: opt-in checked (unless cold campaign), provider chosen (gupshup/facebook), template rendered, message POSTed to provider.
process_status → 3. - Provider fires a delivery/read webhook → (monolith webhook handler +
api-receiptsLambda). Opt-outs and failures reconciled by the daily crons.
Edge cases¶
- Not opted-in ✅ — For non-cold Gupshup sends, if
comm_arraylacks"whatsapp",whatsappMsgProcessadds it viaupdateCommArrayAcrossCollections()before sending (implicit opt-in on send). - Opt-out / block ✅ — Provider error codes 131026 (user opt-out) or 470 (blocked) →
whatsappOptOutCronstrips"whatsapp"from the customercomm_array. - Welcome-message race ✅ — For parent
7175, if the customer placed an order betweeninserted_atandscheduled_at, the welcome message is skipped (process_status=4) to avoid a redundant greeting. - Failed marketing units ✅ —
whatsappRetryCampaignCronrefunds non-retryable failures to the business wallet and re-queues retryable ones as a free (amount:0) campaign. - Media too large / wrong type ✅ —
validateGupshupMediaImagerejects non-image/png|jpegor> 2 MB.
3. Technical Flow¶
- Source ✅ — Campaign/journey engine (
processCampaign.js,automatedCampaignQueueNew.js), transactional producers (Digital Bill, Feedback, low-rating), or onboarding UI. - API ✅ — Onboarding/template/profile routes under
routes/crm.js(auth); provider webhooks (/whatsapp/webhook/callback, no auth). - Validation ✅ — Mobile 10-digit
[6-9]; media validated byvalidateGupshupMediaImage; opt-in check inwhatsappMsgProcess. - Logic ✅ —
processWhatsappMsgDataselects provider bywhatsapp_configuration_type, staggers sends 66 ms apart, renders templates viaprocessCampaignWhatsappHelper(variable + JWT button substitution). - DB ✅ — Reads/writes
whatsapp_queue_YYYYMMDD,business_configs,customers_${parentId},wallet_transactions,addo_campaigns. - Events / Queue ✅ —
process_statusstate machine:0 → 2 → 3 → 4. Fast-path polling (no broker) — Queues. - Notification ✅ — Actual send to Gupshup
https://api.gupshup.io/wa/api/v1/template/msgor Metagraph.facebook.com/v19.0/{phoneNumberId}/messages. - Response ✅ — Provider
messageIdstored on the queue row; delivery/read arrives asynchronously via webhook.
4. Architecture Diagram¶
Flowchart¶
flowchart TD
subgraph Onboard["Onboarding & Config (control plane)"]
GUP[gupshupOnboardingController]
META[metaTSP]
CAT[whatsappCatalogueController]
LOGIN[whatsappLoginControler]
end
GUP -->|Partner API| GAPI[(Gupshup Partner API)]
META -->|OAuth + Graph API| MAPI[(Meta Graph API v22.0)]
subgraph Send["Send pipeline (data plane)"]
PROD[Campaign / Journey / Txn producers]
WQ[(whatsapp_queue_YYYYMMDD)]
C1[sendWhatsappMessageCron 3s\nmarketing: type absent]
C2[sendWhatsappTransactionalCron 1s\ntxn: type present]
C3[sendWhatsappRemainingCron 0 0 * * *]
PROC[processWhatsappMsgData]
end
PROD --> WQ
WQ --> C1 --> PROC
WQ --> C2 --> PROC
WQ --> C3 --> PROC
PROC -->|facebook| MSEND[(Meta messages API v19.0)]
PROC -->|gupshup| GSEND[(Gupshup template/msg)]
subgraph Recon["Reconciliation crons"]
CLICK[whatsappClick 15m]
OPTOUT[whatsappOptOutCron 0 0]
LIMIT[whatsappMsgLimit 1 AM]
RETRY[whatsappRetryCampaignCron hourly]
end
GSEND -.webhook.-> WEBHOOK[monolith webhook + api-receipts λ ⚠️stub]
WEBHOOK --> WQ
CLICK --> CAMP[(addo_campaigns.links_clicked)]
OPTOUT --> CUST[(customers_parentId.comm_array)]
LIMIT --> CFG[(business_configs.waba_limit)]
RETRY --> WALLET[(wallet_transactions refund)]
Send + receipt sequence¶
sequenceDiagram
autonumber
participant PROD as Campaign/Txn producer
participant WQ as whatsapp_queue_YYYYMMDD
participant CR as sendWhatsapp*Cron
participant PR as processWhatsappMsgData
participant PROV as Gupshup / Meta
participant WH as Webhook (monolith + api-receipts λ)
participant OO as whatsappOptOutCron
PROD->>WQ: insert {process_status:0, whatsapp_configuration_type, template, type?}
CR->>WQ: poll {scheduled_at<=now, process_status:0}
CR->>PR: batch (≤10000)
PR->>WQ: set process_status=2 (picked)
PR->>PR: opt-in check (skip if cold_campaign)
PR->>PROV: POST template message (66ms stagger)
PROV-->>PR: {status:"submitted", messageId}
PR->>WQ: set process_status=3 + messageId
PROV->>WH: delivery / read / error webhook
Note over WH: consumer-receipts λ log-only ⚠️ (E1.3)
WH->>WQ: update messageError / read_count
OO->>WQ: nightly scan errorCode∈{131026,470}
OO->>WQ: strip "whatsapp" from comm_array
5. Folder Structure (real files)¶
uengage-crm/
├── controllers/
│ ├── gupshupOnboardingController.js ✅ Gupshup onboarding, templates, profile, opt-in
│ ├── metaTSP.js ✅ Meta TSP OAuth, templates, webhook
│ ├── whatsappCatalogueController.js ✅ WhatsApp ordering menu + FB catalog
│ └── whatsappLoginControler.js ✅ wa.me login deep links
├── crons/
│ ├── sendWhatsappMessageCron.js ✅ marketing sends (every 3s)
│ ├── sendWhatsappTransactionalCron.js ✅ transactional sends (every 1s)
│ ├── sendWhatsappRemainingCron.js ✅ prior-day sweep (midnight)
│ ├── whatsappClick.js ✅ link-click aggregation (15m)
│ ├── whatsappOptOutCron.js ✅ opt-out reconciliation (midnight)
│ ├── whatsappMsgLimit.js ✅ WABA limit sync (1 AM)
│ └── whatsappRetryCampaignCron.js ✅ failed-campaign retry + refund (hourly)
├── processLibrary/
│ ├── whatsappMsgProcess.js ✅ consumer: provider select + send
│ └── processCampaignWhatsappHelper.js ✅ template var + JWT button substitution
└── commonFunctions/
├── validateGupshupMediaImage.js ✅ media type/size validation
└── metaFileUploading.js ✅ multer storage for Meta media upload
Related prism-services (serverless) files handle the send/receipt Kafka path but the consumers are stubbed ♻️⚠️ — see map-05 / High-Level-Architecture.
6. Database¶
Collections / tables¶
| Store | Name | Purpose | Key fields ✅ | Indexes |
|---|---|---|---|---|
| Mongo | whatsapp_queue_YYYYMMDD |
Date-partitioned send queue | destination, scheduled_at, template, media_data, campaign_id, parent_business_id, business_id, whatsapp_configuration_type, process_status, type, messageError, read_count |
none defined ⚠️ (verify) |
| Mongo | whatsapp_cron_status |
Cron run-lock / crash detection | status (false/true/"crashed"), start_time |
none |
| Mongo | business_configs |
Per-business WABA config | whatsapp_configuration.gupshup/.facebook, gupshup_app_id, gupshup_api_key, gupshup_onboarding_status, waba_limit, meta_phone_number_id, waba_id, meta_access_token, phone_quality, whatsapp_account_status |
none |
| Mongo | customers_${parentId} |
Customer profiles | comm_array (channel opt-ins), whatsapp_optin |
verify |
| Mongo | gupshup_media / template stores |
Media handle IDs | handle id, url | none |
| Mongo | pre_approved_templates |
Food/salon pre-approved templates | template code, body | none |
| Mongo | addo_campaigns |
Campaign stats | links_clicked, campaignType, retry_enabled, retry_hours, retry_processed, failed |
verify |
| Mongo | wallet_transactions |
Send debits / retry refunds | transaction_type, amount, parent_business_id |
none |
| Mongo | link_hits_queue |
Raw click events | campaign_id, processed, isBot |
verify |
| MySQL | addo_business |
whatsapp_login flag, business name/phone |
whatsapp_login, name |
PK id |
| MySQL | addo_menu_sections, addo_menu_items |
Catalogue source | type, status, viewType, sp |
verify |
Relationships¶
business_configs (by parent_business_id+child_business_id) ⇄ whatsapp_queue_* (by business_id) ⇄ customers_${parentId} (by mobile). Campaign rows in addo_campaigns link to queue rows via campaign_id.
Common queries ✅¶
// Marketing cron poll
db.whatsapp_queue_20260703.find({ scheduled_at: { $lte: now }, process_status: 0, type: { $exists: false } }).limit(10000)
// Transactional cron poll
db.whatsapp_queue_20260703.find({ scheduled_at: { $lte: now }, process_status: 0, type: { $exists: true } })
// Opt-out scan (prior day)
db.whatsapp_queue_20260702.find({ process_status: { $in: [3,4] }, "messageError.code": { $in: [131026, 470] } })
7. APIs¶
Auth = standard authMiddleware unless noted. All POST. Representative routes marked (verify) where the exact path/middleware should be re-confirmed against routes/crm.js.
Gupshup onboarding¶
| Route | Method | Auth | Purpose |
|---|---|---|---|
/save/onboarding/details |
POST | ✅ yes | Save Gupshup + ordering onboarding |
/get/onboarding/details |
POST | ✅ yes | Read onboarding status |
/update/onboarding/status |
POST | ✅ yes | Approve/reject (is_approved 0/1) |
/get/onboarded/business/list |
POST | ✅ yes | Paginated list (20/page) |
/create/template |
POST | ⚠️ no auth | Create Gupshup template (verify) |
/get/handleId |
POST | ⚠️ no auth (multipart) | Upload media → handle id |
/update/business/optins |
POST | ⚠️ no auth | Update opt-in |
/send/whatsapp/test |
POST | ✅ yes | Send test message |
Meta TSP¶
| Route | Method | Auth | Purpose |
|---|---|---|---|
/get/business/access/token |
POST | ✅ yes | OAuth code → access token |
/create/meta/template |
POST | ✅ yes | Create Meta template |
/get/meta/handleId |
POST | ⚠️ no auth (multipart) | Upload media to Meta |
/get/meta/template |
POST | ✅ yes | Fetch approved templates |
/whatsapp/webhook/callback |
ALL | ⚠️ no auth | Inbound messages + status webhook |
Catalogue & login¶
| Route | Method | Auth | Purpose |
|---|---|---|---|
/get/sections |
POST | ⚠️ no auth | Menu sections (verify) |
/create/catalog |
POST | ✅ yes | Create FB product catalog |
/check/whatsapp/login |
POST | JWT | Return wa.me login link |
/uen_rider/whatsapp/login |
POST | no auth | uEngage-sender rider login (parent 64671) |
Representative example — send test WhatsApp (verify)¶
// POST /send/whatsapp/test Headers: { token: <auth> }
{ "business_id": "12345", "parent_business_id": "999",
"mobile_number": "9876543210", "template_id": "invoice_bill",
"params": ["Ravi", "ORD-8842"] }
// 200 → { "status": 1, "messageId": "gs_abc123" }
[6-9]\d{9}; media via validateGupshupMediaImage. Failures: 400 invalid mobile; 402 insufficient wallet; provider non-submitted → row not advanced to 3.
8. Code Walkthrough¶
Send path (call hierarchy) ✅
sendWhatsappMessageCron (3s) ─┐
sendWhatsappTransactionalCron (1s) ─┼─▶ whatsappMsgLibrary.processWhatsappMsgData(result, cron_id)
sendWhatsappRemainingCron (midnight) ┘ │
├─ per msg: set process_status=2
├─ if whatsapp_configuration_type==="facebook" → sendFacebookMsg()
├─ else "gupshup" → opt-in check → sendGupshupMsg()
├─ processCampaignWhatsappHelper.checkIfTemplateNeedsToBeUpdated()
│ • ue_cust_name / ue_cust_mobile / ue_cust_eid
│ • ue_animated_campaign → generatePromo() → https://uen.io/{code}
│ • ue_jwt_token (button slots) secret uengage@govmwypq43
└─ on success: process_status=3 + messageId
processWhatsappMsgData staggers sends setTimeout(fn, j*66) — crude client-side rate limiting ✅ (no server-side per-business cap ⚠️, see map-04 §5.10).
- Provider selection is purely whatsapp_configuration_type on the queue row ✅.
- Retry ✅ — whatsappRetryCampaignCron: campaigns campaignType:"4", retry_enabled:1, status:1, failed>0, wait scheduled_at + retry_hours <= now; refunds non-retryable (messageError.code ≠ 131049) and re-queues retryable as free campaign.
Dependencies: axios (provider HTTP), jsonwebtoken (login + button JWTs), multer (metaFileUploading.js), MySQL shortenedurls/addo_promo_code_engine (promo codes).
9. Business Rules¶
| Rule | Value ✅ | Source |
|---|---|---|
| Marketing poll interval | every 3s (*/3 * * * *) |
sendWhatsappMessageCron |
| Transactional poll interval | every 1s (*/1 * * * *) |
sendWhatsappTransactionalCron |
| Marketing vs transactional split | type field absent = marketing; present = transactional |
queue schema |
| Batch limit per run | 10,000 rows | send crons |
| Send stagger | 66 ms/message | whatsappMsgProcess |
process_status machine |
0 scheduled → 2 picked → 3 sent(pending webhook) → 4 done/skip | ✅ |
| Opt-out error codes | 131026 (opt-out), 470 (blocked) | whatsappOptOutCron |
| Retry non-retryable code | 131049 treated as retryable-exclusion |
whatsappRetryCampaignCron |
| Media limits | image/png|image/jpeg, ≤ 2 MB |
validateGupshupMediaImage |
| WhatsApp cost (marketing) | ₹0.91 + 18% GST | 📄/✅ wallet_config |
| WhatsApp cost (utility) | ₹0.17 + 18% GST | ✅ wallet_config |
| WABA limit sync | daily 1 AM | whatsappMsgLimit |
| Cron crash detection | stuck > 20 min → status "crashed" | send crons |
Hidden logic ⚠️: parent 7175 has bespoke welcome-message suppression; cold campaigns bypass opt-in entirely. Several onboarding/webhook routes are unauthenticated — treat as security-review items (Security).
10. Performance¶
- Fast-path polling (1s/3s) with no message broker → constant DB churn even when idle ⚠️ (map-04 §5.3).
- No indexes on
whatsapp_queue_*process_status/scheduled_at⚠️ — each poll is a partial scan; date-partitioning caps collection size. - 66 ms stagger ≈ ~15 msg/s per batch; no per-business rate cap risks provider throttle at scale ⚠️.
11. Logging¶
- Winston rotating logs (monolith standard). Cron runs tracked in
whatsapp_cron_status. - Meta inbound webhook logs →
whatsapp_webhook_logs✅. - Provider responses (messageId / error) persisted on the queue row (
messageError).
12. Monitoring¶
- Watch
whatsapp_cron_status.statusfor"crashed"/ long-running locks. - Reconcile
process_statuscounts vs dashboard volume to detect E1.3 drift. whatsappMsgLimitoutput (waba_limit,phone_quality,whatsapp_account_status) is the health signal for provider tier/quality.
13. Troubleshooting¶
| Issue | Likely cause | Fix | Command |
|---|---|---|---|
Messages stuck at process_status:0 |
Cron lock status:false (crashed run) |
Reset lock | db.whatsapp_cron_status.updateOne({},{ $set:{status:true}}) |
| Dashboard volume ≠ queue counts (E1.3) | status:3 counted as delivered; webhook not reconciled |
Count by explicit status | db.whatsapp_queue_20260703.aggregate([{$group:{_id:"$process_status",n:{$sum:1}}}]) |
| Customer stopped receiving WA | Opt-out (131026/470) removed "whatsapp" |
Confirm + re-opt-in per consent | db.customers_999.findOne({mobileNo:"9876543210"},{comm_array:1}) |
| Template send fails | Media invalid / template not approved | Run media validator; re-approve template | check validateGupshupMediaImage return |
| Provider throttling | No rate cap, 1s/3s bursts | Throttle batch / raise stagger | inspect provider 429s in logs |
14. FAQs¶
- Gupshup vs Meta TSP — which sends? Whatever
whatsapp_configuration_typesays on the queue row ✅. - Why two send crons? Latency isolation — transactional (1s) must beat marketing (3s).
- Is opt-in enforced? For non-cold Gupshup sends yes; cold campaigns skip it ⚠️.
- Where do receipts land? Monolith webhook updates the queue row; the prism-services
consumer-receiptsLambda is log-only ⚠️.
15. Cheat Sheet¶
Marketing queue poll : type absent · every 3s · limit 10000
Txn queue poll : type present · every 1s
status: 0 sched → 2 picked → 3 sent(webhook pending) → 4 done
opt-out codes : 131026, 470 (whatsappOptOutCron, midnight)
media : png/jpeg ≤ 2MB (validateGupshupMediaImage)
retry : campaignType "4", retry_enabled 1, hourly, refunds non-131049
WABA limit sync : 1 AM (whatsappMsgLimit)
provider select : whatsapp_configuration_type = gupshup | facebook
cost : marketing ₹0.91 · utility ₹0.17 (+18% GST)
KNOWN BUG : E1.3 WhatsApp volume reports (P0)
Related Modules¶
- Campaigns — produces marketing WhatsApp queue rows.
- Automated-Journeys — event-triggered WhatsApp (welcome/birthday/abandon).
- Digital-Bills & Feedback-NPS — transactional WhatsApp producers.
- Queues · Third-Party · Database · Roadmap E1.3
Knowledge Tests¶
Level 1 — MCQs¶
- A queue row has no
typefield. Which cron sends it? a) transactional (1s) b) marketing (3s) c) remaining d) retry — b.typeabsent = marketing. process_status=3means? a) delivered b) skipped c) sent, webhook pending d) queued — c. It is not confirmed delivery (relates to E1.3).- Error code 131026 triggers? a) retry b) refund c) opt-out removal d) template delete — c.
whatsappOptOutCronstrips"whatsapp"fromcomm_array. - Provider selection is decided by? a) parent_business_id b)
whatsapp_configuration_typec) template type d) cron — b. - Max Gupshup media image size? a) 5 MB b) 2 MB c) 1 MB d) unlimited — b (
validateGupshupMediaImage).
Level 2 — Scenarios¶
- A brand reports "customers say they never got the offer, but the dashboard shows 12,000 delivered." Explain how E1.3 could produce this and which field you'd audit. (Expect:
process_status=3counted as delivered; audit real status distribution + webhook reconciliation.) - Marketing sends are being throttled by Gupshup during a festival blast. Given the 3s/10,000-row cron and 66 ms stagger, what changes would you make and what is the risk of simply lowering the interval? (Expect: per-business rate cap/backoff; lowering interval worsens thundering herd + ban risk.)
Level 3 — Code reading¶
Read whatsappMsgProcess.js. For a cold campaign to a customer who has never opted in, trace whether the message sends and whether comm_array is modified. (Answer: cold sets sendMsgCheck=true, opt-in check skipped, message sends; comm_array not modified for cold.)
Level 4 — Architecture¶
The consumer-receipts Lambda is stubbed and the monolith handles receipts. Argue whether E1.3 should be fixed in the monolith webhook or by completing the Lambda consumer, considering the dual-writer risk in High-Level-Architecture.
Level 5 — Prod debugging¶
At 14:00 marketing WhatsApp stops flowing; whatsapp_queue_20260703 has 40k rows at process_status:0. Give your first three commands and the most likely single-line fix. (Expect: check whatsapp_cron_status for a stuck status:false/"crashed" lock; check PM2 cron process; reset the lock.)
Practical Assignments¶
- Write a read-only script that reconciles, for a given day,
process_statuscounts against the dashboard WhatsApp volume for one parent — surfacing the E1.3 gap. - Add a per-business send-rate guard (config-driven) inside the send path and document its interaction with the 66 ms stagger.
- Draft the design (no code) for completing
consumer-receiptsto persist delivery/read intowhatsapp_queue_*, including dedup vs the monolith webhook.