How to do call transfers with Five9
Configure LiveKit Agents to transfer calls back to Five9 IVR using X-Headers in SIP BYE messages.
Last Updated:
Five9 does not support SIP REFER for call transfers. Instead, Five9 uses a supervised external transfer model where routing details are passed via custom X-Headers in the SIP BYE message. LiveKit now supports this pattern, enabling seamless transfers back to Five9 IVR flows.
How it works
The integration follows this flow:
- Five9 IVR initiates the call: Preconfigured Call Variables (CAVs) in Five9 VCC are converted to custom X-Headers in the SIP INVITE.
- LiveKit receives the call: Your agent processes the call and extracts context from the incoming X-Headers.
- Agent sends SIP BYE with X-Headers: When the agent completes its task, it ends the SIP leg with a BYE message containing X-Headers for routing information (intents, entities, parameters).
- Five9 receives the headers: X-Headers in the BYE are converted back to CAVs, allowing the original IVR flow to continue routing based on the returned data.
Loading diagram…
Configuring the Five9 side
Setting outbound X-Headers
In your Five9 IVR, use the Set Variable module to configure Call Variables that will be converted to X-Headers:
| Five9 Function | Example Value |
|---|---|
PUT(ToIVA, "X-CallANI", Call.ANI) | Caller's phone number |
PUT(ToIVA, "X-CallDNIS", Call.DNIS) | Called number |
PUT(ToIVA, "X-CallID", Call.call_id) | Five9 call identifier |
PUT(ToIVA, "X-CallSessionID", Call.session_id) | Session identifier |
PUT(ToIVA, "X-CallCampaign", Call.campaign_name) | Campaign name |
PUT(ToIVA, "X-CallStartTimestamp", Call.start_timestamp) | Call start time |
PUT(ToIVA, "X-CallDomainID", Call.domain_id) | Domain identifier |
Receiving return X-Headers
Five9 automatically converts X-Headers from the SIP BYE into Call Variables:
| Five9 Function | Purpose |
|---|---|
TOSTRING(FromIVA) | Full response string |
GET(FromIVA, "X-RouteReason") | Why the call is being returned |
GET(FromIVA, "X-RouteType") | Type of routing action |
GET(FromIVA, "X-RouteValue") | Routing destination or value |
GET(FromIVA, "X-MetaData") | Additional context or JSON data |
Configuring the LiveKit inbound trunk
The key to this integration is the attributes_to_headers trunk configuration. This maps LiveKit participant attributes to SIP X-Headers that are automatically included when sending BYE messages.
Step 1: Create an inbound trunk with attribute-to-header mapping
Configure your inbound trunk to map participant attributes to X-Headers:
{ "trunk": { "name": "Five9 Inbound", "numbers": ["+15551234567"], "headers_to_attributes": { "X-CallANI": "five9.call_ani", "X-CallDNIS": "five9.call_dnis", "X-CallID": "five9.call_id", "X-CallSessionID": "five9.session_id" }, "attributes_to_headers": { "five9.route_reason": "X-RouteReason", "five9.route_type": "X-RouteType", "five9.route_value": "X-RouteValue", "five9.metadata": "X-MetaData" } }}
Create the trunk using the LiveKit CLI:
lk sip inbound create five9-trunk.json
The attributes_to_headers mapping tells LiveKit: "When sending a SIP BYE, take the value of the five9.route_reason participant attribute and include it as the X-RouteReason header."
Configuring the LiveKit Agent
With the trunk configured, your agent sets participant attributes to define the routing values. When the call ends, LiveKit automatically includes these as X-Headers in the BYE message.
from livekit import apifrom livekit.agents import Agent, AgentSession, JobContext, get_job_context, function_tool, RunContextfrom livekit.rtc import ParticipantKind
class Five9Agent(Agent): def __init__(self): super().__init__( instructions="You are a helpful assistant. Determine the caller's intent and route them appropriately." )
@function_tool() async def route_and_end_call( self, ctx: RunContext, route_reason: str, route_type: str, route_value: str, metadata: str = "", ) -> str: """Set routing info and end the call, returning to Five9 IVR.""" job_ctx = get_job_context() # Find the SIP participant sip_participant = None for participant in job_ctx.room.remote_participants.values(): if participant.kind == ParticipantKind.PARTICIPANT_KIND_SIP: sip_participant = participant break if not sip_participant: return "No SIP participant found." # Set participant attributes that will be mapped to X-Headers in BYE await job_ctx.api.room.update_participant( api.UpdateParticipantRequest( room=job_ctx.room.name, identity=sip_participant.identity, attributes={ "five9.route_reason": route_reason, "five9.route_type": route_type, "five9.route_value": route_value, "five9.metadata": metadata, } ) ) # End the call - this sends BYE with X-Headers to Five9 await job_ctx.api.room.delete_room( api.DeleteRoomRequest(room=job_ctx.room.name) ) return f"Routing to {route_value} with reason: {route_reason}"
How it works
- Agent determines routing — Based on the conversation, your agent decides where to route the caller
- Set participant attributes — Use
update_participantto set attributes matching yourattributes_to_headersmapping - End the call — Deleting the room triggers a SIP BYE to Five9
- LiveKit includes X-Headers — The trunk config automatically maps attributes to X-Headers in the BYE
- Five9 receives routing info — Five9 converts X-Headers back to CAVs and continues the IVR flow
Example: Intent-based routing
A common pattern is to determine the caller's intent and route them to a specific skill or queue:
from livekit import apifrom livekit.agents import Agent, function_tool, RunContext, get_job_contextfrom livekit.rtc import ParticipantKindimport json
class IntentRoutingAgent(Agent): def __init__(self): super().__init__( instructions="""You are a helpful IVR assistant. Determine the caller's intent and route them to the appropriate department: sales, support, or billing.""" )
@function_tool() async def route_to_department( self, ctx: RunContext, department: str, reason: str, ) -> str: """Route the caller to a specific department in Five9.""" job_ctx = get_job_context() # Find the SIP participant sip_participant = None for participant in job_ctx.room.remote_participants.values(): if participant.kind == ParticipantKind.PARTICIPANT_KIND_SIP: sip_participant = participant break if not sip_participant: return "No SIP participant found." # Set routing attributes that will become X-Headers in BYE await job_ctx.api.room.update_participant( api.UpdateParticipantRequest( room=job_ctx.room.name, identity=sip_participant.identity, attributes={ "five9.route_reason": reason, "five9.route_type": "SKILL_TRANSFER", "five9.route_value": department, "five9.metadata": json.dumps({ "intent": "department_transfer", "confidence": 0.95, "entities": {"department": department} }), } ) ) # Say goodbye before ending await ctx.session.generate_reply( instructions=f"Tell the caller you're transferring them to {department}." ) # End the call - BYE with X-Headers goes to Five9 await job_ctx.api.room.delete_room( api.DeleteRoomRequest(room=job_ctx.room.name) ) return f"Routed to {department}"
Important notes
- Caller hangs up first: If the caller hangs up before the agent ends the call, Five9 will not receive the X-Headers. Your IVR flow should handle this case.
- Trunk configuration required: The
attributes_to_headersmapping must be configured on your inbound trunk for this to work. - Attribute naming: Use consistent attribute names between your trunk config and agent code.