Back to Blog

Building a Unified Social Media Inbox with APIs

Learn how to build a unified social media inbox that aggregates messages from Facebook, Instagram, Twitter, and more into a single API interface.

By

+8

Post everywhere. One API.

Try Free

Managing customer conversations across Facebook, Instagram, Twitter, Bluesky, Reddit, and Telegram requires constant context switching. A unified social media inbox solves this by aggregating all messages into a single interface, letting your team respond faster without jumping between platforms.

This guide walks through building a multi-platform inbox from scratch. You'll learn how to design the data model, normalize messages from different APIs, handle real-time webhooks, and implement cursor-based pagination that works across platforms. By the end, you'll have a production-ready architecture for aggregating social messages at scale.

The Challenge of Multi-Platform Messaging

Every social platform has its own messaging API with unique authentication flows, data structures, rate limits, and webhook formats. Here's what you're dealing with:

Facebook and Instagram use the Messenger Platform API with page-scoped user IDs. Messages arrive via webhooks, and you need separate page access tokens for each connected account.

Twitter/X provides the Direct Messages API, but access requires elevated API tiers. Message polling is the primary method since webhook support is limited.

Bluesky uses the AT Protocol with a decentralized architecture. Direct messages work differently than traditional platforms, requiring lexicon-based queries.

Reddit offers private messaging through their API, but rate limits are aggressive (60 requests per minute for OAuth clients).

Telegram provides the Bot API with long polling or webhooks. Each bot gets its own token, and messages include rich metadata about chat types.

The core challenges when building a social media inbox API include:

  1. Authentication complexity: Each platform requires different OAuth flows, token refresh strategies, and permission scopes
  2. Data normalization: A "message" means something different on each platform
  3. Real-time delivery: Webhook formats vary wildly, and some platforms don't support webhooks at all
  4. Rate limit management: Hitting limits on one platform shouldn't break your entire inbox
  5. Pagination inconsistency: Some use cursors, others use offsets, and timestamp-based pagination behaves differently everywhere

Architecture Overview for a Unified Social Media Inbox

A well-designed multi-platform inbox separates concerns into distinct layers:

Unified Inbox Architecture

// Architecture layers for a unified inbox system

interface InboxArchitecture {
  // Layer 1: Platform Adapters
  adapters: {
    facebook: FacebookAdapter;
    instagram: InstagramAdapter;
    twitter: TwitterAdapter;
    bluesky: BlueskyAdapter;
    reddit: RedditAdapter;
    telegram: TelegramAdapter;
  };
  
  // Layer 2: Normalization Engine
  normalizer: {
    transformMessage: (platformMessage: unknown, platform: string) => NormalizedMessage;
    transformConversation: (platformConvo: unknown, platform: string) => NormalizedConversation;
  };
  
  // Layer 3: Aggregation Service
  aggregator: {
    fetchAllConversations: (accounts: SocialAccount[]) => Promise<AggregatedResult<Conversation>>;
    fetchAllMessages: (conversationIds: string[]) => Promise<AggregatedResult<Message>>;
  };
  
  // Layer 4: Storage Layer
  storage: {
    conversations: ConversationRepository;
    messages: MessageRepository;
    accounts: AccountRepository;
  };
  
  // Layer 5: API Layer
  api: {
    getConversations: (filters: ConversationFilters) => Promise<PaginatedResponse<Conversation>>;
    getMessages: (conversationId: string, cursor?: string) => Promise<PaginatedResponse<Message>>;
    sendMessage: (conversationId: string, content: MessageContent) => Promise<Message>;
  };
}

The adapter pattern isolates platform-specific logic. When Instagram changes their API, you update one adapter without touching the rest of your codebase.

Supported Platforms and Their APIs

Not every platform supports every inbox feature. Here's a configuration-driven approach to managing platform capabilities:

// Platform support configuration for inbox features

export type InboxFeature = 'messages' | 'comments' | 'reviews';

export const INBOX_PLATFORMS = {
  messages: ['facebook', 'instagram', 'twitter', 'bluesky', 'reddit', 'telegram'] as const,
  comments: ['facebook', 'instagram', 'twitter', 'bluesky', 'threads', 'youtube', 'linkedin', 'reddit'] as const,
  reviews: ['facebook', 'googlebusiness'] as const,
} as const;

export type MessagesPlatform = (typeof INBOX_PLATFORMS.messages)[number];
export type CommentsPlatform = (typeof INBOX_PLATFORMS.comments)[number];
export type ReviewsPlatform = (typeof INBOX_PLATFORMS.reviews)[number];

// Check if a platform supports a specific feature
export function isPlatformSupported(platform: string, feature: InboxFeature): boolean {
  return (INBOX_PLATFORMS[feature] as readonly string[]).includes(platform);
}

// Validate and return helpful error messages
export function validatePlatformSupport(
  platform: string,
  feature: InboxFeature
): { valid: true } | { valid: false; error: string; supportedPlatforms: readonly string[] } {
  if (!isPlatformSupported(platform, feature)) {
    const featureLabel = feature === 'messages' ? 'direct messages' : feature;
    return {
      valid: false,
      error: `Platform '${platform}' does not support ${featureLabel}`,
      supportedPlatforms: INBOX_PLATFORMS[feature],
    };
  }
  return { valid: true };
}

Note: TikTok and Pinterest are notably absent from the messages and comments lists. Their APIs don't provide read access to user messages or comments, making them unsuitable for inbox aggregation.

Here's a comparison of API capabilities across platforms:

PlatformMessagesCommentsReviewsWebhooksRate Limits
Facebook200/hour/user
Instagram200/hour/user
TwitterLimited15/15min (DMs)
Bluesky3000/5min
Reddit60/min
Telegram30/sec
LinkedIn✅ (Orgs)100/day
YouTube10000/day

Data Model Design for Conversations and Messages

Your data model needs to handle the union of all platform features while maintaining clean relationships. Here's a MongoDB schema that works well:

// Conversation schema - represents a thread with a participant

import mongoose from "mongoose";

const conversationSchema = new mongoose.Schema(
  {
    userId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
      index: true,
    },
    accountId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "SocialAccount",
      required: true,
      index: true,
    },
    profileId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Profile",
      required: false,
    },
    platform: {
      type: String,
      enum: ["instagram", "facebook", "telegram", "twitter", "bluesky", "reddit"],
      required: true,
      index: true,
    },
    // Platform's native conversation identifier
    platformConversationId: {
      type: String,
      required: true,
      index: true,
    },
    status: {
      type: String,
      enum: ["active", "archived"],
      default: "active",
      index: true,
    },
    // Cached participant info for fast display
    participantName: String,
    participantUsername: String,
    participantPicture: String,
    participantId: String,
    // Preview data for inbox list view
    lastMessage: String,
    lastMessageAt: {
      type: Date,
      index: true,
    },
    // Platform-specific extras
    metadata: {
      type: Map,
      of: mongoose.Schema.Types.Mixed,
    },
  },
  { timestamps: true }
);

// Prevent duplicate conversations per account
conversationSchema.index(
  { accountId: 1, platformConversationId: 1 }, 
  { unique: true }
);

// Optimize inbox list queries
conversationSchema.index(
  { accountId: 1, status: 1, lastMessageAt: -1 }
);

The message schema captures the full range of content types:

// Message schema - individual messages within conversations

const messageSchema = new mongoose.Schema(
  {
    userId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
      index: true,
    },
    accountId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "SocialAccount",
      required: true,
      index: true,
    },
    conversationId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Conversation",
      required: true,
      index: true,
    },
    platform: {
      type: String,
      enum: ["instagram", "facebook", "telegram", "twitter", "bluesky", "reddit"],
      required: true,
      index: true,
    },
    platformMessageId: {
      type: String,
      required: true,
      index: true,
    },
    direction: {
      type: String,
      enum: ["incoming", "outgoing"],
      required: true,
      index: true,
    },
    senderId: {
      type: String,
      required: true,
    },
    senderName: String,
    senderPicture: String,
    text: String,
    attachments: [{
      type: {
        type: String,
        enum: ["image", "video", "audio", "file", "sticker", "share"],
      },
      url: String,
      payload: mongoose.Schema.Types.Mixed,
    }],
    // Instagram story interactions
    storyReply: {
      storyId: String,
      storyUrl: String,
    },
    isStoryMention: {
      type: Boolean,
      default: false,
    },
    platformTimestamp: {
      type: Date,
      required: true,
      index: true,
    },
    isRead: {
      type: Boolean,
      default: false,
    },
    autoResponseSent: {
      type: Boolean,
      default: false,
    },
    // Keep raw data for debugging
    rawPayload: mongoose.Schema.Types.Mixed,
  },
  { timestamps: true }
);

// Prevent duplicate messages
messageSchema.index(
  { accountId: 1, platformMessageId: 1 }, 
  { unique: true }
);

// Chronological message fetching
messageSchema.index(
  { conversationId: 1, platformTimestamp: -1 }
);

// Unread count queries
messageSchema.index(
  { conversationId: 1, direction: 1, isRead: 1 }
);

The direction field is critical. It distinguishes between messages your team sent (outgoing) and messages from customers (incoming). This powers features like unread counts and response time analytics.

Aggregation Strategy and Normalization

The aggregation layer fetches data from multiple accounts in parallel while gracefully handling failures. Here's the core implementation:

// Aggregation utilities for multi-account data fetching

export interface AggregationError {
  accountId: string;
  accountUsername?: string;
  platform: string;
  error: string;
  code?: string;
  retryAfter?: number;
}

export interface AggregatedResult<T> {
  items: T[];
  errors: AggregationError[];
}

export async function aggregateFromAccounts<T>(
  accounts: SocialAccount[],
  fetcher: (account: SocialAccount) => Promise<T[]>,
  options?: { timeout?: number }
): Promise<AggregatedResult<T>> {
  const timeout = options?.timeout || 10000;
  const results: T[] = [];
  const errors: AggregationError[] = [];

  const fetchPromises = accounts.map(async (account) => {
    try {
      const timeoutPromise = new Promise<never>((_, reject) => {
        setTimeout(() => reject(new Error('Request timeout')), timeout);
      });

      const items = await Promise.race([fetcher(account), timeoutPromise]);
      return { account, items, error: null };
    } catch (error: unknown) {
      const err = error as Error & { code?: string; retryAfter?: number };
      return {
        account,
        items: [] as T[],
        error: {
          accountId: account._id?.toString() || account.id,
          accountUsername: account.username,
          platform: account.platform,
          error: err.message || 'Unknown error',
          code: err.code,
          retryAfter: err.retryAfter,
        },
      };
    }
  });

  const settledResults = await Promise.all(fetchPromises);

  for (const result of settledResults) {
    if (result.error) {
      errors.push(result.error);
    } else {
      results.push(...result.items);
    }
  }

  return { items: results, errors };
}

This pattern ensures that a rate limit on Twitter doesn't prevent you from showing Facebook messages. The errors array lets your frontend display partial results with appropriate warnings.

For normalizing platform-specific data into your unified format:

// Message normalization from platform-specific formats

interface NormalizedMessage {
  platformMessageId: string;
  platform: string;
  direction: 'incoming' | 'outgoing';
  senderId: string;
  senderName?: string;
  text?: string;
  attachments: Attachment[];
  platformTimestamp: Date;
}

function normalizeInstagramMessage(
  raw: InstagramMessage, 
  accountId: string
): NormalizedMessage {
  const isOutgoing = raw.from.id === accountId;
  
  return {
    platformMessageId: raw.id,
    platform: 'instagram',
    direction: isOutgoing ? 'outgoing' : 'incoming',
    senderId: raw.from.id,
    senderName: raw.from.username,
    text: raw.message,
    attachments: (raw.attachments?.data || []).map(att => ({
      type: mapInstagramAttachmentType(att.type),
      url: att.url || att.payload?.url,
      payload: att.payload,
    })),
    platformTimestamp: new Date(raw.created_time),
  };
}

function normalizeTwitterMessage(
  raw: TwitterDM,
  accountId: string
): NormalizedMessage {
  const isOutgoing = raw.sender_id === accountId;
  
  return {
    platformMessageId: raw.id,
    platform: 'twitter',
    direction: isOutgoing ? 'outgoing' : 'incoming',
    senderId: raw.sender_id,
    senderName: raw.sender?.name,
    text: raw.text,
    attachments: (raw.attachments?.media_keys || []).map(key => ({
      type: 'image', // Twitter DM attachments are typically images
      url: raw.includes?.media?.find(m => m.media_key === key)?.url,
    })),
    platformTimestamp: new Date(raw.created_at),
  };
}

Real-time Updates with Webhooks

Webhooks eliminate the need for constant polling. Each platform has its own webhook format, so you need platform-specific handlers that feed into your unified processing pipeline.

// Webhook handler for Instagram messages

import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('x-hub-signature-256');
  
  // Verify webhook authenticity
  if (!verifyInstagramSignature(body, signature)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }
  
  const payload = JSON.parse(body);
  
  // Process each entry (can contain multiple updates)
  for (const entry of payload.entry) {
    for (const messaging of entry.messaging || []) {
      await processInstagramMessage(entry.id, messaging);
    }
  }
  
  return NextResponse.json({ success: true });
}

function verifyInstagramSignature(body: string, signature: string | null): boolean {
  if (!signature || !process.env.INSTAGRAM_APP_SECRET) {
    return false;
  }
  
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', process.env.INSTAGRAM_APP_SECRET)
    .update(body)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

async function processInstagramMessage(
  pageId: string, 
  messaging: InstagramMessagingEvent
) {
  // Find the connected account
  const account = await SocialAccount.findOne({
    platform: 'instagram',
    platformAccountId: pageId,
    isActive: true,
  });
  
  if (!account) {
    console.warn(`No active account found for Instagram page ${pageId}`);
    return;
  }
  
  // Find or create conversation
  const conversation = await findOrCreateConversation({
    accountId: account._id,
    platform: 'instagram',
    platformConversationId: messaging.sender.id, // For Instagram, sender ID is conversation ID
    participantId: messaging.sender.id,
  });
  
  // Normalize and store the message
  const normalizedMessage = normalizeInstagramWebhookMessage(messaging, account);
  
  await Message.findOneAndUpdate(
    { 
      accountId: account._id, 
      platformMessageId: normalizedMessage.platformMessageId 
    },
    {
      ...normalizedMessage,
      userId: account.userId,
      conversationId: conversation._id,
    },
    { upsert: true, new: true }
  );
  
  // Update conversation preview
  await Conversation.findByIdAndUpdate(conversation._id, {
    lastMessage: normalizedMessage.text?.substring(0, 100),
    lastMessageAt: normalizedMessage.platformTimestamp,
  });
}

Note: Always verify webhook signatures before processing. Without verification, attackers could inject fake messages into your system.

For platforms without webhook support (like Bluesky and Reddit), implement a polling service:

// Polling service for platforms without webhooks

class MessagePollingService {
  private intervals: Map<string, NodeJS.Timeout> = new Map();
  
  async startPolling(accountId: string, platform: string) {
    const pollInterval = this.getPollInterval(platform);
    
    const poll = async () => {
      try {
        const account = await SocialAccount.findById(accountId);
        if (!account || !account.isActive) {
          this.stopPolling(accountId);
          return;
        }
        
        const adapter = this.getAdapter(platform);
        const messages = await adapter.fetchNewMessages(account);
        
        for (const message of messages) {
          await this.processMessage(account, message);
        }
      } catch (error) {
        console.error(`Polling error for ${accountId}:`, error);
      }
    };
    
    // Initial poll
    await poll();
    
    // Schedule recurring polls
    const interval = setInterval(poll, pollInterval);
    this.intervals.set(accountId, interval);
  }
  
  private getPollInterval(platform: string): number {
    // Respect rate limits with conservative intervals
    const intervals: Record<string, number> = {
      bluesky: 30000,  // 30 seconds
      reddit: 60000,   // 60 seconds (strict rate limits)
      twitter: 60000,  // 60 seconds (limited DM access)
    };
    return intervals[platform] || 30000;
  }
  
  stopPolling(accountId: string) {
    const interval = this.intervals.get(accountId);
    if (interval) {
      clearInterval(interval);
      this.intervals.delete(accountId);
    }
  }
}

Build faster with Late

One API call to post everywhere. No OAuth headaches. No platform-specific code.

Free tier • No credit card • 99.97% uptime

Cursor-based Pagination Across Platforms

When you aggregate social messages from multiple accounts, traditional offset pagination breaks down. A new message arriving shifts all offsets. Cursor-based pagination solves this by using stable identifiers.

The challenge: each platform uses different cursor formats. Your unified API needs a cursor that encodes enough information to resume pagination across all sources.

// Cursor-based pagination for aggregated results

export interface PaginationInfo {
  hasMore: boolean;
  nextCursor: string | null;
  totalCount?: number;
}

// Cursor format: {timestamp}_{accountId}_{itemId}
// This allows stable pagination even when items are added

export function paginateWithCursor<T extends { id?: string; _id?: unknown }>(
  items: T[],
  cursor: string | null,
  limit: number,
  getTimestamp: (item: T) => string,
  getAccountId: (item: T) => string
): { items: T[]; pagination: PaginationInfo } {
  let filteredItems = items;

  // Apply cursor filter if provided
  if (cursor) {
    const [cursorTimestamp, cursorAccountId, cursorItemId] = cursor.split('_');
    const cursorTime = new Date(cursorTimestamp).getTime();

    filteredItems = items.filter((item) => {
      const itemTime = new Date(getTimestamp(item)).getTime();
      const itemAccountId = getAccountId(item);
      const itemId = item.id || String(item._id);

      // Items before the cursor (for descending order)
      if (itemTime < cursorTime) return true;
      if (itemTime === cursorTime) {
        if (itemAccountId > cursorAccountId) return true;
        if (itemAccountId === cursorAccountId && itemId > cursorItemId) return true;
      }
      return false;
    });
  }

  // Apply limit
  const paginatedItems = filteredItems.slice(0, limit);
  const hasMore = filteredItems.length > limit;

  // Generate next cursor from last item
  let nextCursor: string | null = null;
  if (hasMore && paginatedItems.length > 0) {
    const lastItem = paginatedItems[paginatedItems.length - 1];
    const timestamp = getTimestamp(lastItem);
    const accountId = getAccountId(lastItem);
    const itemId = lastItem.id || String(lastItem._id);
    nextCursor = `${timestamp}_${accountId}_${itemId}`;
  }

  return {
    items: paginatedItems,
    pagination: {
      hasMore,
      nextCursor,
    },
  };
}

Usage in your API endpoint:

// API endpoint using cursor pagination

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const cursor = searchParams.get('cursor');
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
  const status = searchParams.get('status') || 'active';
  
  // Get user's accounts
  const accounts = await getInboxAccounts(userId, 'messages');
  
  // Fetch conversations from all accounts
  const { items: conversations, errors } = await aggregateFromAccounts(
    accounts,
    async (account) => {
      return Conversation.find({
        accountId: account._id,
        status,
      })
        .sort({ lastMessageAt: -1 })
        .limit(limit * 2) // Fetch extra for merging
        .lean();
    }
  );
  
  // Sort all conversations by last message time
  const sorted = sortItems(
    conversations,
    'lastMessageAt',
    'desc',
    { lastMessageAt: (c) => c.lastMessageAt }
  );
  
  // Apply cursor pagination
  const { items: paginated, pagination } = paginateWithCursor(
    sorted,
    cursor,
    limit,
    (c) => c.lastMessageAt?.toISOString() || '',
    (c) => c.accountId.toString()
  );
  
  return NextResponse.json({
    conversations: paginated,
    pagination,
    meta: createAggregationMeta(accounts.length, errors),
  });
}

Handling Platform-Specific Features

Each platform has unique features that don't map cleanly to a unified model. Instagram has story replies and mentions. Twitter has quoted DMs. Telegram has reply chains and reactions.

The key is storing platform-specific data in a flexible metadata field while exposing common features through your normalized interface:

// Handling Instagram story replies

interface InstagramStoryReply {
  storyId: string;
  storyUrl: string;
  storyMediaType: 'image' | 'video';
  expiresAt: Date; // Stories expire after 24 hours
}

function normalizeInstagramWebhookMessage(
  messaging: InstagramMessagingEvent,
  account: SocialAccount
): Partial<Message> {
  const base = {
    platformMessageId: messaging.message.mid,
    platform: 'instagram',
    direction: messaging.sender.id === account.platformAccountId ? 'outgoing' : 'incoming',
    senderId: messaging.sender.id,
    platformTimestamp: new Date(messaging.timestamp),
  };
  
  // Handle story replies
  if (messaging.message.reply_to?.story) {
    return {
      ...base,
      text: messaging.message.text,
      storyReply: {
        storyId: messaging.message.reply_to.story.id,
        storyUrl: messaging.message.reply_to.story.url,
      },
      isStoryMention: false,
    };
  }
  
  // Handle story mentions
  if (messaging.message.attachments?.[0]?.type === 'story_mention') {
    return {
      ...base,
      isStoryMention: true,
      attachments: [{
        type: 'share',
        url: messaging.message.attachments[0].payload.url,
        payload: messaging.message.attachments[0].payload,
      }],
    };
  }
  
  // Standard message
  return {
    ...base,
    text: messaging.message.text,
    attachments: normalizeAttachments(messaging.message.attachments),
  };
}

For LinkedIn, you need special handling because inbox features only work with organization accounts:

// LinkedIn organization account validation

export function isLinkedInOrgAccount(
  metadata: Map<string, unknown> | Record<string, unknown> | null | undefined
): boolean {
  if (!metadata) return false;

  // Handle Mongoose Map type
  if (metadata instanceof Map) {
    return metadata.get('accountType') === 'organization' || 
           metadata.has('selectedOrganization');
  }

  // Handle plain object
  return metadata.accountType === 'organization' || 
         'selectedOrganization' in metadata;
}

// Filter accounts to only those supporting inbox features
export function filterAccountsForFeature(
  accounts: SocialAccount[],
  feature: InboxFeature
): SocialAccount[] {
  const supportedPlatforms = INBOX_PLATFORMS[feature];

  return accounts.filter((account) => {
    if (!supportedPlatforms.includes(account.platform)) {
      return false;
    }

    // LinkedIn requires organization account type
    if (account.platform === 'linkedin' && !isLinkedInOrgAccount(account.metadata)) {
      return false;
    }

    return true;
  });
}

Deduplication and Message Threading

When aggregating from multiple sources, duplicates can appear. A message might arrive via webhook and then again during a polling sync. Your system needs robust deduplication:

// Deduplication utilities

export function deduplicateItems<T>(
  items: T[],
  keyFn: (item: T) => string
): T[] {
  const seen = new Set<string>();
  return items.filter((item) => {
    const key = keyFn(item);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// Usage: deduplicate messages by platform ID
const uniqueMessages = deduplicateItems(
  allMessages,
  (msg) => `${msg.platform}_${msg.platformMessageId}`
);

For database-level deduplication, use upsert operations with unique indexes:

// Upsert pattern for webhook message processing

async function upsertMessage(
  accountId: string,
  normalizedMessage: NormalizedMessage
): Promise<Message> {
  return Message.findOneAndUpdate(
    { 
      accountId, 
      platformMessageId: normalizedMessage.platformMessageId 
    },
    {
      $set: normalizedMessage,
      $setOnInsert: {
        createdAt: new Date(),
      },
    },
    { 
      upsert: true, 
      new: true,
      // Return the document whether it was inserted or updated
      rawResult: false,
    }
  );
}

The unique index on { accountId: 1, platformMessageId: 1 } ensures MongoDB rejects true duplicates at the database level, even under concurrent webhook processing.

Performance Optimization and Caching

A multi-platform inbox makes many API calls. Without caching, you'll hit rate limits and create slow user experiences.

// Redis caching layer for conversation lists

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

interface CacheOptions {
  ttl: number; // seconds
  staleWhileRevalidate?: number;
}

async function getCachedConversations(
  userId: string,
  accountIds: string[],
  options: CacheOptions = { ttl: 60, staleWhileRevalidate: 300 }
): Promise<{ conversations: Conversation[]; fromCache: boolean }> {
  const cacheKey = `inbox:${userId}:conversations:${accountIds.sort().join(',')}`;
  
  // Try cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    const data = JSON.parse(cached);
    const age = Date.now() - data.timestamp;
    
    // Return cached data immediately
    if (age < options.ttl * 1000) {
      return { conversations: data.conversations, fromCache: true };
    }
    
    // Stale-while-revalidate: return stale data but trigger background refresh
    if (age < (options.staleWhileRevalidate || options.ttl) * 1000) {
      // Fire and forget background refresh
      refreshConversationsCache(userId, accountIds, cacheKey).catch(console.error);
      return { conversations: data.conversations, fromCache: true };
    }
  }
  
  // Cache miss or expired: fetch fresh data
  const conversations = await fetchConversationsFromDB(userId, accountIds);
  
  // Store in cache
  await redis.setex(
    cacheKey,
    options.staleWhileRevalidate || options.ttl,
    JSON.stringify({ conversations, timestamp: Date.now() })
  );
  
  return { conversations, fromCache: false };
}

async function refreshConversationsCache(
  userId: string,
  accountIds: string[],
  cacheKey: string
) {
  const conversations = await fetchConversationsFromDB(userId, accountIds);
  await redis.setex(
    cacheKey,
    300, // 5 minute TTL for background refresh
    JSON.stringify({ conversations, timestamp: Date.now() })
  );
}

Additional optimization strategies:

  1. Batch webhook processing: Queue incoming webhooks and process in batches to reduce database round trips
  2. Connection pooling: Reuse HTTP connections to platform APIs
  3. Incremental sync: Track the last sync timestamp per account and only fetch newer messages
  4. Denormalization: Store participant info directly on conversations to avoid joins

Building the Frontend Interface

Your frontend needs to handle the complexities of a multi-platform inbox while presenting a clean interface. Here's a React component pattern:

// React hook for unified inbox

import { useQuery, useInfiniteQuery } from '@tanstack/react-query';

interface UseInboxOptions {
  status?: 'active' | 'archived';
  platform?: string;
  accountId?: string;
}

export function useInbox(options: UseInboxOptions = {}) {
  return useInfiniteQuery({
    queryKey: ['inbox', 'conversations', options],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams();
      if (pageParam) params.set('cursor', pageParam);
      if (options.status) params.set('status', options.status);
      if (options.platform) params.set('platform', options.platform);
      if (options.accountId) params.set('accountId', options.accountId);
      
      const response = await fetch(`/api/inbox/conversations?${params}`);
      if (!response.ok) {
        throw new Error('Failed to fetch conversations');
      }
      return response.json();
    },
    getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
    initialPageParam: null as string | null,
  });
}

export function useConversationMessages(conversationId: string) {
  return useInfiniteQuery({
    queryKey: ['inbox', 'messages', conversationId],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams();
      if (pageParam) params.set('cursor', pageParam);
      
      const response = await fetch(
        `/api/inbox/conversations/${conversationId}/messages?${params}`
      );
      if (!response.ok) {
        throw new Error('Failed to fetch messages');
      }
      return response.json();
    },
    getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
    initialPageParam: null as string | null,
    enabled: !!conversationId,
  });
}

Display platform-specific features with conditional rendering:

// Message component with platform-specific rendering

function MessageBubble({ message }: { message: Message }) {
  const isOutgoing = message.direction === 'outgoing';
  
  return (
    <div className={`message ${isOutgoing ? 'outgoing' : 'incoming'}`}>
      {/* Story reply indicator */}
      {message.storyReply && (
        <div className="story-reply-preview">
          <span>Replied to your story</span>
          {message.storyReply.storyUrl && (
            <img 
              src={message.storyReply.storyUrl} 
              alt="Story" 
              className="story-thumbnail"
            />
          )}
        </div>
      )}
      
      {/* Story mention indicator */}
      {message.isStoryMention && (
        <div className="story-mention-badge">
          Mentioned you in their story
        </div>
      )}
      
      {/* Message text */}
      {message.text && (
        <p className="message-text">{message.text}</p>
      )}
      
      {/* Attachments */}
      {message.attachments?.map((attachment, i) => (
        <AttachmentPreview key={i} attachment={attachment} />
      ))}
      
      {/* Metadata */}
      <div className="message-meta">
        <PlatformIcon platform={message.platform} />
        <time>{formatTime(message.platformTimestamp)}</time>
      </div>
    </div>
  );
}

Using Late's Unified Inbox API

Building a unified social media inbox from scratch requires months of development time. You need to maintain integrations with multiple platforms, handle their API changes, manage OAuth flows, and deal with rate limits.

Late provides a pre-built social media inbox API that handles all of this complexity. Instead of integrating with six different platform APIs, you integrate with one.

Here's how Late simplifies the inbox integration:

// Using Late's unified inbox API

import { LateClient } from '@late/sdk';

const late = new LateClient({
  apiKey: process.env.LATE_API_KEY,
});

// Fetch conversations from all connected accounts
const { conversations, pagination, meta } = await late.inbox.getConversations({
  status: 'active',
  limit: 20,
});

// Get messages for a specific conversation
const { messages } = await late.inbox.getMessages(conversationId, {
  limit: 50,
});

// Send a reply (Late handles platform-specific formatting)
await late.inbox.sendMessage(conversationId, {
  text: 'Thanks for reaching out! How can I help?',
});

// Filter by platform if needed
const instagramOnly = await late.inbox.getConversations({
  platform: 'instagram',
});

Late's unified API provides:

  • Single OAuth flow: Connect accounts once, access all inbox features
  • Normalized data: Consistent message format across Facebook, Instagram, Twitter, Bluesky, Reddit, and Telegram
  • Real-time webhooks: One webhook endpoint for all platforms
  • Built-in pagination: Cursor-based pagination that works across accounts
  • Error handling: Graceful degradation when individual platforms have issues
  • Rate limit management: Automatic backoff and retry logic

The aggregation meta in Late's responses tells you exactly which accounts succeeded and which had issues:

// Late's response includes aggregation metadata

interface LateInboxResponse {
  conversations: Conversation[];
  pagination: {
    hasMore: boolean;
    nextCursor: string | null;
  };
  meta: {
    accountsQueried: number;
    accountsFailed: number;
    failedAccounts: Array<{
      accountId: string;
      platform: string;
      error: string;
      retryAfter?: number;
    }>;
    lastUpdated: string;
  };
}

This transparency lets you build UIs that inform users when specific accounts have temporary issues without breaking the entire inbox experience.

Check out Late's documentation to get started with the unified inbox API. You can have a working multi-platform inbox integrated in hours instead of months.

Miquel Palet - Author

Written by

Miquel Palet

Founder & CEO

Miquel is the founder of Late, building the most reliable social media API for developers. Previously built multiple startups and scaled APIs to millions of requests.

View all articles

Learn more about Late with AI

See what AI assistants say about Late API and this topic

One API. 13+ platforms.

Ship social media features in minutes, not weeks.

Built for developers. Loved by agencies. Trusted by 6,325 users.