Integrations
Messaging
Whatsapp

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.mdorder-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

7. Env vars & config

VarRequiredPurposeWhat breaks
WHATSAPP_APP_SECRETyesHMAC key for X-Hub-Signature-256 verification on inbound webhooksAll inbound webhooks rejected with 401
WHATSAPP_VERIFY_TOKENyesGlobal verify token for GET /webhooks/meta?hub.verify_token=...Meta cannot subscribe the webhook
ENCRYPTION_KEYyes32-char key for AES encryption of per-seller access tokensStored credentials cannot be decrypted; all send calls fail
WHATSAPP_ACCESS_TOKENnoDev fallback used by catalog service when per-user token is absentCatalog sync fails for sellers without stored creds
NODE_ENVyesWhen 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 131047 from Graph API.
  • Template rejected by Meta: Template stuck at status='pending' for days, then flips to rejectedCause: 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[].parameters array 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 phoneNumberId in WhatsAppUserCredentials (test data) → Fix: add a unique constraint on active rows; verify via /whatsapp-business/status.
  • Signature verification skipped in test runner: Bypass branch in verifyWebhookSignature only fires when NODE_ENV === 'test' (Jest sets this automatically). Real local dev with npm run backend:dev requires valid HMAC signatures — use ngrok and your real WHATSAPP_APP_SECRET. Previous behaviour bypassed in development; that branch was removed (commit b930489).
  • Token encryption: All credential encryption goes through backend/lib/encryption.ts — real AES-256-GCM via createCipheriv with 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 rotate ENCRYPTION_KEY without 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 deprecated createCipher (CBC under the hood, no auth tag, MD5 key derivation); fixed in Workstream A3 (commit d94ed35).
  • 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 extractTextFromWhatsAppMessage in meta-webhooks; if it should bypass the parser, add its action ID to CATALOG_ACTION_IDS.
  • New outbound message type: add a method to WhatsAppBusinessAPI mirroring sendMediaMessage, then expose via a whatsapp-business.ts route. Always call logMessage so analytics stays consistent.
  • Multiple phone numbers per seller: requires dropping the @unique on WhatsAppUserCredentials.userId and switching the lookup in findSellerByPhoneNumberId to 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