Cross-Channel Message Routing for Chatbots: What We Learned Building It Across WhatsApp, Instagram, and Telegram
Every chatbot starts simple: one channel, one webhook, one reply. Then a customer asks, "Can you also answer on Instagram?" and suddenly you're maintaining three webhook formats, three rate-limit models, and three definitions of what a "message" even is.
At Botsailors, we hit this wall while building our omnichannel automation layer. This post is a behind-the-scenes look at how to route messages across WhatsApp, Instagram, and Telegram without turning your codebase into a pile of if (channel === ...) statements. You'll learn the architecture we settled on, the mistakes that shaped it, and code you can adapt.
Note: This is a build post, not a product pitch. Where we compare approaches, we include alternatives so you can pick what fits your stack.
Table of Contents
- Prerequisites
- What is cross-channel message routing?
- Lesson 1: Normalize before you route
- Lesson 2: Make every inbound event idempotent
- Lesson 3: Separate routing rules from channel adapters
- Lesson 4: Respect each channel's limits on the way out
- Lesson 5: Design human handoff early
- Build vs. buy: alternatives to consider
- Conclusion
Prerequisites
Before you follow along, you should have:
- [ ] Working knowledge of Node.js and TypeScript
- [ ] Basic familiarity with webhooks and REST APIs
- [ ] A developer account on at least one messaging platform (WhatsApp Business Platform, Meta for Instagram, or the Telegram Bot API)
- [ ] A queue or job runner (we use Redis-backed queues, but any works)
What is cross-channel message routing?
Cross-channel message routing is the layer that receives messages from many channels, decides what should handle each one (a bot flow, an AI agent, or a human), and sends the reply back through the correct channel in the correct format.
It sits between channel-specific webhooks and your business logic:
WhatsApp ─┐
Instagram ─┼─> Adapters ─> Normalizer ─> Router ─> Handler (bot / AI / human)
Telegram ─┘ │
Outbound queue <───┘
The goal: your business logic never knows or cares which channel a message came from, unless it explicitly needs to.
Lesson 1: Normalize before you route
Our first version routed on raw payloads. It worked for about two weeks.
Each channel describes the same event differently. A text message is messages[0].text.body on one platform and message.text on another. Routing on raw payloads means every new rule has to understand every payload.
The fix was a single internal message shape:
type Channel = "whatsapp" | "instagram" | "telegram";
interface NormalizedMessage {
id: string; // our internal ID
externalId: string; // the channel's message ID
channel: Channel;
contactId: string; // channel-specific user ID
conversationId: string;
type: "text" | "image" | "audio" | "location" | "button_reply";
text?: string;
mediaUrl?: string;
receivedAt: Date;
raw: unknown; // keep the original payload for debugging
}
Each channel gets a small adapter that converts into this shape:
function telegramAdapter(update: any): NormalizedMessage {
const m = update.message;
return {
id: crypto.randomUUID(),
externalId: String(m.message_id),
channel: "telegram",
contactId: String(m.from.id),
conversationId: `tg:${m.chat.id}`,
type: m.text ? "text" : "image",
text: m.text,
receivedAt: new Date(m.date * 1000),
raw: update,
};
}
Tip: Always keep the
rawpayload. When a customer says "your bot ignored my message," raw payloads are the fastest way to find out why.
Lesson 2: Make every inbound event idempotent
Webhook providers retry. If your server is slow or returns a non-200, the same message can arrive two or three times, and your bot happily replies to each one.
We deduplicate at the door using the channel's own message ID:
async function acceptOnce(msg: NormalizedMessage): Promise<boolean> {
const key = `seen:${msg.channel}:${msg.externalId}`;
// SET NX with a TTL: only the first writer wins
const result = await redis.set(key, "1", "EX", 60 * 60 * 24, "NX");
return result === "OK";
}
app.post("/webhooks/:channel", async (req, res) => {
res.sendStatus(200); // acknowledge fast, process async
const msg = adapters[req.params.channel](req.body);
if (await acceptOnce(msg)) {
await inboundQueue.add("route", msg);
}
});
Two rules we now treat as non-negotiable:
- Acknowledge first, process later. Slow handlers cause retries.
- Deduplicate on the external ID, not on message content. Customers legitimately send "ok" twice.
Lesson 3: Separate routing rules from channel adapters
Routing decisions (who handles this message?) should be pure functions of the normalized message plus conversation state. They should never import a channel SDK.
type Target = "flow" | "ai_agent" | "human";
interface ConversationState {
assignedTo?: "human" | "bot";
activeFlowId?: string;
lastIntent?: string;
}
function route(msg: NormalizedMessage, state: ConversationState): Target {
// 1. A human owns the conversation: bots stay quiet
if (state.assignedTo === "human") return "human";
// 2. A flow is mid-execution: continue it
if (state.activeFlowId) return "flow";
// 3. Explicit escalation keywords, on any channel
if (msg.text && /\b(agent|human|representative)\b/i.test(msg.text)) {
return "human";
}
// 4. Default: let the AI agent handle it
return "ai_agent";
}
Because route is pure, we can unit test it with plain objects, no mock webhooks required:
test("human-owned conversations never reach the bot", () => {
const msg = { text: "hello", channel: "instagram" } as NormalizedMessage;
expect(route(msg, { assignedTo: "human" })).toBe("human");
});
Lesson 4: Respect each channel's limits on the way out
Inbound is only half the problem. Outbound is where channels differ most:
| Concern | What varies by channel |
|---|---|
| Messaging windows | Some platforms restrict free-form replies after a time window and require approved templates |
| Rate limits | Different per-second and per-user throughput |
| Rich content | Buttons, carousels, and quick replies differ in shape and limits |
| Message length | Text limits vary |
Check each platform's current documentation for exact numbers, because they change. Our approach is an outbound adapter per channel plus a shared queue with per-channel rate limiting:
const limiters: Record<Channel, RateLimiter> = {
whatsapp: new RateLimiter({ perSecond: 20 }), // example values: verify against current docs
instagram: new RateLimiter({ perSecond: 10 }),
telegram: new RateLimiter({ perSecond: 30 }),
};
async function send(channel: Channel, contactId: string, reply: Reply) {
await limiters[channel].acquire();
const payload = outboundAdapters[channel](contactId, reply);
return channelClients[channel].post(payload);
}
Heads up: The numbers above are placeholders. Always confirm current limits in each platform's official docs before relying on them.
A shared Reply type (text, buttons, media) that each outbound adapter degrades gracefully is the key. If a channel can't render a carousel, fall back to a numbered text list rather than failing silently.
Lesson 5: Design human handoff early
We treated handoff as a "later" feature. That was a mistake, because handoff changes your routing model.
Once a human can take over a conversation, you need:
- A conversation owner field that the router checks first (see Lesson 3).
- A way to hand back to the bot when the human is done.
- Idle timeouts so a conversation doesn't stay "owned" by an agent who went home.
async function handoff(conversationId: string, to: "human" | "bot") {
await db.conversations.update(conversationId, {
assignedTo: to,
assignedAt: new Date(),
});
}
Adding ownership after the fact meant rewriting every handler. Adding it up front costs one column.
Build vs. buy: alternatives to consider
Building your own router is not always the right call. Depending on your team and requirements, you could consider:
- Open-source frameworks such as Botpress or Rasa if you want self-hosting and full control.
- Managed omnichannel platforms if you'd rather not maintain webhooks, templates, and compliance yourself.
- Direct platform APIs (as in this post) when you need custom routing logic or tight integration with your own backend.
Build your own if routing logic is core to your product. Otherwise, evaluate what already exists before writing adapters.
Conclusion
Cross-channel message routing for chatbots comes down to a few durable decisions:
- Normalize every inbound message into one internal shape.
- Deduplicate at the door and acknowledge webhooks fast.
- Keep routing pure and separate from channel SDKs.
- Treat outbound limits as first-class, with a per-channel queue and graceful degradation.
- Plan human handoff from day one.
None of this is exotic, but skipping any one of them creates bugs that only show up in production, usually on a Friday evening.
If you're building something similar, I'd love to hear how you handle routing across channels. What did you wish you'd known earlier? Share it in the comments.

Top comments (0)