Voicemail drop sounds like a playback feature.
The agent hears a mailbox greeting, clicks a button, the system plays a prerecorded message and the agent moves to the next call. In a diagram, that is one arrow. In a real dialer, it is a handoff between a browser, a backend, FreeSWITCH, a carrier and a remote voicemail system — all of which can change state at nearly the same time.
The difficult part is not starting the audio file. The difficult part is deciding who owns the customer leg after the click, what happens when two terminal events arrive together and whether retrying the command is safe.
The button is a request, not the state change
Our first useful discovery was embarrassingly simple: a visible call-control button can still do nothing. The UI rendered Drop voicemail, but there was no complete action behind it. Wiring the button to an endpoint fixed the obvious gap, but it also exposed the real design problem.
At the moment the agent clicks, several things may already be happening:
- the remote mailbox may hang up;
- the agent may close the browser or lose connectivity;
- a previous request may be retried after a timeout;
- FreeSWITCH may emit
CHANNEL_HANGUPbeforeCHANNEL_HANGUP_COMPLETE; - the selected recording may have been deleted or replaced;
- the stored customer UUID may refer to a channel that no longer exists.
Treating the HTTP response as the source of truth does not make these events disappear. It only hides them until a call becomes stuck or a message plays twice.
The command therefore needs to mean: attempt to transition this call from agent-owned conversation to system-owned voicemail playback.
Make ownership explicit
Before voicemail drop, the active call has two important legs:
- the agent leg, connected to the browser softphone;
- the customer leg, connected to the carrier and remote destination.
During a normal conversation, ending either leg usually ends the bridge. Voicemail drop changes that rule. The agent leg must be released while the customer leg remains alive long enough to play the recording.
That is an ownership transfer. The agent no longer controls the customer channel; the backend and FreeSWITCH dialplan do.
A small lifecycle is enough to make that visible:
EXAMPLE
bridged -> vm_handoff
vm_handoff -> playing
playing -> completed
* -> terminated
The names are less important than the invariant: only one component may decide what happens next to the customer leg.
Claim the transition before sending commands
The endpoint should not begin with a FreeSWITCH command. It should begin by validating and claiming the transition.
Useful preconditions include:
- The call belongs to the current agent and is still active.
- The call is in a state where voicemail drop is allowed.
- An active customer leg exists.
- The selected recording exists and resolves to a runtime file.
- No voicemail handoff has already been accepted for this call.
The fifth condition is what makes retries safe. A double click, impatient refresh or reverse-proxy retry should observe the existing handoff instead of starting a second playback.
In database terms, this is a compare-and-set operation:
EXAMPLE
update calls
set state = 'vm_handoff'
where id = $1
and state = 'bridged';
If no row changes, the handler must inspect the current state. vm_handoff, playing or completed can be returned as an already accepted operation. A terminal state is a conflict. What matters is that every retry reaches the same result.
Keep the FreeSWITCH sequence narrow
In our flow, the handoff uses two FreeSWITCH API commands against the customer UUID:
EXAMPLE
uuid_setvar <customer_uuid> voicemail_drop_file <runtime_file_path>
uuid_transfer <customer_uuid> voicemail_drop XML default
The dialplan reads voicemail_drop_file, plays it and hangs up when playback finishes.
There are two details worth protecting.
First, resolve the recording before claiming success. A database recording ID is not enough; the runtime needs a file that FreeSWITCH can actually read.
Second, do not keep sending unrelated commands after the transfer. Once uuid_transfer is accepted, the dialplan owns the channel. Continuing to manipulate the same leg from the request handler creates exactly the split ownership we are trying to avoid.
Agent release is part of the transaction
The product promise is not merely that a recording starts. The promise is that the agent can move on without terminating the playback.
Releasing the agent therefore needs its own explicit state update:
- end or detach the agent leg;
- mark the agent ready for another call;
- preserve the customer leg as system-owned;
- stop showing the call as agent-controlled in the frontend;
- keep enough state to process playback and terminal events later.
This is where leg-aware event handling matters. An agent-leg NORMAL_CLEARING after the handoff is expected. It must not finalize the customer leg or overwrite the outcome. Conversely, a customer hangup during vm_handoff is terminal even if playback never began.
The first terminal event should not blindly win. The event handler needs the call state, the leg type and whether the handoff was already accepted.
A timeout does not mean failure
Consider this sequence:
- The backend sends
uuid_transfer. - FreeSWITCH accepts it.
- The HTTP connection times out before the response reaches the browser.
- The browser retries.
If the second request sends the transfer again, the result depends on timing. It may restart playback, fail against a moved channel or race with hangup.
If the transition was claimed first, the retry sees vm_handoff or playing and returns the existing result. The command becomes idempotent even though the network is not.
This same rule applies to event consumers. Duplicate CHANNEL_HANGUP and CHANNEL_HANGUP_COMPLETE events should converge on one terminal state. Event delivery can be at least once; state transitions must still be exactly once from the product’s point of view.
Be precise about what the logs prove
There is one subtle operational trap. A log sequence like this:
EXAMPLE
Transfer ... voicemail_drop@default
playback(...)
done playing file
proves that FreeSWITCH executed local playback. It does not prove that the remote voicemail service recorded the complete message.
The remote system may have disconnected, clipped the beginning, stopped recording after a carrier tone or treated the media differently from a human mailbox. Local success and remote delivery are related, but they are not the same fact.
That distinction changes monitoring language. A useful outcome model separates:
- handoff accepted;
- playback started;
- playback completed locally;
- customer leg ended;
- remote delivery confirmed, if an external signal exists.
Do not call the last one successful unless you can actually observe it.
The practical checklist
Before shipping voicemail drop, I now look for these properties:
- The UI action is wired end to end and shows pending/error state.
- The backend validates call ownership, customer-leg ownership and recording availability.
- The call state is claimed atomically before FreeSWITCH commands are sent.
- Repeated requests return the existing operation instead of replaying it.
- Agent and customer terminal events are handled differently.
- Agent release cannot terminate the system-owned playback leg.
- Logs record state transitions and command results, not only button clicks.
- Local playback is not presented as proof of remote recording.
Voicemail drop became reliable when we stopped treating it as an audio feature. It is a distributed ownership transfer with media attached. Once that is explicit, the races become ordinary state-transition problems — and ordinary problems are much easier to test.