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
0086generated + 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)
- 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. - 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.
- Plan —
implementation-plan.mdwritten and treated as the source of truth. - Build — phases 1–8 below, each landing green.
- Wrap-up — docs, Linear, memory, this handoff.
2. Decision log (with the “why”)
| Decision | Why |
|---|---|
| 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 default | Gyms 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 only | v2/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 v1 | One-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 otherwise | Mirrors 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 connect | taikan_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 ledger | No increment races; refreshAndMap recounts for sending broadcasts and flips to completed when all messages are terminal. |
| Graph API pinned at v21.0 | The 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 eventFINISH_WHATSAPP_BUSINESS_APP_ONBOARDINGcarries onlywaba_id→ backend resolves the number viaGET /{waba_id}/phone_numbers(prefersis_on_biz_app). Never call/registeror/deregisteron these numbers. Verify withGET /{phone_number_id}?fields=is_on_biz_app,platform_type. - Coexistence constraints: fixed 20 msg/s; app must open every ~13 days (
PRIMARY_INACTIVITYdisconnect); 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_updatePARTNER_REMOVEDwebhook (handled: auto-revoke). - ES connect error codes surfaced in the UI:
3441045needs-more-app-activity,3441042region,4563015app too old (<2.24.17),2655093already partnered,3441049wait 15 min. - Send error semantics (implemented in the processor):
131049/130472per-user marketing cap →dropped, never retry same-day;131047outside window → terminal fail;130429/131056/HTTP 429/5xx → retry with backoff;190→ connection flaggederror;held_for_quality_assessment→ statusheld(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 inintegration_connections.encrypted_credentials(keyPAYMENT_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, uniquewamid),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 toNOTIFICATION_CHANNELS(zero-migration; mobile app picks it up on next shared release).schemas/automation.schema.ts—whatsappTemplateIdon message config;whatsappmoved intoSUPPORTED_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_proofon every call, typedWhatsAppGraphError.whatsapp-sender.service.ts— the single producer entry point: flag+connection gate → ledger row (exact request body persisted) →whatsapp-sendqueue.whatsapp-send.processor.ts— delivery + the full error mapping above +broadcastjob 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_updatePARTNER_REMOVED→revoke+wipe, quality→connection config, template status→registry.whatsapp-window.service.ts— 24h window fromconversations.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 optionalphoneNumberId+signupType.lead-ingestion/whatsapp.types.ts— statuses/echoes/richer message types.conversations/conversations.service.ts— new publicrecordChannelInbound/recordChannelEcho+findOrCreateByExternalRef(threads keyed by wa_id, identity backfill).automations/channels/whatsapp.channel.ts— real strategy (was stub);channel.strategy.tsSendParams gainedwhatsappTemplateId+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 importWhatsAppModule.
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). Routedashboard/broadcasts/page.tsx, sidebar item (Megaphone icon, Grow group).overview/automations/automation-editor.tsx— whatsapp channel appears viaSUPPORTED_CHANNELS; template picker rendered when channel=whatsapp; round-tripswhatsappTemplateId.
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):
- Real Graph calls end-to-end (test number): send text/template, status webhooks landing, template create/review round-trip.
- Coexistence ES popup on a real number (event shape, error codes, contacts sync).
- Template auto-provisioning against a real WABA (Meta may recategorize or pace).
- The
smb_message_echoespayload shape (typed from docs; confirmvalue.message_echoeson first real event). - Messaging-limit scoping (portfolio vs per-number — Meta docs changed; check before any big broadcast).
6. Resume runbook — the moment Meta approves
- 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 → setWHATSAPP_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.
- Confirm Advanced Access granted for
- Merge the branch (commit → PR → CI runs migration
0086on merge; prod migrates via Railway deploy). - 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. - Enable for Erez (
whatsapp-messagingflag → his orgb86fae6c…in PostHog): he updates the WhatsApp Business app, sets display name/photo, then Settings → Integrations → “חיבור המספר הקיים”. Watch: connection rowactive+signupType: 'coexistence', health endpoint showsis_on_biz_app: true, system templates land (sync + check statuses), he adds a payment method in WhatsApp Manager (business.facebook.com/wa/manage/home/). - First supervised sends: booking confirmation on a real booking → class-reminder cron → one small broadcast (utility template first; watch quality rating +
heldpacing). - 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 WhatsAppChannelAdapterin 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_aireserved 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_synccontacts not yet consumed (subscribed + ignored), web has no notification-prefs UI (prefs surface is mobile;whatsappchannel 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 xguards (bit us once inwhatsapp.channel.ts). conversations.external_contact_idholds wa_id (digits, no+) for WhatsApp threads butlead:{id}/user:{id}refs for email threads — always go throughfindOrCreateByExternalReffor channel webhooks.- Lead phones are stored canonical
+972…; wa_ids arrive without+—normalizeLeadPhone/WhatsAppWindowService.toWaIddo the mapping. whatsapp_messages.payloadis 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.