Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWhatsapp MessagingWhatsApp Messaging & Notifications — Implementation Plan

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):

  1. Outbound notification infrastructure — template + free-form sends from the org’s own number, with delivery tracking. First transactional consumers: booking confirmation + class reminder.
  2. Automations whatsapp channel — replace the stub behind ChannelStrategy; free-form inside the 24h service window, approved-template outside it.
  3. 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).
  4. Coexistence-first connect flow — gyms keep their WhatsApp Business App on their existing number; classic (dedicated-number) Embedded Signup also supported.
  5. 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 list business_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 via account_update webhook.
  • 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_ONBOARDING and carries only waba_id → resolve the number via GET /{waba_id}/phone_numbers. Classic path keeps FINISH/FINISH_ONLY_WABA with both IDs.
  • Provisioning delta vs classic: exchange code + POST /{waba_id}/subscribed_apps as normal, but skip POST /{phone_number_id}/register (already registered via the app; /deregister is likewise blocked). Verify with GET /{phone_number_id}?fields=is_on_biz_app,platform_type.
  • Post-onboard syncs (one-shot each, 24h deadline): POST /{phone_number_id}/smb_app_data with sync_type: smb_app_state_sync (contacts) and sync_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_echoes webhooks (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, reason PRIMARY_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_reconnected webhook confirms.

2.3 Multi-WABA routing & scale

  • One app-level webhook endpoint (existing POST /webhooks/whatsapp) receives everything; route by value.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 by entry[].id (WABA) instead; store wabaId denormalized on the connection (already in config).
  • 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 131047 if 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 whatsapp channel in notification prefs (opt-out model, consistent with existing push/email); leads = existing explicit-consent gate (LeadConsentService, CTWA grants service_window consent; 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 capabilityv1Later
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) + prefstemp-blacklist UI
Failed-payment dunningtemplate groundworkwire to payments events
Scheduling + Shabbat awareness✅ broadcast scheduling warns on Saturday
Preview/edit queue before sendbroadcast preview + countsper-recipient toggle
Menu chatbots / CRM-status-aware routing—flows/bots phase
AI agent—Spotter-on-WhatsApp
Unified inbox w/ client carddata 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_at

Broadcast 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_at documented as the 24h-window driver, external_message_id = wamid): the webhook starts recording all inbound WhatsApp messages (not just CTWA) via ConversationsService.recordInbound, and smb_message_echoes as outbound rows with metadata.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.config gains: signupType: 'coexistence' | 'classic', businessId, qualityRating, messagingLimit (updated by webhooks). No column change — jsonb.
  • notification_prefs: add 'whatsapp' to NOTIFICATION_CHANNELS in @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’s MetaGraphService to 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 attach appsecret_proof.
  • WhatsAppSenderService — the one entry point every producer uses: enqueueSend({orgId, to, recipient, source, sourceRef, content}). Writes the whatsapp_messages row (queued), resolves the active connection, pushes a job to queue whatsapp-send.
  • WhatsAppSendProcessor (ObservableWorkerHost, queue registered in bull-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 → mark failed: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): reads conversations.last_inbound_at for (org, channel='whatsapp', external_contact_id); true iff < 24h ago.
  • Webhook expansion (in lead-ingestion’s existing controller/service, which stays the single POST /webhooks/whatsapp):
    • value.statuses[] → update whatsapp_messages by 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 connection revoked, wipe credentials, track event; PARTNER_ADDED → track; account_reconnected → re-activate if row exists.
    • phone_number_quality_update → stash qualityRating/limit in connection config.
    • message_template_status_update → update whatsapp_templates.status by (waba_id, name, language), store reason.

7. Connect flow v2 (coexistence + classic)

Backend (whatsapp-connect.service.ts — extend):

  • finalize gains signupType (from which ES event fired). Coexistence: skip register, resolve phone_number_id via GET /{waba_id}/phone_numbers when the event only carried waba_id, call initiateSmbAppSync(…, 'smb_app_state_sync') best-effort, store config.signupType.
  • Store businessId when 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.login with/without featureType: 'whatsapp_business_app_onboarding'.
  • Handle FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING (waba_id only) alongside existing events; pass signupType to 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 active whatsapp_cloud connection.
  • MessageActionConfig gains optional whatsappTemplateId (shared zod). Send logic per recipient:
    1. In service window → free-form sendText with the interpolated messageBody (free, exactly what the author wrote).
    2. Outside window + template configured → sendTemplate with param mapping (template body params map from the same interpolation context: first_name, org_name, trigger-seeded tokens).
    3. Outside window, no template → {skipped, 'outside_service_window'} (visible in the step-run ledger, falls to fallbackChannel).
  • Consent: leads already gated by LeadConsentService in the dispatcher (fail-closed); members gated by notification prefs (automation category, whatsapp channel) inside the strategy.
  • Editor UI: move whatsapp into SUPPORTED_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' to NOTIFICATION_CHANNELS; prefs UI renders it automatically (verify web prefs component iterates the catalog).
  • WhatsAppNotificationsService: notifyMember(userId, category, template, params) — resolves user phone, checks filterByChannel-style prefs (category × whatsapp), flag + connection, then enqueueSend(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.ts confirmation 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-broadcast queue job expands the audience at send time, applies gates per recipient (lead consent / member prefs, valid phone, dedup), creates whatsapp_messages rows and feeds whatsapp-send in paced chunks (respecting the token bucket; marketing category → expect 131049 drops and count them as skipped). Counters update from status webhooks; broadcast flips to completed when all rows are terminal.
  • UI (dashboard/marketing/whatsapp or 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-testid only.
  • 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)

#PhaseSurface
1DB schema + enums + shared zod (templates/messages/broadcasts, notification channel, automation config)libs/db, libs/shared
2Graph client + sender service + send queue + window helperapps/api/whatsapp
3Webhook expansion (statuses, all-inbound→conversations, echoes, account_update, quality, template status)apps/api/lead-ingestion
4Connect flow v2: coexistence backend + frontend cardapi + web
5Templates: service + sync + auto-provision + UIapi + web
6Automations channel real impl + editorapi + web + shared
7Notifications: prefs channel + booking confirmation + class reminderapi + web
8Broadcasts: schema→API→processor→UIapi + web
9Tests, i18n (en/he/ru), docs (README/behavior/code-map/qa), Linear updateall

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.