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.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 + verify 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 development, signature verification is skipped | Silent 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
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 dev: Any payload accepted locally → Cause:
NODE_ENV !== 'production'branch inverifyWebhookSignature→ Fix: never rely on dev behaviour in staging; setNODE_ENV=productionon Azure. createDecipherdeprecation: The encryption helpers use Node's deprecatedcreateCipher/createDecipher(not GCM despite the name) → Cause: legacy implementation → Fix: tracked as tech debt; do not rotateENCRYPTION_KEYwithout 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
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