The agent clicks Drop voicemail, a recording plays to the customer, and the agent starts the next call. That's the whole feature from the agent's point of view.

Underneath, two call legs have to stop sharing a lifecycle. The browser leg can end, but the customer leg must stay alive for playback. A hangup handler that was correct a moment ago can now cut off the message.

Our first problem was more basic: the button existed, but the action wasn't wired all the way through. Connecting it to the backend exposed the harder questions about retries, channel ownership and when to release the agent.

Make ownership explicit

During a conversation, the browser's agent leg is bridged to the customer leg. Ending either leg normally ends the conversation. After a voicemail handoff, the backend and FreeSWITCH dialplan take responsibility for the customer leg.

We represented the handoff explicitly:

EXAMPLE

bridged       -> vm_handoff
vm_handoff    -> playing
playing       -> completed
*             -> terminated

The practical rule is that the request handler, agent and dialplan must agree on who can control the customer channel. Once playback has been handed to the dialplan, the agent leaving must be an expected event.

Claim the transition before sending commands

At the time of the click, the mailbox may already have hung up. The stored UUID may refer to a dead channel. The recording may have been removed. The browser may be retrying a request whose response was lost.

So the endpoint first checks the current agent's ownership, the call state, the customer leg and the recording's runtime file. It then claims the handoff atomically. The state check has to be part of the write; two handlers can both pass an earlier read.

The core of that operation looks like this:

EXAMPLE

update calls
set state = 'vm_handoff'
where id = $1
  and state = 'bridged';

This shows only the state transition. Authorization and recording validation still belong in the surrounding handler.

If no row changes, inspect the current state. A retry for an existing vm_handoff, playing or completed handoff should report that operation. A call that ended before a handoff was accepted should return a conflict. Those cases need to remain distinguishable.

Claiming a state prevents two handlers from starting the same operation. It doesn't make a database write and a FreeSWITCH command one transaction. If the worker stops between them, the handoff needs reconciliation against the live channel. Otherwise it can remain pending forever.

Keep the FreeSWITCH sequence narrow

In our flow, the backend sends two 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 voicemail_drop dialplan reads the file path, plays the recording and hangs up when playback finishes.

Resolve the file before reporting acceptance: a recording ID in the database doesn't tell you whether FreeSWITCH can read its file. Check the command results too. A transition to vm_handoff records intent; it isn't evidence that the transfer succeeded.

After an accepted transfer, the dialplan owns playback. Additional commands from the HTTP handler can race with it, especially if that handler is still trying to clean up the bridge.

Releasing the agent without ending playback

Agent release touches the media and product state. The agent leg must end or detach, the agent must become available, and the frontend must stop offering controls for the customer leg. Meanwhile, the backend still needs to handle playback and terminal events for that customer channel.

An agent-leg NORMAL_CLEARING after handoff is expected. It must not finalize the customer leg. A customer hangup during vm_handoff, on the other hand, ends that attempt even if the recording never started.

Both CHANNEL_HANGUP and CHANNEL_HANGUP_COMPLETE can arrive, and both legs can produce terminal events. Handling them correctly takes the leg type and current call state. The first hangup received isn't necessarily the event that should determine the voicemail outcome.

The browser can time out after the transfer succeeds

Consider a backend that sends uuid_transfer, receives acceptance from FreeSWITCH and then loses the HTTP connection before the response reaches the browser.

The browser has a timeout. FreeSWITCH has a transferred channel. Sending the same command again might restart playback or race with its completion.

The retry should therefore look up the existing handoff and return its status. If execution is uncertain, reconcile it with the runtime state before deciding what to do next. A pending operation and a failed operation are different things, even when both started with a timeout.

Event processing needs the same care. Duplicate terminal events should leave one final outcome and release agent capacity once.

What a completed playback actually proves

This log sequence is useful:

EXAMPLE

Transfer ... voicemail_drop@default
playback(...)
done playing file

It tells us that FreeSWITCH executed local playback. It doesn't tell us that the remote mailbox recorded the entire message. The mailbox might have clipped the start, stopped recording, or disconnected.

I keep the observable stages separate: handoff accepted, playback started, playback completed locally, and customer leg ended. Remote delivery needs its own evidence if the product claims to confirm it.

Before shipping, I test double clicks, a lost HTTP response, missing files, a dead customer UUID, and hangups from each leg during handoff. I also check that the agent can actually start another call while the first message plays. That is the behavior the button promises.