WhatsApp Messaging & Notifications — Implementation Plan
Status: Approved plan — build in progress (this branch)
Date: 2026-07-10
Supersedes: FIT-140’s Twilio ISV plan (Twilio dropped; Meta-direct committed)
Builds on: leads-crm/whatsapp-lead-ingestion.md (inbound CTWA leads + Embedded Signup connect, shipped in PR #138)
Research basis: Meta primary docs (July 2026 state) + UpGrade360 competitor teardown — key findings inlined below with flags where docs are ambiguous.
1. Scope
v1 (this build):
- Outbound notification infrastructure — template + free-form sends from the org’s own number, with delivery tracking. First transactional consumers: booking confirmation + class reminder.
- Automations
whatsappchannel — replace the stub behindChannelStrategy; free-form inside the 24h service window, approved-template outside it. - Broadcasts/campaigns — bulk template sends to member/lead audiences with per-recipient delivery stats (UpGrade360’s headline feature; also replaces the app-side broadcast lists that coexistence disables).
- Coexistence-first connect flow — gyms keep their WhatsApp Business App on their existing number; classic (dedicated-number) Embedded Signup also supported.
- Template management — per-org template registry synced with Meta, create/submit UI, status webhooks.
Explicitly deferred (post-v1): unified two-way inbox UI (the data layer IS built in v1 — see §5.2), chatbots/flows, Spotter AI agent on WhatsApp, coexistence chat-history import, payment-failure dunning + more notification categories, Conversions API.
Locked decisions (Saar, 2026-07-10): Meta-direct as Tech Provider (no Twilio, no BSP) · orgs pay Meta directly (each WABA carries the gym’s own payment method — zero billing infra for Taikan) · coexistence is the default path, classic supported · plan + full implementation this session, testable against Meta’s free test number until App Review clears.
2. Platform architecture (what the research locked)
2.1 Tech Provider model
- Taikan’s one Meta app serves every org (never per-customer apps). Orgs onboard via Embedded Signup v4 (v2/v3 hard-deprecate 2026-10-15 — we build v4 only).
- Tech Providers need no line of credit: each customer adds a credit card to their own WABA in WhatsApp Manager and Meta bills them per delivered template message. Taikan bills nothing WhatsApp-related.
- Advanced Access via App Review for exactly two permissions:
whatsapp_business_management+whatsapp_business_messaging(older guides also listbusiness_management— no longer required). Review = 2 screencasts (send-from-app, template-created-from-app); cURL/test-number recordings accepted pre-launch. - Onboarding cap: 10 orgs/rolling-7-days before Business Verification + App Review; 200/week after.
- Token from ES code exchange = Business Integration System User token, never-expires by default. Stored per connection (existing
integration_connections.encrypted_credentials, AES-256-GCM). Invalidated only if the org revokes the app in Business Manager — handled viaaccount_updatewebhook. - ES exchange code TTL ≈ 30s → exchange synchronously in
finalize(already the shape of the existing code).
2.2 Coexistence (the default path)
The gym keeps the WhatsApp Business App on their number; Taikan gets API access to the same number.
- Availability: GA worldwide since 2026-04-15; Israel supported (never blocklisted). Requires WhatsApp Business App ≥ 2.24.17. Eligibility is Meta-side (account tenure/quality) — surface error
3441045(“more activity needed”) gracefully. - ES launch: same popup,
extras.featureType: 'whatsapp_business_app_onboarding',sessionInfoVersion: '3'. Success postMessage event =FINISH_WHATSAPP_BUSINESS_APP_ONBOARDINGand carries onlywaba_id→ resolve the number viaGET /{waba_id}/phone_numbers. Classic path keepsFINISH/FINISH_ONLY_WABAwith both IDs. - Provisioning delta vs classic: exchange code +
POST /{waba_id}/subscribed_appsas normal, but skipPOST /{phone_number_id}/register(already registered via the app;/deregisteris likewise blocked). Verify withGET /{phone_number_id}?fields=is_on_biz_app,platform_type. - Post-onboard syncs (one-shot each, 24h deadline):
POST /{phone_number_id}/smb_app_datawithsync_type: smb_app_state_sync(contacts) andsync_type: history(6-month chat history). v1 decision: initiate contacts sync; skip history sync — we have no inbox surface to hold 6 months of chats and holding them is a privacy liability; an org that later wants history can offboard + reconnect when the inbox ships. Flagged as revisit-at-inbox. - Ongoing behavior: app-sent messages arrive as
smb_message_echoeswebhooks (we record them — see §5.2); app traffic never opens/extends the API’s 24h window and stays free; API traffic is billed normally. - Constraints to encode: fixed 20 msg/sec throughput (no auto-upgrade) → send-queue rate limit; app must be opened every ~13 days or Meta disconnects (
PARTNER_REMOVED, reasonPRIMARY_INACTIVITY); disconnect is SMB-initiated from the app (Settings → Account → Business Platform); gym’s app loses broadcast lists (read-only), disappearing/view-once/live-location in 1:1s; groups keep working in-app but are invisible to the API; display name + profile photo are locked post-onboarding (tell gyms to set them first); COEX numbers can’t take OBA/blue-badge or standard Business Verification. - Reconnect: since 2026-05, app re-registration (new device/reinstall) shows a pre-checked opt-in that auto-reconnects Cloud API products within minutes;
account_reconnectedwebhook confirms.
2.3 Multi-WABA routing & scale
- One app-level webhook endpoint (existing
POST /webhooks/whatsapp) receives everything; route byvalue.metadata.phone_number_id→integration_connections(already implemented).entry[].id= WABA id. - Template webhooks (
message_template_status_update) and account-level webhooks always hit the app-level URL — route those byentry[].id(WABA) instead; storewabaIddenormalized on the connection (already inconfig). - App-level webhook field subscriptions to add in the App Dashboard:
messages(have),message_template_status_update,account_update,phone_number_quality_update,smb_message_echoes,smb_app_state_sync,history. - Rate limits to respect: 80 msg/s per classic number / 20 msg/s coexistence; pair limit ≈ 1 msg per 6s per recipient (burst 45); management API 200 calls/hr/WABA (5k/hr for active WABAs). Business-initiated unique-user limits: 250/24h (new portfolio) → 2k → 10k → 100k → unlimited, auto-scaling; portfolio-scoped per current docs (flag: verify at build).
2.4 Messaging + pricing rules the product encodes
- 24h customer-service window: opens on each inbound user message. Inside: free-form anything, free. Outside: approved template only (error
131047if violated). CTWA click opens a 72h free entry-point window. - Per-message pricing (since 2025-07-01): charged per delivered template by category × country. Israel ≈ $0.0353 marketing / $0.0053 utility / $0.0053 auth (BSP-mirrored rate card — re-verify before publishing pricing to gyms). Utility templates inside an open service window are free. Service (free-form) messages always free.
- Per-user marketing cap: Meta silently drops marketing templates beyond ~2/24h per user across all businesses (error
131049/130472) → treat as drop-don’t-retry-today. - Template lifecycle: create via
POST /{waba_id}/message_templates(category MARKETING/UTILITY/AUTHENTICATION, language incl.he, named or positional params); review usually minutes, statuses PENDING→APPROVED/REJECTED plus runtime PAUSED/DISABLED via quality; new-template pacing (held_for_quality_assessment); Meta may recategorize utility→marketing. 250 templates/WABA unverified. - Opt-in policy: business must have permission to message the user on WhatsApp. Taikan posture: members = relationship-based opt-in at signup, surfaced as a
whatsappchannel in notification prefs (opt-out model, consistent with existing push/email); leads = existing explicit-consent gate (LeadConsentService, CTWA grantsservice_windowconsent; STOP/הסר keywords already withdraw it — this shipped).
3. Competitive parity (UpGrade360 → Taikan)
UpGrade360 (Arbox’s partner) is primarily an unofficial WhatsApp-Web automation (QR-scan session + Chrome extension) mid-migration to a Cloud API hybrid. Their moat is CRM-context-aware messaging — which Taikan gets natively by being the CRM. Our differentiators: official-API-only (no ban risk, no phone-online requirement, green-badge eligible on classic numbers), native data (no daily report pulls), and coexistence (they require WhatsApp Web pairing; we keep the gym’s app fully working).
| UpGrade360 capability | v1 | Later |
|---|---|---|
| Class/booking reminders + confirmations | ✅ notifications (§7) | more categories |
| Broadcasts to segments (≤1/week guidance) | ✅ broadcasts (§9) | |
| Drip sequences (lead nurture, win-back, onboarding) | ✅ automations channel (§8) | more triggers |
| Keyword opt-out (הסר) + blacklist | ✅ shipped (STOP keywords) + prefs | temp-blacklist UI |
| Failed-payment dunning | template groundwork | wire to payments events |
| Scheduling + Shabbat awareness | ✅ broadcast scheduling warns on Saturday | |
| Preview/edit queue before send | broadcast preview + counts | per-recipient toggle |
| Menu chatbots / CRM-status-aware routing | — | flows/bots phase |
| AI agent | — | Spotter-on-WhatsApp |
| Unified inbox w/ client card | data layer only (§5.2) | inbox UI phase |
4. Feature flag
Per repo policy: PostHog flag whatsapp-messaging, organization group, keyed on orgId, default off, fail toward off.
- Gates every send path (notifications, automations channel
isConfigured, broadcast execution) and the connect UI card’s new capabilities. - Off/unevaluable ⇒ behave exactly like today (automations whatsapp stays “channel_not_configured”; no sends). Inbound lead ingestion stays gated by connection-existence as shipped (unchanged).
5. Data model (new migration)
5.1 New tables (libs/db/src/lib/schema/whatsapp.ts)
whatsapp_templates -- per-org registry, mirror of Meta state
id, organization_id FK, connection_id FK(integration_connections),
waba_id varchar, -- denormalized routing key for status webhooks
external_id varchar, -- Meta template id
name varchar(512), language varchar(16), -- (waba_id, name, language) unique
category enum whatsapp_template_category (marketing|utility|authentication),
status enum whatsapp_template_status (draft|pending|approved|rejected|paused|disabled),
components jsonb, -- Meta component array (header/body/footer/buttons)
parameter_format varchar(16), -- named|positional
rejection_reason text, quality jsonb,
created_by_user_id, created_at, updated_at
whatsapp_messages -- outbound send log (delivery tracking; source of stats)
id, organization_id FK, connection_id FK,
direction enum (outbound|outbound_app), -- outbound_app = smb echo, see §5.2
to_phone varchar(50), -- canonical +972…
recipient_user_id FK users NULL, recipient_lead_id FK leads NULL,
source enum whatsapp_message_source (notification|automation|broadcast|manual|smb_app),
source_ref uuid NULL, -- broadcast id / step_run id / category key in metadata
kind enum (template|text), -- what we sent
template_name varchar, template_language varchar,
payload jsonb, -- exact request body sent (audit/replay)
wamid varchar UNIQUE NULL, -- set on accept; statuses join on this
status enum whatsapp_message_status (queued|accepted|held|sent|delivered|read|failed|dropped),
error_code varchar, error_message text,
queued_at, sent_at, delivered_at, read_at, created_at, updated_at
INDEX (organization_id, created_at), INDEX (source, source_ref)
whatsapp_broadcasts
id, organization_id FK, name varchar(255),
template_id FK whatsapp_templates,
params jsonb, -- static + token-mapped template params ({{first_name}} etc.)
audience jsonb, -- {type: members|leads, filter: {...}} snapshot of the query
status enum whatsapp_broadcast_status (draft|scheduled|sending|completed|failed|cancelled),
scheduled_at timestamptz NULL,
total_count int, sent_count int, delivered_count int, read_count int, failed_count int, skipped_count int,
created_by_user_id, started_at, completed_at, created_at, updated_atBroadcast recipients are not a separate table — each recipient becomes a whatsapp_messages row with source='broadcast', source_ref=broadcast.id; counters on the broadcast are denormalized by the processor.
5.2 Reused tables (no new schema)
conversations/conversation_messages(already channel-aware,external_contact_id= wa_id,last_inbound_atdocumented as the 24h-window driver,external_message_id= wamid): the webhook starts recording all inbound WhatsApp messages (not just CTWA) viaConversationsService.recordInbound, andsmb_message_echoesas outbound rows withmetadata.source='smb_app'. This gives us the service-window clock per (org, phone) and makes the future inbox UI a pure-frontend phase. Lead-creation rule is unchanged (CTWA-referral only).integration_connections.configgains:signupType: 'coexistence' | 'classic',businessId,qualityRating,messagingLimit(updated by webhooks). No column change — jsonb.notification_prefs: add'whatsapp'toNOTIFICATION_CHANNELSin@taikan/shared— designed as a zero-migration change.- Lead consent (
lead_consent): unchanged; already models whatsapp explicit opt-in + STOP withdrawal.
6. API — send infrastructure (apps/api/src/whatsapp/)
New WhatsAppModule (global-ish like NotificationsModule; lead-ingestion keeps its module and imports the sender where needed).
WhatsAppGraphService— extends the Graph surface (kept separate from lead-ingestion’sMetaGraphServiceto avoid a circular module):sendTemplate(phoneNumberId, token, {name, language, components}),sendText(phoneNumberId, token, to, body),createTemplate(wabaId, token, def),deleteTemplate,listTemplates(wabaId, token),listPhoneNumbers(wabaId, token),initiateSmbAppSync(phoneNumberId, token, syncType),getPhoneNumberStatus(phoneNumberId, token)(is_on_biz_app,platform_type,quality_rating). All calls attachappsecret_proof.WhatsAppSenderService— the one entry point every producer uses:enqueueSend({orgId, to, recipient, source, sourceRef, content}). Writes thewhatsapp_messagesrow (queued), resolves the active connection, pushes a job to queuewhatsapp-send.WhatsAppSendProcessor(ObservableWorkerHost, queue registered inbull-board/queue-names.ts) — per-connection Redis token bucket (default 10 msg/s, coexistence-safe), decrypts token, calls Graph, updates the row →accepted(+wamid) /held/failed. Error mapping:131047→ markfailed:outside_window(producer decides template fallback — automations do this pre-send, so this is a safety net);131049/130472→dropped, no retry;130429/131056/HTTP 429/5xx → BullMQ retry with backoff;132001/132015/132016→ fail + mark template row from webhook.- Service-window helper —
isWithinServiceWindow(orgId, phone): readsconversations.last_inbound_atfor(org, channel='whatsapp', external_contact_id); true iff < 24h ago. - Webhook expansion (in
lead-ingestion’s existing controller/service, which stays the singlePOST /webhooks/whatsapp):value.statuses[]→ updatewhatsapp_messagesby wamid (sent/delivered/read/failed+errors[]), bump broadcast counters.- all inbound
messages[]→conversations.recordInbound(new), then existing STOP + CTWA-lead logic unchanged. smb_message_echoes→ conversation outbound rows (metadata.source='smb_app').account_update:PARTNER_REMOVED(+disconnection_info.reason) → mark connectionrevoked, wipe credentials, track event;PARTNER_ADDED→ track;account_reconnected→ re-activate if row exists.phone_number_quality_update→ stashqualityRating/limit in connection config.message_template_status_update→ updatewhatsapp_templates.statusby (waba_id, name, language), store reason.
7. Connect flow v2 (coexistence + classic)
Backend (whatsapp-connect.service.ts — extend):
finalizegainssignupType(from which ES event fired). Coexistence: skipregister, resolvephone_number_idviaGET /{waba_id}/phone_numberswhen the event only carriedwaba_id, callinitiateSmbAppSync(…, 'smb_app_state_sync')best-effort, storeconfig.signupType.- Store
businessIdwhen present; keep the deterministic-PIN register for classic. - New
GET /organizations/:orgId/integrations/whatsapp/health→{status, isOnBizApp, platformType, qualityRating, messagingLimit}for the settings card.
Frontend (whatsapp-connect.tsx + fb-embedded-signup.ts — extend):
- Two entry buttons on the card: “חיבור המספר הקיים” (coexistence, default/primary) and “מספר ייעודי חדש” (classic). Each launches
FB.loginwith/withoutfeatureType: 'whatsapp_business_app_onboarding'. - Handle
FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING(waba_id only) alongside existing events; passsignupTypeto finalize. - Map ES error codes to i18n copy:
3441045(needs more app activity),3441042(region),4563015(update app),2655093(already partnered — disconnect first),3441049(wait 15 min). - Post-connect status panel: number, verified name, coexistence badge, quality dot, “keep using your app as usual” explainer + the 13-day-activity and set-profile-first warnings for coexistence.
- Pre-connect checklist copy (coexistence): update app ≥2.24.17, set display name/photo in app first, expect broadcast-lists to lock.
Env: existing META_APP_ID/SECRET, WHATSAPP_VERIFY_TOKEN, WHATSAPP_EMBEDDED_SIGNUP_CONFIG_ID (one config serves both flows). Graph pin to v25.0 (constant bump from v21.0 — verify compat of existing calls).
8. Automations channel (real WhatsAppChannelStrategy)
isConfigured(orgId)= flag on AND activewhatsapp_cloudconnection.MessageActionConfiggains optionalwhatsappTemplateId(shared zod). Send logic per recipient:- In service window → free-form
sendTextwith the interpolatedmessageBody(free, exactly what the author wrote). - Outside window + template configured →
sendTemplatewith param mapping (template body params map from the same interpolation context:first_name,org_name, trigger-seeded tokens). - Outside window, no template →
{skipped, 'outside_service_window'}(visible in the step-run ledger, falls to fallbackChannel).
- In service window → free-form
- Consent: leads already gated by
LeadConsentServicein the dispatcher (fail-closed); members gated by notification prefs (automationcategory,whatsappchannel) inside the strategy. - Editor UI: move
whatsappintoSUPPORTED_CHANNELS, add template picker (approved templates only) + “sends free-form when the member wrote recently, template otherwise” helper copy. Delivery outcome joins the existing step-run display. - All sends go through
WhatsAppSenderService(source='automation',source_ref=step_run id) so budget caps + logs are uniform. The org daily cap (2000) and circuit breaker already apply.
9. Notifications (transactional)
- Add
'whatsapp'toNOTIFICATION_CHANNELS; prefs UI renders it automatically (verify web prefs component iterates the catalog). WhatsAppNotificationsService:notifyMember(userId, category, template, params)— resolves user phone, checksfilterByChannel-style prefs (category × whatsapp), flag + connection, thenenqueueSend(source='notification'). Utility templates only.- System templates auto-provisioned on connect (he/en/ru, category UTILITY, named params):
taikan_booking_confirmed(“ההרשמה שלך ל{{class_name}} ב-{{time}} אושרה”),taikan_class_reminder(“תזכורת: {{class_name}} מחר ב-{{time}}”). Creation is best-effort + idempotent (list-then-create); statuses tracked by webhook. Inside a service window these are free; outside ≈ $0.005. - v1 wiring:
bookings.service.tsconfirmation path (next to the existing email gate) + class-reminder scheduler (mirror how email/push reminders fan out today). Further categories (payment failed, membership expiry, birthday) are fast-follows on the same rail.
10. Broadcasts
- API (
whatsapp/broadcasts.controller.ts,organizations/:orgId/whatsapp/broadcasts): CRUD draft → preview (resolveAudience→ count + sample) → send now / schedule (scheduled_at). Audience: members (all / by membership status) or leads (by pipeline status/tags/campaign) — reusing the existing lead-filter query shapes. - Processor:
whatsapp-broadcastqueue job expands the audience at send time, applies gates per recipient (lead consent / member prefs, valid phone, dedup), createswhatsapp_messagesrows and feedswhatsapp-sendin paced chunks (respecting the token bucket; marketing category → expect131049drops and count them asskipped). Counters update from status webhooks; broadcast flips tocompletedwhen all rows are terminal. - UI (
dashboard/marketing/whatsappor alongside leads): list + create wizard (template picker with preview, param fill from tokens, audience picker with live count, schedule). Guidance copy: ≤1 marketing broadcast/week (quality protection); Saturday warning when scheduled on Shabbat; show per-broadcast delivered/read/failed. Templates page (§11) linked.
11. Templates UI
Settings → WhatsApp tab (or sub-page): template list (status badges incl. paused/rejected + reason), create form (name, category, language he/en/ru, body with {{param}} insertion, optional footer/buttons), delete, “sync from Meta” action. Warn on MARKETING category cost + pacing.
12. Testing & rollout
- Unit: sender error mapping, window helper, channel strategy branching, broadcast audience gating, webhook status/account_update handlers, template status transitions (mirror existing lead-ingestion spec style + drivers).
- Integration (web): connect card (both flows, error codes), template manager, broadcast wizard, automation editor whatsapp channel — driver pattern,
data-testidonly. - Live verification: Meta test number end-to-end (send text/template, statuses webhook) via ngrok before App Review; the App Review screencasts come from this build.
- Rollout: flag off in prod → Business Verification + Tech Provider onboarding + App Review (screencasts) → enable for Erez’s org first (coexistence connect on his real number) → template pack + first broadcast supervised.
- Ops checklist (dashboard, not code): ES v4 config with coexistence customization in the FB Login for Business config; app-level webhook field subscriptions (§2.3); payment-method step in the org onboarding guide; verify Israel rate card before publishing per-message costs to gyms.
13. Build phases (each lands green independently)
| # | Phase | Surface |
|---|---|---|
| 1 | DB schema + enums + shared zod (templates/messages/broadcasts, notification channel, automation config) | libs/db, libs/shared |
| 2 | Graph client + sender service + send queue + window helper | apps/api/whatsapp |
| 3 | Webhook expansion (statuses, all-inbound→conversations, echoes, account_update, quality, template status) | apps/api/lead-ingestion |
| 4 | Connect flow v2: coexistence backend + frontend card | api + web |
| 5 | Templates: service + sync + auto-provision + UI | api + web |
| 6 | Automations channel real impl + editor | api + web + shared |
| 7 | Notifications: prefs channel + booking confirmation + class reminder | api + web |
| 8 | Broadcasts: schema→API→processor→UI | api + web |
| 9 | Tests, i18n (en/he/ru), docs (README/behavior/code-map/qa), Linear update | all |
14. Open items / risks
- Messaging-limit scoping (portfolio vs per-number) changed in Meta docs — verify live before large broadcasts.
- History sync deliberately skipped in v1 (one-shot API; requires offboard/reconnect to redo) — revisit at inbox phase.
- Template pacing can hold the first broadcast of a new template — set expectation in UI copy.
- Graph API version bump to v25.0 touches the shipped lead-ingestion calls — regression-test the CTWA path.
- App Review timeline (Meta says ~24h; community reports 2–7 days) + Business Verification (days–weeks) are the go-live critical path — start immediately; everything here works against the test number meanwhile.