Routing seven messaging channels into one queue: what actually decides the design
Most "omnichannel support" discussions are about which channels a product supports. The harder part is the data model behind them: what a conversation is when the same person writes on WhatsApp, then emails, then opens the chat widget.
Here are the four decisions that shaped ours, and the trade-offs behind them.
1. A conversation belongs to a customer, not to a channel
The intuitive model is one table per channel: whatsapp_messages\, email_threads\, chat_sessions\. It is easy to build and painful to live with. The moment a customer switches channel, the history splits, and the agent has to remember that "the person who emailed us" is also "the WhatsApp number from yesterday".
We inverted it: a conversation is an entity with a channel-agnostic identity, and each channel is an adapter that writes into it. Identity resolution (same phone, same email) happens on ingest, not on read.
The cost is a merge step: two channel identities can turn out to be one person after the fact. That is a normal, testable operation — much cheaper than reconciling three histories at query time.
2. Delivery state is per channel, not per message
Email has SENT\, DELIVERED\, BOUNCED\. WhatsApp has its own receipt semantics, LINE another, and the web widget has none of them — the "delivery" is just a successful HTTP response.
If you model one status enum for all channels, you end up with a lowest-common-denominator field that means nothing. We keep the provider's native state alongside a small normalised enum used only for UI decisions (is it safe to show "delivered"?).
3. Out-of-order ingestion is the default, not the exception
Webhooks retry. Mobile clients queue and flush. A conversation arriving from a laptop that was offline will not be in timestamp order.
Two things made this survivable: an idempotency key from the provider on every inbound event, and an ordering key (provider message id or a monotonic sequence) stored with the message, so the UI sorts on delivery rather than on arrival.
4. AI needs to be inside the conversation, not beside it
The tempting design is a separate AI service that reads a copy of the conversation and posts a suggestion. It demos well and fails the audit: the reply lives in a log nobody reads, and a human taking over cannot see what the assistant already told the customer.
We attach generated drafts to the conversation itself. Any agent can take over mid-thread, the draft is visible in place, and billing is per action rather than per seat — so cost tracks work rather than headcount.
What we did not build
No workflow builder. Three primitives (tags, priority, views) cover triage for a team of three to ten, and every branch-based builder we have seen at this size gets abandoned in a month. No plugin marketplace either — the seven channels we support are the ones small teams actually receive messages on.
The product doing this is Onsites AI — free for the first three seats, cloud or self-hosted. If you want the operational side rather than the architecture, the small-team support desk guide covers the decisions in order.