Simple projects sometimes give us a piece of wisdom we’ve never thought about.
We had built a straightforward outbound dialer: an agent starts a call, talks to a live person, or drops a voicemail message and moves on. The system was event-driven and stateless. Every event had a handler. It worked — until the edge cases began multiplying.
The actual problem
Agent and remote hangups could happen together. Playback could start just before the media channel disappeared. Trunks sent duplicate BYEs. Frontends retried commands after network timeouts. None of these bugs was hard alone; together, they made the call impossible to reason about.
We weren’t managing calls. We were managing events — and those are two different things.
Make the call the source of truth
The fix was to give every call an explicit lifecycle and make every external event a request to transition that lifecycle. Duplicate or stale events stopped being special cases: the state machine could accept, reject or ignore them deterministically.
CALL_STATE.TS
idle → dialing dialing → bridged bridged → vm_handoff vm_handoff → playing playing → completed * → terminated
What changed
- Commands became idempotent.
- Invalid transitions failed in one predictable place.
- Late FreeSWITCH events could no longer resurrect a finished call.
- Logs described call history instead of isolated callbacks.
- Tests became sequences of transitions, not timing-dependent integration puzzles.
The state machine did not remove the messy reality of SIP, media and unreliable networks. It gave that mess a boundary — and made the dialer understandable again.