WhatsApp Business API
Multi-tenant WhatsApp Business Cloud API integration: each seller brings their own Meta app credentials; Eziseller handles webhook ingestion, conversation storage, outbound messaging, and catalog sync.
1. Overview
Eziseller's Meta App is shared across all sellers. Each seller connects their own WABA via Embedded Signup (or manual onboarding today) and we store their per-seller access token, phone number ID, and business account ID. The backend uses those per-seller credentials to send messages and routes inbound webhooks to the right seller via phone_number_id. The webhook verify token and app secret are per-app (env vars), not per-seller — Meta's webhook plumbing is shared across all WABAs that have subscribed to our app.
Inbound messages flow through a single Meta webhook endpoint, are fanned out to the correct seller by phone_number_id, stored in the Conversation/Message tables, then (if text) passed to the AI parser to produce a draft order. Outbound messages go directly against Meta's Graph API using the seller's token.
2. Architecture
Single webhook endpoint, per-seller fan-out. Outbound uses per-seller tokens pulled from WhatsAppUserCredentials (AES-encrypted at rest).
3. Data model
Credentials live on WhatsAppUserCredentials (primary, encrypted) with a legacy fallback to WhatsAppBusinessVerification.overallStatus = 'approved'. Conversations are keyed by (userId, customerId, channel='whatsapp'). See schema.prisma.
4. Key flows
4.1 Inbound message
Signature check uses WHATSAPP_APP_SECRET (shared) with HMAC-SHA256 timing-safe compare. Seller is resolved via WhatsAppUserCredentials.phoneNumberId, then WhatsAppBusinessVerification as fallback. Catalog-action button IDs (e.g. "add to cart") short-circuit to WhatsAppCatalogWebhookHandler instead of the AI parser. See meta-webhooks.ts:L39-L254 and ai-parser.md → order-creation-whatsapp.md.
4.2 Outbound message (free-form vs template)
Meta's customer service window allows free-form replies only for 24 hours after the customer's last inbound message; outside it, only pre-approved templates (MARKETING / UTILITY / AUTHENTICATION) are accepted. See whatsapp-business-api.ts:L235-L305.
4.3 Catalog sync
Products are pushed to Meta's Commerce catalog via WhatsAppCatalogService.syncProductsCatalog. The catalog is created once per seller (whatsapp_catalogs table stores the Meta catalog ID), then products are POSTed to /{catalogId}/products; on 400 "already exists" the code falls back to a /batch UPDATE. Variants sync as separate items with retailer_id = ${productId}_${variantId}. Batching is 50 products with a 1s delay between batches. Incremental sync filters by updatedAt in the last 24h. See whatsapp-catalog-service.ts:L145-L298.
5. Lifecycle: message template
Templates are stored locally in WhatsAppMessageTemplate at submission time with status='pending'; status updates arrive via webhook (handled in meta-webhooks around whatsAppMessageTemplate.updateMany).
6. Key files
- backend/lib/whatsapp-business-api.ts:L96-L542 — Graph API client: config resolution, send text/template/media, template CRUD
- backend/lib/whatsapp-business-api.ts:L728-L769 — AES-GCM encryption for stored tokens
- backend/lib/whatsapp-catalog-service.ts:L63-L298 — catalog init + product sync
- backend/routes/whatsapp-business.ts:L70-L720 — seller-facing REST endpoints (connect, send, templates, analytics)
- backend/routes/meta-webhooks.ts:L39-L76 — webhook verification (GET hub.challenge)
- backend/routes/meta-webhooks.ts:L79-L254 — inbound POST with signature check
- backend/routes/meta-webhooks.ts:L448-L730 — message processing + parser dispatch
- backend/prisma/schema.prisma:L1022-L1040 —
WhatsAppUserCredentials
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
WHATSAPP_APP_SECRET | yes | HMAC key for X-Hub-Signature-256 verification on inbound webhooks | All inbound webhooks rejected with 401 |
WHATSAPP_VERIFY_TOKEN | yes | Global verify token for GET /webhooks/meta?hub.verify_token=... | Meta cannot subscribe the webhook |
ENCRYPTION_KEY | yes | 32-char key for AES encryption of per-seller access tokens | Stored credentials cannot be decrypted; all send calls fail |
WHATSAPP_ACCESS_TOKEN | no | Dev fallback used by catalog service when per-user token is absent | Catalog sync fails for sellers without stored creds |
NODE_ENV | yes | When test, signature verification is skipped (Jest only) | Real local dev requires valid HMAC signatures |
Per-seller creds (access token, phone number ID, WABA ID) are stored in WhatsAppUserCredentials, not env. The webhook verify token is per-Meta-App (single env var) — Meta's GET handshake echoes the value set in App Dashboard, once; there's no per-seller verify token to store.
8. Gotchas & troubleshooting
- 24-hour window: Seller tries to send free-form reply >24h after last inbound → Cause: Meta policy → Fix: detect window in UI and force template selection. Errors surface as
131047from Graph API. - Template rejected by Meta: Template stuck at
status='pending'for days, then flips torejected→ Cause: marketing language in UTILITY category, missing samples, or promotional content without opt-in wording → Fix: resubmit with correct category and samples; reviews take minutes to 24h. - Template parameter order mismatch: Runtime error "parameter count mismatch" → Cause:
components[].parametersarray order must match{{1}} {{2}}placeholders exactly → Fix: keep parameter construction next to the template definition; do not reorder BODY params without editing the template. - Webhook retries cause duplicate messages: Meta retries webhooks on non-2xx within ~20s → Cause: our handler threw after partial work → Fix: always return 200 quickly; dedup on
Message.externalId(indexed) when re-ingesting. Handler currently logs errors and still returns 200. - Wrong seller receives a message: Inbound arrives for phone A but routes to seller B → Cause: two sellers seeded the same
phoneNumberIdinWhatsAppUserCredentials(test data) → Fix: add a unique constraint on active rows; verify via/whatsapp-business/status. - Signature verification skipped in test runner: Bypass branch in
verifyWebhookSignatureonly fires whenNODE_ENV === 'test'(Jest sets this automatically). Real local dev withnpm run backend:devrequires valid HMAC signatures — use ngrok and your realWHATSAPP_APP_SECRET. Previous behaviour bypassed indevelopment; that branch was removed (commitb930489). - Token encryption: All credential encryption goes through backend/lib/encryption.ts — real AES-256-GCM via
createCipherivwith random IV + auth tag, scrypt-derived key. The three lib files (whatsapp-business-api,whatsapp-business-config,instagram-business-api) delegate to it. Do not rotateENCRYPTION_KEYwithout re-encrypting existing rows — the salt is hardcoded as'eziseller-salt'and the scrypt-derived key is deterministic from the env var, so a new env value cannot decrypt existing data. Previously was deprecatedcreateCipher(CBC under the hood, no auth tag, MD5 key derivation); fixed in Workstream A3 (commitd94ed35). - Catalog "already exists" storms: First full sync spams 400s → Cause: product created on a prior sync → Fix: already handled by the POST→batch UPDATE fallback in
syncSingleProduct; ignore the warning log.
9. Extension points
- Add a new inbound message type: extend
extractTextFromWhatsAppMessagein meta-webhooks; if it should bypass the parser, add its action ID toCATALOG_ACTION_IDS. - New outbound message type: add a method to
WhatsAppBusinessAPImirroringsendMediaMessage, then expose via awhatsapp-business.tsroute. Always calllogMessageso analytics stays consistent. - Multiple phone numbers per seller: requires dropping the
@uniqueonWhatsAppUserCredentials.userIdand switching the lookup infindSellerByPhoneNumberIdto return the specific row. - Template auto-resubmit on rejection: hook into the template status webhook branch in meta-webhooks and re-POST with edits.
- Proper dedup: wrap message ingestion in an upsert on
Message.externalId(already indexed) before calling the parser.
10. Related docs
- messaging-overview.md — shared messaging architecture across channels
- instagram.md — sister integration, same webhook endpoint
- ai-parser.md — how inbound text becomes structured order intent
- ../webhooks/webhooks-overview.md — signature verification patterns
- ../../01-core/orders/order-creation-whatsapp.md — what happens after the parser returns