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 Customer → Conversation → Message 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
- backend/lib/conversation-service.ts:L36-L138 — single write path; find-or-create Customer + Conversation, insert Message, update denormalised fields.
- backend/lib/conversation-service.ts:L336-L383 — outbound
pending→sent/failedpattern. - backend/routes/conversations.ts:L14-L29 — Professional-plan gate for the whole inbox.
- backend/routes/whatsapp-business-webhook.ts — WA inbound webhook, signature verification, tenant resolution by
phoneNumberId. - backend/routes/instagram-dm-webhook.ts — IG inbound webhook; shape differs (
entry[].messaging[]). - backend/lib/whatsapp-business-api.ts — per-tenant WA send (looks up
WhatsAppUserCredentials, decrypts token). - backend/lib/instagram-business-api.ts — per-tenant IG send.
- backend/lib/instagram-dm-processor.ts — IG-specific message normalisation and order detection.
- backend/lib/message-parsing-service.ts — shared parser entrypoint used by both channels.
- src/app/dashboard/inbox/page.tsx — unified inbox UI (conversation list + thread view, polls).
- backend/prisma/schema.prisma:L1239-L1311 —
Conversation,Message,MessageTemplate.
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
WHATSAPP_APP_SECRET | yes | HMAC-verify Meta WA webhook payloads | All WA inbound rejected |
WHATSAPP_VERIFY_TOKEN | yes | Meta webhook GET verification challenge | Cannot register webhook |
INSTAGRAM_APP_SECRET | yes | HMAC-verify IG webhook payloads | All IG inbound rejected |
ENCRYPTION_KEY | yes | AES key for encrypted tokens in *UserCredentials | Tenant send/webhook lookup fails |
FRONTEND_URL | yes | CORS for inbox API | Inbox 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 arriving → Cause:
ConversationService.storeMessageearly-returns when the user lacks theWhatsApp APIfeature (conversation-service.ts:L39-L43). → Fix: upgrade plan, or checkcanUserAccessin 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-approvedMessageTemplateinstead — template sends are not gated by the window. - Instagram 7-day /
pages_messagingwindow → IG allows replies within 7 days of the last user interaction, and requiresInstagram Messaging+pages_messagingpermissions granted to the connected Page. Missing permission surfaces as a generic 400 from Meta. - Same customer, two conversations →
Conversationis unique on(userId, customerId, channel)by design. Do not try to merge WA and IG threads; theCustomerrow 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 —storeMessagedoes NOT dedup today. - Status stuck on
sent→ Delivery/read updates arrive on a separate status webhook and match byexternalId. 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.
getUnreadCountis 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):
- Add
ChannelUserCredentialsmodel + encrypted token columns, mirroringWhatsAppUserCredentials. - Add a webhook route under
backend/routes/that verifies the provider signature and resolvesuserIdfrom the incoming bot/page id. - Call
ConversationService.storeMessage({ channel: 'telegram', ... })— the service treatschannelas an opaque string, so no schema change is needed onConversation/Message. - Extend
sendAndStoreOutgoingMessagewith a new branch, or inject a channel-sender strategy. - Whitelist the channel in the inbox filter UI and in
ConversationFilters. - Decide on the channel's reply window and surface it in the composer so sellers know when templates are required.
10. Related docs
- whatsapp.md — WhatsApp Business specifics (templates, catalog, verification)
- instagram.md — Instagram DM specifics (permissions, shopping)
- ai-parser.md — how inbound text becomes a DraftOrder
- ../webhooks/webhooks-overview.md — signature verification, retry semantics
- ../../01-core/orders/draft-orders.md — draft-order lifecycle created from messages