The problem
Agents lived in three tools. Voice arrived through the PSTN and VoIP stack, WhatsApp messaging through a separate service, and WhatsApp Calls were not a supported channel at all.
When a customer escalated from a chat to a call, the context stayed behind — and so did the history, and any hope of measuring the interaction end to end. Debugging a dropped conversation meant three dashboards and a guess. Meanwhile half the answers still lived inside the legacy PHP system we were actively trying to leave.
The approach
One interaction model, owned by one service, with channel adapters at the edge.
Whether a conversation arrived as a WhatsApp message, a WhatsApp Call or a PSTN leg, it became the same object with the same lifecycle — and the agent saw one thread. Signalling moved onto an event log so any consumer could replay a conversation from start to finish.
Because the migration off PHP was already underway, I could define the service patterns for the greenfield side rather than retrofit them a year later. That timing mattered more than any individual technical decision.
What shipped
Interaction Hub — a shared agent inbox on a NestJS microservice mesh, Redis for presence, Socket.io for live updates, behind a Next.js front end.
And the piece I am proudest of: the first IVR integration on WhatsApp in Asia. A caller navigates a menu inside WhatsApp and lands with the right agent, in the thread they already started. No callback, no context loss, no second channel.
Three junior engineers came through the project with me, and the event conventions we set there are still the ones new services follow.
What I would do differently
I would put the interaction event schema under contract tests on day one.
We got the model right and the versioning wrong, and paid for it twice during rollout. Schema discipline is cheap before the first consumer and expensive after the fifth.
interaction flow, simplified