DeveloperGrowthMessaging
Messaging (Campaign Sends)
Overview
Campaign message delivery for the growth domain (#2174 send wiring): a started
campaign fans out per-recipient Growth::Communication rows and a background
job renders + sends each one through the real channel engines, deducting one
message credit per successful billable send.
- Channels: SMS (MSG91 India with DLT ids / Twilio US-UAE), WhatsApp
(Wati, approved registered templates only), Email (SES via
Growth::CampaignMailer), Push (AWS SNS to patient-app devices — free). - Personalization:
{{first_name}} / {{last_name}} / {{clinic_name}}are substituted per recipient at send time (Growth::Campaigns::TemplateRenderingService); WhatsApp parameters are extracted in body order of appearance. - Billing: deduct-on-success only (
Growth::MsgCredits::DeductService— idempotent per communication). Failed sends never bill. Push never bills.
Technical Architecture
Start (manual) or CampaignExecutionJob (auto, at scheduled_at)
└─ Growth::Campaigns::ExecutionService
├─ credit gate ── RecipientResolver counts (per message, per channel)
└─ fan-out ── RecipientResolver.recipients_for(message)
→ Communication.create! → CommunicationSendJob
└─ Communications::SendService
├─ TemplateRenderingService ({{vars}})
├─ SmsService / WhatsappService /
│ CampaignMailer / PushService
└─ success → mark_sent! → deduct 1
failure → mark_failed! (no charge)Growth::Campaigns::RecipientResolveris the ONE audience-resolution path: credit gate, fan-out, and theGET /growth/campaigns/:id/audience_previewendpoint all use it, so the three can never disagree. Audience exclusion (exclude_previously_contacted+exclusion_window_days, 30/60/90/any) subtracts patients with a prior successful send of the same template within the window — failed attempts re-include. Template-less push is exempt.- Push targeting (
Growth::Campaigns::PushTargeting): patient → user → patient-app devices; unreachable patients are skipped silently (no Communication row, never billed). Push rows store a NULLrecipient_address. - Webhook-driven wallet top-up: credit purchases go through Cashfree
(
POST /growth/msg_credits/orders/:id/pay→openCheckout()SDK) and only the signature-verified billing webhook flips an order paid and tops the wallet (Growth::MsgCredits::PurchaseService.complete_from_webhook!, idempotent against replays). The frontend polls the order and invalidates wallet queries — it never asserts payment itself.
Environment behavior (important for testing)
| Env | SMS | Push | ||
|---|---|---|---|---|
| development | succeeds via dev outbox (/dev/notifications), never sends | returns failure ("disabled in dev") | letter_opener | local only |
| test | stub the engines at the Shared::Notifications boundary (see send_service_test.rb) | same | ActionMailer :test deliveries | stub |
| staging/prod | real (staging Slack-routing guards) | real (test-number gate) | SES | SNS |
Cashfree checkout tokens are environment-scoped (#2122): the pay response
carries env and the SDK adapter obeys it.
Common Tasks
- Send flow debugging:
Growth::Communicationrows carrystatus,failure_reason,render_error,provider_message_id; the messaging log UI surfaces failed rows with Retry. - Duplicate a finished campaign to message new segment members:
POST /growth/campaigns/:id/duplicate(copy-to-draft with lineage) → audience preview shows the delta → Start. - Ops: a stuck
pendingcredit order = missed webhook → replay it from the Cashfree dashboard (safe; completion is idempotent).
Status: Live (#2174) — send wiring, deduction, Cashfree purchase loop, audience exclusion, auto-send, and campaign duplicate. Last Updated: 2026-09-02