Integrations
Messaging
Messaging Overview

Messaging — Overview

Unified inbox that normalises WhatsApp Business and Instagram DM conversations into a single Conversation/Message model, per tenant. Audience: new dev with Node/React experience, no Eziseller context.

1. Overview

Sellers on Eziseller talk to customers across WhatsApp Business and Instagram DMs. Each channel has its own Meta webhook, credentials, and quirks, but sellers want one inbox, one customer record, and one place where AI parsing can turn a chat into a draft order. The messaging layer achieves this by funnelling both channels through ConversationService, which maps every inbound/outbound message onto a tenant-scoped CustomerConversationMessage graph. Credentials are stored per seller (WhatsAppUserCredentials, InstagramUserCredentials), so the platform is fully multi-tenant — there is no shared Meta app token path. The inbox is gated to the Professional plan (WhatsApp API feature); the service silently no-ops for users without it.

2. Architecture

Meta delivers webhooks to channel-specific handlers; each handler resolves the tenant from the incoming phone/page id, then hands off to ConversationService.storeMessage, which is the single write path. Outbound sends go through sendAndStoreOutgoingMessage, which writes a pending row first, then flips it to sent/failed after the Meta call. The inbox UI currently polls the conversations endpoint — there is no WebSocket yet.

3. Data model

Key points: Conversation is uniquely keyed by (userId, customerId, channel) — the same customer on WhatsApp and Instagram becomes two conversations but one Customer (matched on (userId, phone)). Message.externalId holds the Meta message id and is indexed so status callbacks (delivered, read) can update the right row. Denormalised lastMessageAt, lastMessagePreview, unreadCount live on Conversation for cheap inbox listing. See schema.prisma:L1239-L1293.

4. Key flows

4.1 Inbound message (end-to-end)

4.2 Outbound message

5. Lifecycle / state machine

ConversationService.storeMessage always sets status:'open' on write, so any new inbound message reopens a closed/archived thread — see conversation-service.ts:L117-L121.

6. Key files

7. Env vars & config

VarRequiredPurposeWhat breaks
WHATSAPP_APP_SECRETyesHMAC-verify Meta WA webhook payloadsAll WA inbound rejected
WHATSAPP_VERIFY_TOKENyesMeta webhook GET verification challengeCannot register webhook
INSTAGRAM_APP_SECRETyesHMAC-verify IG webhook payloadsAll IG inbound rejected
ENCRYPTION_KEYyesAES key for encrypted tokens in *UserCredentialsTenant send/webhook lookup fails
FRONTEND_URLyesCORS for inbox APIInbox UI fails CORS

Per-tenant credentials (phone number id, page id, access/verify tokens) live in WhatsAppUserCredentials / InstagramUserCredentials, encrypted with ENCRYPTION_KEY — not in env.

8. Gotchas & troubleshooting

  • Inbox silently empty despite webhooks arrivingCause: ConversationService.storeMessage early-returns when the user lacks the WhatsApp API feature (conversation-service.ts:L39-L43). → Fix: upgrade plan, or check canUserAccess in logs.
  • WhatsApp 24-hour window → Free-form outbound text only works inside 24h of the last customer message. Outside it, Meta rejects the send and the message row flips to failed. Use a pre-approved MessageTemplate instead — template sends are not gated by the window.
  • Instagram 7-day / pages_messaging window → IG allows replies within 7 days of the last user interaction, and requires Instagram Messaging + pages_messaging permissions granted to the connected Page. Missing permission surfaces as a generic 400 from Meta.
  • Same customer, two conversationsConversation is unique on (userId, customerId, channel) by design. Do not try to merge WA and IG threads; the Customer row is the unification point.
  • Duplicate messages on webhook retry → Meta retries on non-2xx. Dedup on Message.externalId (indexed) before insert if you add new write paths — storeMessage does NOT dedup today.
  • Status stuck on sent → Delivery/read updates arrive on a separate status webhook and match by externalId. If the outbound send returned no id (network error mid-flight), no status callback will ever land.
  • Outgoing message without corresponding Meta send → IG outbound via Business API is not implemented in sendAndStoreOutgoingMessage; only the DB row is written. See the comment at conversation-service.ts:L382.
  • No real-time push → The inbox polls. getUnreadCount is cheap (aggregate over denormalised field) but do not poll faster than a few seconds.

9. Extension points

To add a new channel (e.g. Telegram):

  1. Add ChannelUserCredentials model + encrypted token columns, mirroring WhatsAppUserCredentials.
  2. Add a webhook route under backend/routes/ that verifies the provider signature and resolves userId from the incoming bot/page id.
  3. Call ConversationService.storeMessage({ channel: 'telegram', ... }) — the service treats channel as an opaque string, so no schema change is needed on Conversation/Message.
  4. Extend sendAndStoreOutgoingMessage with a new branch, or inject a channel-sender strategy.
  5. Whitelist the channel in the inbox filter UI and in ConversationFilters.
  6. Decide on the channel's reply window and surface it in the composer so sellers know when templates are required.

10. Related docs