Kulcare Docs
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::RecipientResolver is the ONE audience-resolution path: credit gate, fan-out, and the GET /growth/campaigns/:id/audience_preview endpoint 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 NULL recipient_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)

EnvSMSWhatsAppEmailPush
developmentsucceeds via dev outbox (/dev/notifications), never sendsreturns failure ("disabled in dev")letter_openerlocal only
teststub the engines at the Shared::Notifications boundary (see send_service_test.rb)sameActionMailer :test deliveriesstub
staging/prodreal (staging Slack-routing guards)real (test-number gate)SESSNS

Cashfree checkout tokens are environment-scoped (#2122): the pay response carries env and the SDK adapter obeys it.

Common Tasks

  • Send flow debugging: Growth::Communication rows carry status, 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 pending credit 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

On this page