Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWhatsapp MessagingWhatsApp Integration — End-to-End Handoff (2026-07-10)

WhatsApp Integration — End-to-End Handoff (2026-07-10)

Purpose: full context snapshot of the WhatsApp build session so work resumes instantly once Meta App Review clears. Written at the end of the session that produced the entire v1 implementation.

State at handoff:

  • Branch whatsapp-integration (repo clone at ~/dev/whatsapp-integration), all work staged, NOT committed — awaiting Saar’s review + “commit”.
  • Meta App Review: submitted, in progress (Business Verification path underway). Everything else is done.
  • Tests all green: API 877/877 unit · web 175/175 unit · web 370/370 integration; typecheck + lint clean on all touched files (remaining repo errors are pre-existing and unrelated).
  • Migration 0086 generated + reviewed, not applied locally (CI applies on merge).
  • Linear updated: FIT-140 rewritten (Meta-direct, In Progress), FIT-103 closed duplicate, FIT-54/FIT-102/FIT-17 commented with what’s unblocked/superseded.

1. What happened in this session (chronology)

  1. Research — four parallel deep dives over Meta’s July-2026 docs: Tech Provider/ISV model, Embedded Signup v4, Coexistence (four sub-angles: eligibility/availability, ES flow, webhooks/payloads, operational constraints), Cloud API messaging reference, and an UpGrade360 competitor teardown. The distilled digest (with source URLs and confidence flags) lives in implementation-plan.md §2–3; do not re-research these.
  2. Scoping decisions (Saar, via prompt): v1 = outbound notifications + automations channel + broadcasts (NOT the inbox UI); org pays Meta directly (Tech Provider, no credit line); coexistence default with classic ES also supported; plan + full implementation in one session.
  3. Plan — implementation-plan.md written and treated as the source of truth.
  4. Build — phases 1–8 below, each landing green.
  5. Wrap-up — docs, Linear, memory, this handoff.

2. Decision log (with the “why”)

DecisionWhy
Meta-direct Tech Provider, Twilio dead$0 markup, no BSP dependency, full CTWA attribution; Twilio has no coexistence support at all. Customers add a card to their own WABA → zero billing infra for Taikan.
Coexistence defaultGyms live in the WhatsApp Business app and won’t give it up. Israel fully supported. Classic ES kept for orgs wanting a dedicated number.
Embedded Signup v4 onlyv2/v3 hard-deprecate 2026-10-15.
Inbound → conversations tables (no new message-log for inbound)Schema was already channel-aware (external_contact_id, last_inbound_at documented as the future window clock). Gives the 24h service-window clock now and makes the inbox UI a pure-frontend phase later.
Outbound → new whatsapp_messages ledger (not conversations)Delivery statuses join by wamid; broadcast stats and audit/replay need the exact request payload; keeps consent/inbox concerns separate.
Skip coexistence history sync in v1One-shot API + 24h deadline, but we have no inbox to hold 6 months of chats and it’s a privacy liability. Re-onboarding recovers it later. Contacts sync IS initiated.
Automations: free-form in window / template outside / skip otherwiseMirrors Meta’s rules exactly; skip reason outside_service_window is visible in the step-run ledger and falls to the fallback channel. Rejected the “generic {{1}} template” ISV hack (Meta rejects those).
System templates auto-provisioned on connecttaikan_booking_confirmed + taikan_class_reminder × he/en/ru (UTILITY) so notifications work without the gym touching the templates screen. Best-effort, idempotent.
Flag whatsapp-messaging (PostHog, per-org, default off, fail-off)Repo policy. Gates every send path + isConfigured. Local: FEATURE_FLAGS=whatsapp-messaging:true.
Broadcast counters recomputed lazily from the ledgerNo increment races; refreshAndMap recounts for sending broadcasts and flips to completed when all messages are terminal.
Graph API pinned at v21.0The shipped CTWA lead path uses it; bumping to v25 is a deliberate follow-up with regression testing.

3. Meta facts you’ll need again (condensed; full digest in the plan)

  • Coexistence ES: extras.featureType: 'whatsapp_business_app_onboarding', sessionInfoVersion: '3'. Success event FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING carries only waba_id → backend resolves the number via GET /{waba_id}/phone_numbers (prefers is_on_biz_app). Never call /register or /deregister on these numbers. Verify with GET /{phone_number_id}?fields=is_on_biz_app,platform_type.
  • Coexistence constraints: fixed 20 msg/s; app must open every ~13 days (PRIMARY_INACTIVITY disconnect); app-side broadcast lists become read-only (our broadcasts replace them); display name + photo lock at onboarding (tell gyms to set them first); disconnect is SMB-initiated → account_update PARTNER_REMOVED webhook (handled: auto-revoke).
  • ES connect error codes surfaced in the UI: 3441045 needs-more-app-activity, 3441042 region, 4563015 app too old (<2.24.17), 2655093 already partnered, 3441049 wait 15 min.
  • Send error semantics (implemented in the processor): 131049/130472 per-user marketing cap → dropped, never retry same-day; 131047 outside window → terminal fail; 130429/131056/HTTP 429/5xx → retry with backoff; 190 → connection flagged error; held_for_quality_assessment → status held (template pacing).
  • Pricing: per delivered template message (Israel ≈ $0.0053 utility / $0.0353 marketing); free-form in-window free; utility templates in-window free; CTWA click opens a 72h free window. ES exchange code TTL ≈ 30s → exchange synchronously (already the shape of finalize).
  • Tokens: BISU tokens from the code exchange never expire by default; revocation comes only via account_update / Business Manager. Stored AES-256-GCM in integration_connections.encrypted_credentials (key PAYMENT_CREDENTIALS_ENCRYPTION_KEY).

4. What was built (file inventory)

DB (libs/db) — migration drizzle/0086_chunky_impossible_man.sql:

  • schema/whatsapp.ts: whatsapp_templates (unique (waba_id, name, language) — how status webhooks route), whatsapp_messages (outbound ledger, unique wamid), whatsapp_broadcasts (audience jsonb definition + denormalized counters).
  • schema/enums.ts: 5 new enums (template category/status, message source/status, broadcast status).

Shared (libs/shared):

  • schemas/whatsapp.schema.ts — all DTOs/zod (template create, connect finalize incl. signupType, broadcast create/audience).
  • schemas/notification-prefs.ts — 'whatsapp' added to NOTIFICATION_CHANNELS (zero-migration; mobile app picks it up on next shared release).
  • schemas/automation.schema.ts — whatsappTemplateId on message config; whatsapp moved into SUPPORTED_CHANNELS.
  • Dictionaries en/he/ru — connect card coexistence copy, editor template-picker copy, templates manager, broadcasts page, nav.

API — new module apps/api/src/whatsapp/ (registered in app.module.ts; queue in bull-board/queue-names.ts):

  • whatsapp-graph.service.ts — Cloud API client (sendMessage, templates CRUD, listPhoneNumbers, initiateSmbAppSync, getPhoneNumberStatus), appsecret_proof on every call, typed WhatsAppGraphError.
  • whatsapp-sender.service.ts — the single producer entry point: flag+connection gate → ledger row (exact request body persisted) → whatsapp-send queue.
  • whatsapp-send.processor.ts — delivery + the full error mapping above + broadcast job dispatch; worker limiter 10 msg/s (coexistence-safe). Spec: 10 cases.
  • whatsapp-events.service.ts — webhook fan-out: statuses→ledger (rank-ordered, never regress), all inbound→conversations.recordChannelInbound (lead/member identity resolution by phone), smb_message_echoes→recordChannelEcho, account_update PARTNER_REMOVED→revoke+wipe, quality→connection config, template status→registry.
  • whatsapp-window.service.ts — 24h window from conversations.last_inbound_at.
  • whatsapp-templates.service.ts + controller — registry CRUD/sync vs Graph, findApproved (language fallback exact→he→en), provisionSystemTemplates.
  • whatsapp-system-templates.ts — the he/en/ru system template definitions.
  • whatsapp-template-params.ts — buildBodyParams (named {{param}} → components payload, blank-unknown rule).
  • whatsapp-notifications.service.ts — transactional member sends (prefs-gated, approved-template-gated, never throws).
  • whatsapp-broadcasts.service.ts + controller — audience resolution (members by membership scope / leads by status+tags+campaign, phone-dedup), per-recipient consent gates at send time, 5-min scheduler cron (CRONS_ENABLED-gated), lazy counter recompute.

API — modified:

  • lead-ingestion/whatsapp-webhook.controller.ts — routes all new webhook fields (was messages-only).
  • lead-ingestion/whatsapp-connect.service.ts — coexistence branch (skip register, resolve number, contacts sync, config.signupType), health() endpoint, fire-and-forget system-template provisioning; DTO gained optional phoneNumberId + signupType.
  • lead-ingestion/whatsapp.types.ts — statuses/echoes/richer message types.
  • conversations/conversations.service.ts — new public recordChannelInbound / recordChannelEcho + findOrCreateByExternalRef (threads keyed by wa_id, identity backfill).
  • automations/channels/whatsapp.channel.ts — real strategy (was stub); channel.strategy.ts SendParams gained whatsappTemplateId+context; dispatcher passes them. Spec: 9 cases.
  • bookings.service.ts — WhatsApp confirmation leg (fire-and-forget, fetches user phone); notification-scheduler.service.ts — class-reminder WhatsApp leg in the 8:00 cron. Modules import WhatsAppModule.

Web:

  • settings/fb-embedded-signup.ts — signupType param, coexistence featureType, tolerates missing phone_number_id.
  • settings/whatsapp-connect.tsx — two connect buttons (coexistence primary), checklist/keep-alive copy, coexistence badge.
  • settings/whatsapp-templates.tsx (+driver/spec, 8 tests) — list/status badges/sync/delete/create dialog.
  • components/broadcasts/broadcasts-view.tsx + create-broadcast-dialog.tsx (+driver/spec, 8 tests) — list with stats + wizard (approved-template picker, audience radio, debounced live preview count, datetime schedule with Saturday/Shabbat warning, ≤1-marketing/week guidance). Route dashboard/broadcasts/page.tsx, sidebar item (Megaphone icon, Grow group).
  • overview/automations/automation-editor.tsx — whatsapp channel appears via SUPPORTED_CHANNELS; template picker rendered when channel=whatsapp; round-trips whatsappTemplateId.

Docs: implementation-plan.md (canonical), README.md, this handoff; leads-crm/whatsapp-lead-ingestion.md cross-linked; .env.example flag note; docs/features/_meta.json nav entry.

5. What is verified vs NOT verified

Verified: everything unit/integration-testable (suites above), typecheck, lint, both connect flows at the service level, webhook routing, error mapping, audience gating.

NOT yet verified (needs live credentials):

  1. Real Graph calls end-to-end (test number): send text/template, status webhooks landing, template create/review round-trip.
  2. Coexistence ES popup on a real number (event shape, error codes, contacts sync).
  3. Template auto-provisioning against a real WABA (Meta may recategorize or pace).
  4. The smb_message_echoes payload shape (typed from docs; confirm value.message_echoes on first real event).
  5. Messaging-limit scoping (portfolio vs per-number — Meta docs changed; check before any big broadcast).

6. Resume runbook — the moment Meta approves

  1. Dashboard (one-time):
    • Confirm Advanced Access granted for whatsapp_business_management + whatsapp_business_messaging.
    • Facebook Login for Business → the ES configuration (Embedded Signup variation, both whatsapp_* permissions, coexistence/“WhatsApp Business app onboarding” customization enabled) → copy config id → set WHATSAPP_EMBEDDED_SIGNUP_CONFIG_ID (Railway + local).
    • App Dashboard → WhatsApp → Configuration → webhook URL https://<api>/webhooks/whatsapp + WHATSAPP_VERIFY_TOKEN; subscribe fields: messages, message_template_status_update, account_update, phone_number_quality_update, smb_message_echoes, smb_app_state_sync, history.
  2. Merge the branch (commit → PR → CI runs migration 0086 on merge; prod migrates via Railway deploy).
  3. Test-number smoke (can also be done pre-approval): pnpm dev:api + ngrok → point a test-app webhook at it → FEATURE_FLAGS=whatsapp-messaging:true → connect the test number (classic flow) → book a class as a member with a phone → verify ledger row goes queued→accepted→delivered → reply from the phone → verify a conversation row + window opens → trigger an automation → verify free-form send.
  4. Enable for Erez (whatsapp-messaging flag → his org b86fae6c… in PostHog): he updates the WhatsApp Business app, sets display name/photo, then Settings → Integrations → “חיבור המספר הקיים”. Watch: connection row active + signupType: 'coexistence', health endpoint shows is_on_biz_app: true, system templates land (sync + check statuses), he adds a payment method in WhatsApp Manager (business.facebook.com/wa/manage/home/).
  5. First supervised sends: booking confirmation on a real booking → class-reminder cron → one small broadcast (utility template first; watch quality rating + held pacing).
  6. If review bounces: usual causes — screencast doesn’t literally show the permission in use, missing customer-usage text, over-requested permissions (only the two whatsapp_* are needed).

7. Follow-up backlog (Linear state)

  • FIT-140 (In Progress) — this build; closes when merged + first org live.
  • Inbox UI — biggest next feature; data layer done (all inbound + echoes already in conversations); needs a WhatsApp ChannelAdapter in the inbox registry + UI; revisit history sync then. No ticket yet.
  • FIT-54 — birthday/inactive/expiry triggers (cron enrollment sources) + campaign template pack; channel ready.
  • Payment-failure dunning — small: template + hook into Morning failure events. No ticket yet.
  • FIT-102 — reduced to the tier/packaging decision (whatsapp_ai reserved as Pro since FIT-81).
  • FIT-17 — Spotter-on-WhatsApp; plumbing exists; rewrite the epic around Spotter before building.
  • Tech debt: Graph v21→v25 bump (regression-test CTWA), per-connection rate buckets at scale, media/interactive message types, smb_app_state_sync contacts not yet consumed (subscribed + ignored), web has no notification-prefs UI (prefs surface is mobile; whatsapp channel appears there on next shared release).

8. Gotchas for whoever picks this up

  • The repo compiles without TS strict mode — boolean-discriminant narrowing doesn’t work; use 'prop' in x guards (bit us once in whatsapp.channel.ts).
  • conversations.external_contact_id holds wa_id (digits, no +) for WhatsApp threads but lead:{id}/user:{id} refs for email threads — always go through findOrCreateByExternalRef for channel webhooks.
  • Lead phones are stored canonical +972…; wa_ids arrive without + — normalizeLeadPhone / WhatsAppWindowService.toWaId do the mapping.
  • whatsapp_messages.payload is the exact Graph request body — replay/audit tool, don’t slim it.
  • Broadcast re-runs are safe: expansion skips phones that already have a ledger row for that broadcast.
  • The webhook must never throw (Meta retries the entire delivery); every events-service handler swallows + logs.
  • STOP keywords (הסר/עצור/ביטול/STOP/UNSUBSCRIBE) withdraw lead consent — shipped pre-session, don’t duplicate.