The dialer had a handler for every event. What it lacked was one place to decide whether that event still made sense for the call.

An agent would dial a customer, have a conversation or leave a recorded voicemail, then move on. The normal path worked. Trouble started when hangups, playback commands and browser retries overlapped.

The actual problem

The agent and customer could hang up at almost the same time. A playback command could arrive just as its channel disappeared. Trunks sent duplicate BYEs, and the frontend retried requests when it did not receive a response.

Each handler dealt with its own event, but the result depended on which handler ran first. The voicemail path made this especially clear: after handoff, the agent leg should end while the customer leg stays alive to hear the recording. A generic hangup handler could undo that handoff.

Make the call the source of truth

We gave each call a lifecycle and checked every command or event against its current state. A request to start voicemail playback was valid from a bridged conversation. The same request during an existing handoff referred to that operation. It could not start another playback.

The main path looked like this:

CALL_STATE.TS

idle         → dialing
dialing      → bridged
bridged      → vm_handoff
vm_handoff   → playing
playing      → completed
*            → terminated

The transition rules also needed the leg identity. An agent hangup during vm_handoff was expected; a customer hangup ended the attempt. Terminal calls stayed terminal when later FreeSWITCH events arrived.

The state machine did not make the database and FreeSWITCH one transaction. Commands could still fail or return an uncertain result. It gave us a place to record that situation and decide how to reconcile it.

What changed

Retries became safe to handle, invalid transitions failed in one place, and logs showed the call state before and after an event. We could read a call history and see why a callback had been ignored.

Tests became easier to express too: accept a handoff, receive an agent hangup, receive it again, then finish customer playback. Or end the customer leg before playback starts. We could check the result of each sequence without relying on callbacks arriving in a convenient order.

The useful part was being able to explain the next call that went wrong. We had its state, the event, the leg and the transition decision in one place. That made a much better starting point than another isolated callback log.