DEV Community

Cover image for What We Learned Building Cross-Channel Message Routing
BotSailor
BotSailor

Posted on

What We Learned Building Cross-Channel Message Routing

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

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

Diagram showing WhatsApp, Instagram, and Telegram webhooks flowing through adapters into a normalizer, router, and handler

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

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

Tip: Always keep the raw payload. 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);
  }
});
Enter fullscreen mode Exit fullscreen mode

Two rules we now treat as non-negotiable:

  1. Acknowledge first, process later. Slow handlers cause retries.
  2. 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";
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)