DEV Community

Kseniia Shevchenko
Kseniia Shevchenko

Posted on

Building a Unified Messaging Inbox: Multichannel Architecture From Scratch

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
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 '';
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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 }
  };
}
Enter fullscreen mode Exit fullscreen mode
// 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
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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


Tags: javascript typescript architecture websockets node postgresql redis whatsapp

Top comments (0)