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 sellers receive orders on WhatsApp from their own customers, so we cannot share a single Meta app across the platform (phone numbers are tied 1:1 to a WhatsApp Business Account). Each seller connects their own WABA by saving their access token, phone number ID, business account ID, and a webhook verify token. The backend uses those per-user credentials to send messages and to route inbound webhooks to the right seller.

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 + verify 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 development, signature verification is skippedSilent webhook acceptance in prod if misconfigured

Per-seller creds (access token, phone number ID, WABA ID, webhook verify token) are stored in WhatsAppUserCredentials, not env.

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 dev: Any payload accepted locally → Cause: NODE_ENV !== 'production' branch in verifyWebhookSignatureFix: never rely on dev behaviour in staging; set NODE_ENV=production on Azure.
  • createDecipher deprecation: The encryption helpers use Node's deprecated createCipher/createDecipher (not GCM despite the name) → Cause: legacy implementation → Fix: tracked as tech debt; do not rotate ENCRYPTION_KEY without a migration path for existing rows.
  • 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