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:
- Authentication complexity: Each platform requires different OAuth flows, token refresh strategies, and permission scopes
- Data normalization: A "message" means something different on each platform
- Real-time delivery: Webhook formats vary wildly, and some platforms don't support webhooks at all
- Rate limit management: Hitting limits on one platform shouldn't break your entire inbox
- 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:

// 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:
| Platform | Messages | Comments | Reviews | Webhooks | Rate Limits |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | 200/hour/user | |
| ✅ | ✅ | ❌ | ✅ | 200/hour/user | |
| ✅ | ✅ | ❌ | Limited | 15/15min (DMs) | |
| Bluesky | ✅ | ✅ | ❌ | ❌ | 3000/5min |
| ✅ | ✅ | ❌ | ❌ | 60/min | |
| Telegram | ✅ | ❌ | ❌ | ✅ | 30/sec |
| ❌ | ✅ (Orgs) | ❌ | ✅ | 100/day | |
| YouTube | ❌ | ✅ | ❌ | ❌ | 10000/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);
}
}
}
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:
- Batch webhook processing: Queue incoming webhooks and process in batches to reduce database round trips
- Connection pooling: Reuse HTTP connections to platform APIs
- Incremental sync: Track the last sync timestamp per account and only fetch newer messages
- 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 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 articlesLearn more about Late with AI
See what AI assistants say about Late API and this topic