WhatsApp Bulk Messaging Architecture
TL;DR
Our WhatsApp bulk messaging path is built around one rule: never let a campaign depend on a manual export or a single long-running web request. The operator connects WhatsApp through WhatsApp Embedded Signup, verifies that the workspace has the right WhatsApp Business Account and phone permissions, creates approved templates and optional flows, then builds a recipient audience from message analytics. The send itself is queued into Temporal and processed by a worker in controlled batches.
Product-wise, this gives the team a reliability for high-volume campaign programs because the workflow is gated at every point where WhatsApp can fail: access verification, phone and WABA discovery, template approval, opt-in checks, audience preview, payload-size limits, rate-aware dispatch, idempotency, delivery webhooks, and outbox-backed persistence.

The current single-dispatch guardrails are intentional:
Manual recipient payloads are capped at (N/day) recipients, taken from clients business limits;
Analytics-derived WhatsApp audiences are capped at 50,000 recipients per dispatch.
Million-scale, or "1,000k", programs should be partitioned into multiple queued analytics campaigns. The runtime can use Meta high-throughput phone numbers at up to 1,000 messages per second when Meta reports the phone number throughput level as
HIGHor 1000, but the application still keeps bounded workflow payloads and explicit audience caps.
That split is what makes the system operationally safe: we scale by repeatable, observable workflow runs, not by one unbounded request.
End-To-End Path
-> [UI] User picks an option for Embedded Signup with coexistence or full Cloud API
-> [manual] We help with verification and access preflight
-> [backend] OAuth callback, WABA and phone discovery, webhook subscription
-> [UI] User creates Templates and Flows
-> [UI] User filters audience for new bulk campaign
-> [UI] User creates campaigns, template variables, opt-in checks
-> [backend] Clicks send -> send bulks API
-> [backend] temporal workflows for bulks
-> [backend] Worker batch activities
-> [backend] Meta WhatsApp Cloud API
-> [UI, backend] User sees campaign history, conversations, delivery webhooks1. WhatsApp Onboarding Through Embedded Signup And Coexistence

Start with two connection modes:
coexistence: the preferred path for WhatsApp Business App users who need to keep the business app while connecting the platform.cloud_api: a direct Cloud API path for accounts that should not use business app migration.
The coexistence option uses Meta's URL-based Embedded Signup dialog and includes setup extras for business app onboarding:
featureType: "whatsapp_business_app_onboarding"business prefill data from the business profile
allow_business_app_migration = truea standardized redirect URI;
The redirect URI is deliberately normalized to /api/sources/whatsapp/callback?embedded=true. Meta requires the redirect URI used during code exchange to match the one used during signup, so centralizing that value prevents a common class of OAuth failures.
The callback handler does the heavy lifting:
exchanges the short-lived Embedded Signup code for a business access token
validates the token with Graph
debug_tokenreads granular permissions
discovers business portfolios, WhatsApp Business Accounts, and phone numbers
handles multiple WABA or phone choices by redirecting to a selection flow
subscribes WABA and phone webhooks
registers a Cloud API phone number when required
detects coexistence through
coexistence_mode,partner_app_installed, and SMB platform metadataencrypts the business access token before storing it in Supabase
sourcesruns WhatsApp auto-defaults
requests WhatsApp history sync for analytics continuity
Legacy non-Embedded OAuth is intentionally removed in apps/web/src/app/api/sources/whatsapp/connect/route.ts and apps/web/src/app/api/sources/whatsapp/callback/route.ts. That is a reliability choice: all new connections go through the flow that gives us the WABA, phone, token, and webhook context we need for campaign sending.
2. Verification Help And Readiness Checks
Before the user starts signup, the UI calls apps/web/src/app/api/whatsapp/verify-access/route.ts. This endpoint validates server-side Meta app credentials and checks whether the Meta app can access the required Graph surfaces. It returns guidance that the integration UI can show as verification help.
The verification layer is intentionally separate from the OAuth callback. It catches setup problems earlier, such as:
missing
NEXT_PUBLIC_META_APP_IDmissing
META_APP_SECRETincomplete Meta app access verification
unavailable app metadata or permissions
business verification requirements
The integration UI also watches token expiry and prompts reconnect when the stored token is near expiry. That keeps campaign sending from failing at the worst possible time: after a marketer has already selected an audience and queued a send.
3. Template And Flow Creation
Bulk WhatsApp sending is template-first. We do not send arbitrary free-form marketing messages to large audiences because WhatsApp requires approved templates for business-initiated conversations.
Template management lives under messengers/whatsapp/template & flow creation:
apps/web/src/app/panel/w/[workspaceId]/messengers/whatsapp-templates/page.tsxapps/web/src/components/whatsapp-templates/templates-manager.tsxapps/web/src/app/api/whatsapp/templates/route.tsapps/web/src/lib/whatsapp/template-service.ts
The template API enforces workspace membership and manager roles for mutations. It reads and writes templates through Meta's Business Management API, paginates template listing, and stores normalized template data for the UI. The campaign send API later re-checks Meta-approved templates by exact name and language before queueing a campaign. If the operator picks an unapproved template or a language that is not approved for that template, the request fails before Temporal is started.
WhatsApp Flows are managed through:
apps/web/src/app/panel/w/[workspaceId]/messengers/whatsapp-flows/page.tsxapps/web/src/components/whatsapp-flows/flows-manager.tsxapps/web/src/app/api/whatsapp/flows/route.tsapps/web/src/lib/whatsapp/flows-client.ts
The Flows API supports listing, creating, updating, publishing, deprecating, deleting, uploading Flow JSON assets, previewing, and sending test interactive Flow messages. Flow list calls are cached briefly on the client to keep the UI responsive while still reflecting Meta state.
This template and flow step matters for bulk reliability because it turns campaign content into a prevalidated Meta asset. The send runtime only has to resolve variables and deliver an approved template payload; it does not have to discover whether the message is policy-compatible during the bulk send.
4. Analytics / Bulk Audience Selection
The bulk campaign audience is selected in apps/web/src/app/panel/w/[workspaceId]/analytics/bulk/page.tsx, which renders apps/web/src/components/analytics/bulk-analytics.tsx.
The UI lets the operator filter WhatsApp conversations and sends by:
date range or preset period
template names
campaign IDs
replied or not replied status
engagement level
conversation scope, including campaign sends, fresh conversations, all conversations, or campaign-or-fresh
reply attribution window
minimum run size
contact search query
message text contains or does not contain
excluded templates
The analytics endpoint, apps/web/src/app/api/workspaces/[workspaceId]/analytics/bulk/route.ts, builds the reporting view from MongoDB WhatsApp messages and conversations. It uses aggregation pipelines with allowDiskUse(true), and it treats accepted sends as outbound WhatsApp template messages with Meta wamid IDs. Replies are attributed to outbound sends when inbound messages arrive before the next outbound or inside the selected reply window.
For text filters, the audience resolver uses MongoDB Atlas Search through apps/web/src/lib/analytics/bulk-analytics-audience.ts instead of doing expensive regex scans across joined message history. The relevant guardrails are:
messages_text_searchAtlas Search index200,000 max scanned text-search messages
50,000 max conversation IDs from text search
20,000 max campaign message IDs from Supabase campaign history
50,000 max analytics audience recipients per dispatch
There is also an audience preview endpoint: apps/web/src/app/api/workspaces/[workspaceId]/analytics/bulk/audience-preview/route.ts. The campaign drawer uses it before sending to show matched recipients, eligible recipients, opt-in exclusions, cap status, and the max recipient limit.
This makes picking and filtering reliable because the operator sees the resolved audience before queueing the campaign, and the server repeats the same audience resolution when the campaign is actually submitted.
5. Campaign Submission
The drawer path is:
apps/web/src/components/campaigns/campaign-blast-drawer.tsxapps/web/src/components/campaigns/multi-channel-campaign-builder.tsxapps/web/src/app/api/campaigns/bulk-send/route.ts
For analytics-derived audiences, the builder sends an analyticsAudience object instead of uploading a recipient CSV. That object contains the selected bulk analytics filters and template variable source configuration. The API resolves the final audience server-side, so the client cannot silently alter the recipient list after preview.
The bulk send API validates:
authenticated Supabase user
workspace membership
manager role (
member,admin, orowner)WhatsApp send context for the workspace
approved template name and language
required template variables
required header media
analytics audience support only for WhatsApp
marketing opt-in status for
MARKETINGtemplatesTemporal availability
idempotency key reuse
For non-marketing WhatsApp templates, the API validates that numbers exist in MongoDB conversation history. For marketing templates, the API allows broader outreach but filters to conversations whose WhatsApp opt-in status is true. If no recipient is eligible after opt-in filtering, the campaign is rejected instead of queueing an empty workflow.
Before starting Temporal, the API creates a Supabase campaign_history record. That gives the UI a campaign ID immediately and gives webhooks a stable place to write delivery status.
6. Dispatch Runtime
The dispatch runtime is built on Temporal:
starter:
apps/web/src/lib/temporal/bulk-campaign-dispatch.tscontract:
packages/temporal/src/campaign/contracts.tsworkflow:
apps/temporal-worker/src/workflows/bulk-campaign-dispatch.tsactivities:
apps/temporal-worker/src/activities/bulk-campaign.tsruntime implementation:
packages/airi-runtime/src/bulk-campaign-dispatch.ts
The API starts bulkCampaignDispatchWorkflow with a deterministic workflow ID for the workspace and campaign. The workflow slices the campaign into batches based on the resolved batch plan, calls the send-batch activity, sleeps between batches, accumulates success and failure counts, and finalizes the campaign stats at the end.
Activity retry policy is explicit:
send-batch activity timeout: 300 seconds
send-batch max attempts: 3
initial retry interval: 2 seconds
backoff coefficient: 2
max retry interval: 60 seconds
finalization max attempts: 3
Inside each batch, the runtime uses DISPATCH_CONCURRENCY = 3. This keeps throughput moving while reducing Meta 429 bursts and write races in the data stores. Each activity also creates a dedupe key from Temporal workflow, run, and activity identity. If Temporal retries an activity after a partial success, the runtime can return the recorded message ID instead of sending the same recipient again.
The actual WhatsApp send goes through:
apps/web/src/lib/campaign/campaign-transport.tsapps/web/src/lib/whatsapp.ts
For WhatsApp, the transport sends a template payload to Meta's /{phone-number-id}/messages endpoint. The payload includes biz_opaque_callback_data when campaign context is available, so delivery webhooks can map Meta status events back to a campaign message without an expensive lookup.
7. Batch Planning And Throughput
Batch planning is centralized in apps/web/src/lib/campaign/campaign-batch-plan.ts.
The default WhatsApp plan is:
batch size: 20
delay between batches: 250 ms
expected rate: 80 messages per second
source:
meta_default_throughputthroughput level:
STANDARD
When the connected Meta phone number reports throughput.level as HIGH or 1000, the plan changes to:
batch size: 100
delay between batches: 100 ms
expected rate: 1,000 messages per second
source:
meta_phone_throughput
This is why the path can support high-volume programs without hard-coding a single global rate. The application asks Meta for phone-number metadata and uses the throughput level that Meta exposes. If that lookup fails, the app falls back to the conservative standard plan.
8. Payload Staging For Large Dispatches
Temporal workflow input is sent over gRPC, so /api/campaigns/bulk-send keeps inline workflow payloads under 900 KB. If the campaign payload is larger, the API stages dispatch items in Upstash Redis using packages/shared/src/lib/bulk-dispatch-payload-store.ts and starts Temporal with a small payload key instead of the full item array.
The staging store uses:
key prefix
bulk_campaign_dispatch:v1workspace-scoped keys
7-day TTL
12 MB max stored JSON payload
The worker later loads only the slice needed for each activity. This avoids Temporal payload-size failures while keeping the workflow deterministic and bounded.
9. Persistence, Delivery Status, And Analytics Feedback Loop
Successful sends are persisted in two places:
Supabase
campaign_historyandcampaign_messagesMongoDB conversation and message collections
Supabase is used for campaign reporting and status history. MongoDB is used for conversation analytics, reply attribution, and the analytics/bulk screen. When Supabase campaign writes fail, apps/web/src/lib/cross-store-outbox.ts queues a pending operation in MongoDB so the system can retry the campaign-message write instead of losing the event.
WhatsApp delivery webhooks are handled in apps/web/src/app/api/webhooks/whatsapp/route.ts. The webhook handler verifies the Meta signature, records structured delivery events, updates denormalized message delivery fields, updates campaign message status, recomputes campaign stats, and pushes real-time UI updates through Pusher.
Delivery event storage is handled by apps/web/src/lib/whatsapp/message-delivery.ts and apps/web/src/models/message-delivery-event.ts. The same events then become available to analytics and future segmentation.
This closes the loop:
Campaign send
-> Meta accepted `wamid`
-> campaign_messages + Mongo conversation message
-> Meta status webhook
-> delivery event + campaign status update
-> analytics/bulk can segment future audiences by behavior
Tech Stack And Why It Works
Layer | Technology | Why it fits this path |
|---|---|---|
Web app | Next.js App Router, React, TypeScript | Route Handlers give typed server endpoints close to the UI flow. React keeps the integration, template, flow, analytics, and campaign-builder states cohesive. |
Auth and workspace data | Supabase Auth and Supabase tables | The app already uses Supabase for user sessions, workspace membership, sources, campaign history, and campaign message records. |
Conversation analytics | MongoDB, Mongoose, Atlas Search | Bulk audience resolution needs flexible message/conversation documents, aggregation pipelines, and indexed text search over message bodies. |
WhatsApp platform | Meta Graph API and WhatsApp Cloud API | Embedded Signup, WABA discovery, templates, Flows, phone metadata, message sends, and delivery webhooks all come from the official Meta platform. |
Durable dispatch | Temporal TypeScript SDK | Campaign sends are long-running, retryable, observable workflows. Temporal gives deterministic orchestration, activity retries, sleeps between batches, and workflow history. |
Large payload staging | Upstash Redis | Redis stores large dispatch item arrays outside Temporal input limits while keeping worker access simple through REST credentials. |
Runtime worker |
| The worker owns background send execution, batch activity context, dedupe, persistence, and finalization. |
Realtime UI | Pusher Channels | Delivery webhooks can push campaign-message status updates to the UI without polling. |
Validation | Zod | API payloads for campaigns, analytics audiences, and Flows are validated before side effects. |
Monorepo | pnpm workspaces and Turbo | Shared campaign, Temporal, WhatsApp, and analytics contracts can live in packages while apps stay independently buildable. |
This stack works because each component owns the failure mode it is best at handling. Next.js handles user-facing orchestration and validation. MongoDB handles analytics and conversation search. Supabase handles workspace and campaign records. Temporal handles long-running execution. Redis handles payload size pressure. Meta remains the source of truth for WhatsApp assets and delivery status.
Reliability Properties
The path is reliable because it has explicit controls before, during, and after dispatch.
Before dispatch:
Meta app access verification before signup
exact redirect URI handling for Embedded Signup
WABA and phone discovery in the callback
encrypted token storage
webhook subscription during onboarding
reconnect prompt near token expiry
template approval and language validation
Flow readiness checks
audience preview with server-side re-resolution
marketing opt-in filtering
idempotency key support for double-submit protection
During dispatch:
campaign history record created before queueing
Temporal workflow for durable background execution
batch plan based on Meta phone throughput
bounded concurrency per batch
retry policy on send-batch activities
dedupe keys for retried activities
Redis staging for large workflow payloads
explicit errors for unconfigured Temporal or Redis
After dispatch:
accepted sends persisted to Supabase and MongoDB
outbox fallback for campaign-message persistence failures
WhatsApp status webhooks with signature verification
biz_opaque_callback_datafast path for campaign status mappingreal-time Pusher updates
campaign stat recomputation
delivery events fed back into analytics segmentation
Scale Model For 1,000k Campaign Programs
The system should not be described as a single unbounded "send one million rows" endpoint. The reliable model is segmented execution:
Use
analytics/bulkto define a precise audience slice.Preview the resolved count and opt-in eligibility.
Queue one campaign workflow for that slice.
Repeat for additional slices, such as by date window, template history, engagement band, region, import cohort, or campaign ID.
Let Temporal, Redis staging, delivery webhooks, and campaign history provide per-slice observability.
With standard Meta throughput, a 50,000-recipient slice is rate-planned at about 80 messages per second before provider-side variability. With high-throughput Meta phone numbers, the plan can use 1,000 messages per second. The application still keeps the 50,000 analytics-recipient cap because the business risk is not only transport speed; it is also previewability, opt-in auditability, campaign reporting, retry blast radius, and payload size.
That is the product reason behind the architecture. Operators get a path that can be repeated to cover very large audiences, while engineering keeps every workflow bounded enough to debug and retry safely.
Important Code Map
Area | Files |
|---|---|
Integration UI |
|
Coexistence Embedded Signup |
|
Standard Embedded Signup button |
|
Redirect URI normalization |
|
Embedded Signup callback |
|
Access verification |
|
Template API |
|
Template service |
|
Flows API |
|
Analytics bulk page |
|
Bulk analytics API |
|
Audience resolver |
|
Audience preview API |
|
Campaign builder |
|
Bulk send API |
|
Batch plan |
|
Temporal starter |
|
Temporal contract |
|
Temporal workflow |
|
Temporal activities |
|
Runtime dispatcher |
|
Redis payload staging |
|
WhatsApp send transport |
|
Delivery webhooks |
|
Delivery event store |
|
Helpful Official Docs
Meta WhatsApp Platform:
Runtime and data platform docs: