Your users don't care which channel you support. They message you on WhatsApp at 9pm, email you at noon, and open a chat widget when they're frustrated at 2am. By the time they reach a human on your team, there's zero context — three separate threads, three different tools, zero shared history.
This is the multichannel inbox problem. And it's messier than it looks.
This article is about the architecture behind solving it properly — one unified inbox that aggregates WhatsApp, email, web chat, Instagram DMs, Telegram, and whatever comes next. We'll cover the data model, the real-time layer, normalization patterns, and how to avoid the failure modes that kill most implementations.
The Core Problem: N Channels × M Tools = Chaos
Most teams start like this:
WhatsApp → WhatsApp Business Manager
Email → Gmail / Outlook
Chat → Intercom / Crisp
Instagram → Meta Business Suite
Telegram → some bot nobody remembers building
The result is a context-switching nightmare. Your support agent has 5 tabs open. A customer who emailed yesterday and WhatsApp'd today is treated as two different people. Nobody has the full picture.
The solution is a channel normalization layer — a single abstraction that converts every incoming message, regardless of source, into a unified format your system can reason about.
The Data Model
Start here. Everything else depends on getting this right.
-- Contacts: one record per real human
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
name VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50), -- E.164 format: +31612345678
avatar_url TEXT,
custom_attrs JSONB DEFAULT '{}', -- flexible: shopify_id, plan, etc.
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Channel identities: one contact, many channel identifiers
CREATE TABLE contact_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
channel VARCHAR(50) NOT NULL, -- 'whatsapp' | 'email' | 'webchat' | 'instagram' | 'telegram'
identifier VARCHAR(255) NOT NULL, -- phone number, email, instagram user id, etc.
metadata JSONB DEFAULT '{}', -- channel-specific data
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel, identifier) -- one identity per channel
);
-- Conversations: a thread of messages
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
contact_id UUID NOT NULL REFERENCES contacts(id),
channel VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'open', -- 'open' | 'resolved' | 'snoozed'
assignee_id UUID REFERENCES agents(id),
inbox_id UUID NOT NULL REFERENCES inboxes(id),
ai_handled BOOLEAN DEFAULT false,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Messages: normalized across all channels
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
channel VARCHAR(50) NOT NULL,
direction VARCHAR(10) NOT NULL, -- 'inbound' | 'outbound'
sender_type VARCHAR(10) NOT NULL, -- 'contact' | 'agent' | 'bot'
sender_id UUID,
content_type VARCHAR(20) DEFAULT 'text', -- 'text' | 'image' | 'audio' | 'document' | 'template'
content TEXT,
attachments JSONB DEFAULT '[]',
external_id VARCHAR(255), -- channel's own message ID (for dedup)
status VARCHAR(20) DEFAULT 'sent', -- 'sent' | 'delivered' | 'read' | 'failed'
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel, external_id) -- prevent duplicate processing
);
-- Inboxes: logical groupings (e.g., "WhatsApp Sales", "Email Support")
CREATE TABLE inboxes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
channel VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
config JSONB NOT NULL, -- channel credentials, settings
created_at TIMESTAMPTZ DEFAULT NOW()
);
Key design decisions here:
contact_identities is separate from contacts — a single person might message you on WhatsApp AND email. Two identities, one contact record. Your identity resolution layer merges them.
external_id with UNIQUE constraint on messages — every channel gives messages their own IDs. Store them. Without this, webhook retries cause duplicate messages in your inbox.
content_type enum on messages — WhatsApp sends voice notes. Instagram sends reels. Email sends HTML. Normalize at ingestion, not at render time.
The Channel Normalization Layer
Every channel speaks a different language. Your job is to translate them all into one internal format before they touch your database.
// types/messaging.ts
interface NormalizedMessage {
externalId: string;
channel: Channel;
direction: 'inbound' | 'outbound';
contactIdentifier: string; // phone, email, user_id — depends on channel
contactName?: string;
contentType: ContentType;
content: string;
attachments: Attachment[];
timestamp: Date;
rawPayload: Record<string, unknown>; // always store the original
}
type Channel = 'whatsapp' | 'email' | 'webchat' | 'instagram' | 'telegram';
type ContentType = 'text' | 'image' | 'audio' | 'video' | 'document' | 'template';
interface Attachment {
type: ContentType;
url: string;
mimeType?: string;
fileName?: string;
size?: number;
}
Now implement a normalizer for each channel:
// normalizers/whatsapp.ts
export function normalizeWhatsAppMessage(payload: WhatsAppWebhookPayload): NormalizedMessage | null {
const entry = payload.entry?.[0];
const change = entry?.changes?.[0];
const message = change?.value?.messages?.[0];
if (!message) return null;
const contact = change?.value?.contacts?.[0];
return {
externalId: message.id,
channel: 'whatsapp',
direction: 'inbound',
contactIdentifier: message.from, // E.164 phone number
contactName: contact?.profile?.name,
contentType: normalizeWhatsAppContentType(message.type),
content: extractWhatsAppContent(message),
attachments: extractWhatsAppAttachments(message),
timestamp: new Date(parseInt(message.timestamp) * 1000),
rawPayload: payload
};
}
function normalizeWhatsAppContentType(type: string): ContentType {
const map: Record<string, ContentType> = {
text: 'text',
image: 'image',
audio: 'audio',
video: 'video',
document: 'document',
template: 'template'
};
return map[type] || 'text';
}
function extractWhatsAppContent(message: any): string {
switch (message.type) {
case 'text': return message.text?.body || '';
case 'image': return message.image?.caption || '[Image]';
case 'audio': return '[Voice message]';
case 'document': return message.document?.filename || '[Document]';
default: return '';
}
}
// normalizers/email.ts
import { ParsedMail } from 'mailparser';
export function normalizeEmailMessage(parsed: ParsedMail, rawEmail: string): NormalizedMessage {
return {
externalId: parsed.messageId || generateId(),
channel: 'email',
direction: 'inbound',
contactIdentifier: parsed.from?.value[0]?.address || '',
contactName: parsed.from?.value[0]?.name,
contentType: 'text',
content: parsed.text || stripHtml(parsed.html || ''),
attachments: (parsed.attachments || []).map(att => ({
type: 'document' as ContentType,
url: '', // upload to S3 first, store URL
mimeType: att.contentType,
fileName: att.filename,
size: att.size
})),
timestamp: parsed.date || new Date(),
rawPayload: { headers: parsed.headers, subject: parsed.subject }
};
}
// normalizers/telegram.ts
export function normalizeTelegramMessage(update: TelegramUpdate): NormalizedMessage | null {
const message = update.message || update.edited_message;
if (!message) return null;
return {
externalId: `${update.update_id}`,
channel: 'telegram',
direction: 'inbound',
contactIdentifier: `${message.from.id}`,
contactName: [message.from.first_name, message.from.last_name].filter(Boolean).join(' '),
contentType: message.photo ? 'image' : message.voice ? 'audio' : 'text',
content: message.text || message.caption || '',
attachments: [],
timestamp: new Date(message.date * 1000),
rawPayload: update
};
}
The Ingestion Pipeline
Webhook → normalize → deduplicate → resolve contact → route to conversation:
// services/MessageIngestionService.ts
export class MessageIngestionService {
constructor(
private db: Database,
private contactResolver: ContactResolutionService,
private conversationRouter: ConversationRoutingService,
private realtimePublisher: RealtimePublisher
) {}
async ingest(normalized: NormalizedMessage): Promise<void> {
// 1. Deduplicate
const exists = await this.db.messages.findOne({
channel: normalized.channel,
external_id: normalized.externalId
});
if (exists) {
console.log(`Duplicate message ${normalized.externalId} — skipping`);
return;
}
// 2. Resolve or create contact
const contact = await this.contactResolver.resolve({
channel: normalized.channel,
identifier: normalized.contactIdentifier,
name: normalized.contactName
});
// 3. Find or create conversation
const conversation = await this.conversationRouter.route({
contact,
channel: normalized.channel,
inboxId: await this.getInboxId(normalized.channel)
});
// 4. Persist the message
const message = await this.db.messages.create({
conversation_id: conversation.id,
channel: normalized.channel,
direction: normalized.direction,
sender_type: 'contact',
sender_id: contact.id,
content_type: normalized.contentType,
content: normalized.content,
attachments: normalized.attachments,
external_id: normalized.externalId,
created_at: normalized.timestamp
});
// 5. Update conversation timestamp
await this.db.conversations.update(conversation.id, {
updated_at: new Date()
});
// 6. Publish to real-time layer
await this.realtimePublisher.publish(`workspace:${conversation.workspace_id}`, {
type: 'message.created',
data: { message, conversation, contact }
});
// 7. Trigger AI or routing
await this.conversationRouter.handleNewMessage(conversation, message, contact);
}
}
Contact Resolution: Merging Identities
This is the hard part. The same human messages you on WhatsApp and then emails you. How do you know it's the same person?
// services/ContactResolutionService.ts
export class ContactResolutionService {
async resolve(params: {
channel: Channel;
identifier: string;
name?: string;
email?: string;
phone?: string;
}): Promise<Contact> {
// Step 1: Look up by channel identity (most reliable)
const identity = await this.db.contactIdentities.findOne({
channel: params.channel,
identifier: params.identifier
});
if (identity) {
return this.db.contacts.findById(identity.contact_id);
}
// Step 2: Try to match by email or phone across channels
let existingContact: Contact | null = null;
if (params.email) {
existingContact = await this.db.contacts.findOne({ email: params.email });
}
if (!existingContact && params.phone) {
existingContact = await this.db.contacts.findOne({
phone: normalizePhone(params.phone)
});
}
if (existingContact) {
// Link this channel identity to existing contact
await this.db.contactIdentities.create({
contact_id: existingContact.id,
channel: params.channel,
identifier: params.identifier
});
return existingContact;
}
// Step 3: Create new contact
const newContact = await this.db.contacts.create({
name: params.name,
email: params.email,
phone: params.phone ? normalizePhone(params.phone) : null
});
await this.db.contactIdentities.create({
contact_id: newContact.id,
channel: params.channel,
identifier: params.identifier
});
return newContact;
}
}
// Always normalize to E.164 before storing
function normalizePhone(phone: string): string {
const digits = phone.replace(/\D/g, '');
return digits.startsWith('+') ? phone : `+${digits}`;
}
Real-Time Layer: WebSockets for the Inbox UI
Your agents need live updates. New message arrives → UI updates instantly without polling.
// services/RealtimePublisher.ts
import { Server as SocketIOServer } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
export class RealtimePublisher {
private io: SocketIOServer;
constructor(httpServer: any) {
this.io = new SocketIOServer(httpServer, {
cors: { origin: process.env.FRONTEND_URL }
});
// Redis adapter for horizontal scaling
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
this.io.adapter(createAdapter(pubClient, subClient));
});
this.setupAuthMiddleware();
}
private setupAuthMiddleware() {
this.io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const agent = await verifyAgentToken(token);
if (!agent) return next(new Error('Unauthorized'));
socket.data.agent = agent;
socket.join(`workspace:${agent.workspace_id}`);
next();
});
}
async publish(room: string, event: { type: string; data: unknown }) {
this.io.to(room).emit(event.type, event.data);
}
}
Frontend (React) subscribes to the room:
// hooks/useRealtimeInbox.ts
import { useEffect, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useInboxStore } from '../stores/inboxStore';
let socket: Socket;
export function useRealtimeInbox(token: string) {
const { addMessage, updateConversation } = useInboxStore();
useEffect(() => {
socket = io(process.env.NEXT_PUBLIC_API_URL!, {
auth: { token }
});
socket.on('message.created', ({ message, conversation }) => {
addMessage(conversation.id, message);
updateConversation(conversation);
});
socket.on('conversation.assigned', ({ conversation }) => {
updateConversation(conversation);
});
socket.on('agent.typing', ({ conversationId, agentName }) => {
// Show typing indicator in UI
});
return () => { socket.disconnect(); };
}, [token]);
// Send typing indicator
const sendTyping = useCallback((conversationId: string) => {
socket.emit('typing', { conversationId });
}, []);
return { sendTyping };
}
Sending Replies: The Outbound Layer
The outbound side is simpler — you pick the right channel adapter and fire:
// services/OutboundMessageService.ts
interface SendMessageParams {
conversationId: string;
content: string;
contentType?: ContentType;
agentId: string;
}
export class OutboundMessageService {
private adapters: Map<Channel, ChannelAdapter>;
constructor() {
this.adapters = new Map([
['whatsapp', new WhatsAppAdapter()],
['email', new EmailAdapter()],
['telegram', new TelegramAdapter()],
['webchat', new WebChatAdapter()],
]);
}
async send(params: SendMessageParams): Promise<void> {
const conversation = await this.db.conversations.findById(params.conversationId);
const contact = await this.db.contacts.findById(conversation.contact_id);
const identity = await this.db.contactIdentities.findOne({
contact_id: contact.id,
channel: conversation.channel
});
const adapter = this.adapters.get(conversation.channel as Channel);
if (!adapter) throw new Error(`No adapter for channel: ${conversation.channel}`);
// Send via channel
const externalId = await adapter.send({
identifier: identity.identifier,
content: params.content,
contentType: params.contentType || 'text'
});
// Persist outbound message
const message = await this.db.messages.create({
conversation_id: params.conversationId,
channel: conversation.channel,
direction: 'outbound',
sender_type: 'agent',
sender_id: params.agentId,
content_type: params.contentType || 'text',
content: params.content,
external_id: externalId,
status: 'sent'
});
// Publish to real-time
await this.realtimePublisher.publish(`workspace:${conversation.workspace_id}`, {
type: 'message.created',
data: { message, conversation }
});
}
}
Channel adapter example for WhatsApp:
// adapters/WhatsAppAdapter.ts
export class WhatsAppAdapter implements ChannelAdapter {
private baseUrl = 'https://graph.facebook.com/v19.0';
async send(params: {
identifier: string; // E.164 phone
content: string;
contentType: ContentType;
}): Promise<string> {
const response = await fetch(
`${this.baseUrl}/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: params.identifier,
type: 'text',
text: { body: params.content }
})
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(`WhatsApp send failed: ${JSON.stringify(data.error)}`);
}
return data.messages[0].id; // external message ID
}
async handleDeliveryUpdate(payload: any): Promise<void> {
const status = payload.entry?.[0]?.changes?.[0]?.value?.statuses?.[0];
if (!status) return;
// Update message delivery status
await this.db.messages.update(
{ channel: 'whatsapp', external_id: status.id },
{ status: status.status } // 'delivered' | 'read' | 'failed'
);
}
}
Webhook Router: One Endpoint, Many Channels
Don't create a separate route for each channel. One router, channel-specific handlers:
// routes/webhooks.ts
router.post('/channels/:channel', async (req, res) => {
const { channel } = req.params;
// Always acknowledge fast
res.status(200).json({ status: 'received' });
try {
await webhookQueue.add('process', {
channel,
payload: req.body,
headers: req.headers
});
} catch (error) {
console.error('Failed to queue webhook:', error);
}
});
// Worker
const worker = new Worker('webhooks', async (job) => {
const { channel, payload, headers } = job.data;
// Verify signature per channel
await verifyChannelSignature(channel, payload, headers);
// Normalize
const normalizer = normalizers[channel];
const normalized = normalizer(payload);
if (!normalized) return; // status updates, read receipts, etc.
// Ingest
await ingestionService.ingest(normalized);
}, { connection, concurrency: 20 });
The Failure Modes to Avoid
1. Processing webhooks synchronously
Channels expect a 200 response in under 5 seconds. If your processing takes 6 seconds — WhatsApp retries. You get duplicates. Always queue, always acknowledge fast.
2. Not storing external_id
Every channel retries failed webhooks. Without deduplication on external_id, your agents see the same message 3 times. Add the UNIQUE constraint on day one.
3. Separate contact records per channel
If WhatsApp contact and email contact are stored separately, your agents have zero cross-channel context. The contact_identities pattern solves this — invest in the identity resolution logic early.
4. Polling instead of WebSockets
Polling every 2 seconds for new messages means 30 requests/minute per agent. With 10 agents: 300 requests/minute, all returning empty responses. WebSockets + Redis pub/sub scales to thousands of concurrent agents at a fraction of the cost.
5. Hardcoding channel logic in the inbox UI
If your frontend has if (channel === 'whatsapp') { ... } scattered everywhere, adding Instagram breaks 12 components. Normalize at the API layer. Your UI should only know about Message, never about WhatsAppMessage.
What This Looks Like in Production
Incoming WhatsApp message
│
▼
POST /webhooks/whatsapp
│
(200 immediately)
│
▼
BullMQ Queue
│
▼
Verify HMAC signature
│
▼
Normalize to NormalizedMessage
│
▼
Deduplicate (check external_id)
│
▼
Resolve Contact
(existing? merge identity. new? create.)
│
▼
Route to Conversation
(open thread? append. new? create.)
│
▼
Persist Message to PostgreSQL
│
▼
Publish to Redis → Socket.IO
│
▼
Agent inbox updates in real-time
│
▼
AI Bot evaluates → responds or escalates
Total latency from WhatsApp send to agent inbox update: under 800ms on a decent server.
Tools & Libraries Used
{
"dependencies": {
"socket.io": "^4.7.0",
"@socket.io/redis-adapter": "^8.3.0",
"bullmq": "^5.0.0",
"ioredis": "^5.3.0",
"mailparser": "^3.6.0",
"bottleneck": "^2.19.5"
}
}
If you don't want to build this from scratch, Oscar Chat implements this architecture out of the box — WhatsApp, Telegram, email, and web chat all in one inbox, with an AI layer on top. Worth looking at before you commit to building the entire normalization layer yourself.
Further Reading
- WhatsApp Cloud API docs
- Telegram Bot API
- Socket.IO with Redis adapter
- BullMQ documentation
- Oscar Chat API docs
Tags: javascript typescript architecture websockets node postgresql redis whatsapp
Top comments (0)