Skip to main content

Layered Caller Verification for LiveKit SIP Calls

Identity verification for inbound SIP callers in LiveKit happens at three layers: the trunk, the dispatch rule, and the agent itself. Each layer gives you progressively more granular control, and in practice you'd combine them.

If you just want to know how to verify the caller from within the agent, skip to the Agent-driven verification section.

1. Trunk-level filtering (before LiveKit accepts the call)

When you create an inbound SIP trunk, you can answer two separate questions before LiveKit accepts the call: is the trunk itself trustworthy? and is the caller who they claim to be?.

Verifying the trunk

Many of the parameters you specify when creating the trunk verify the provider, not the caller. This prevents someone from bypassing your SIP carrier and injecting rogue INVITEs directly at your LiveKit endpoint. For example, LiveKit uses auth_username / auth_password to authenticate inbound INVITEs from your provider.

For a full list of supported parameters, see the docs for CreateSIPInboundTrunk. LiveKit offers several provider-specific quickstarts that detail any considerations particular to your SIP provider.

Verifying the caller

If you need to restrict access to a defined list of callers, you can specify allowed_numbers on your incoming SIP trunk. Calls from any other number are rejected at the trunk:

1
{
2
"trunk": {
3
"name": "My inbound trunk",
4
...
5
"allowedNumbers": ["+13105550100", "+17145550100"]
6
}
7
}

This is a coarse filter for expected callers, not strong proof of identity since the caller ID over the PSTN is trivially spoofable.

You can leave allowed_numbers empty to accept calls from any number.

2. Dispatch-rule-level controls (when routing into a room)

The dispatch rule decides which LiveKit room an accepted call joins, and it has its own configurable parameters.

PIN-protected rooms

If you need to restrict access to users based on a shared secret, you can prompt them to enter a PIN using their keypad (DTMF) before being allowed to join the room. Define a pin on the dispatch rule as follows:

1
{
2
"dispatch_rule": {
3
"name": "protected room rule",
4
"trunk_ids": [],
5
"rule": {
6
"dispatchRuleDirect": {
7
"roomName": "protected-room",
8
"pin": "12345"
9
}
10
}
11
}
12
}

Inbound numbers (inbound_numbers)

Similar to allowed_numbers at the trunk level, inbound_numbers matches against a defined list of caller numbers, allowing you to apply different policies per caller, such as routing them to a specific room, or dispatching a separate agent.

This is a useful feature if you expect calls from a defined set of numbers, but this parameter can be left blank to accept calls from any number. As stated previously, rules based on the caller's number should be treated as a routing hint rather than secure authentication.

1
{
2
"dispatch_rule": {
3
"name": "VIP dispatch rule",
4
"inbound_numbers": ["+13105550100", "+17145550100"],
5
"rule": {
6
"dispatchRuleIndividual": {
7
"roomPrefix": "vip-"
8
}
9
},
10
"roomConfig": {
11
"agents": [{ "agentName": "vip-agent" }]
12
}
13
}
14
}

3. Participant-level identification (inside the agent)

Once the call is accepted, it becomes a SIP participant in the room with kind == SIP and a bundle of auto-populated sip.* attributes the agent can read.

SIP phone number (sip.phoneNumber)

The caller's phone number (ANI) is the most common form of caller identification you can use to look up account info. As mentioned previously, the caller's number it should not be used on its own to securely identify the user. Also note that this attribute isn't available if hide_phone_number is set on the dispatch rule.

1
from livekit import rtc
2
3
@ctx.on("participant_connected")
4
async def on_participant(participant: rtc.RemoteParticipant):
5
if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
6
return
7
8
caller_number = participant.attributes.get("sip.phoneNumber")

Other SIP attributes

The full list of sip.* attributes is available from the SIP participant docs.

Some of the relevant attributes for caller identification are as follows:

AttributePurpose
sip.phoneNumberCaller's number, as detailed above
sip.trunkPhoneNumberNumber the caller dialed, which can be useful for multi-tenant routing
sip.trunkIDTrunk ID. Use this to distinguish an "authenticated-customer trunk" from the "public support trunk"
sip.ruleIDDispatch rule ID that matched

Custom SIP headers

Any X-* headers on the INVITE can be mapped to participant attributes via headers_to_attributes on the trunk. This is how you propagate things like an already-authenticated customer ID from an upstream PBX or IVR:

1
{
2
"trunk": {
3
"name": "Authenticated IVR trunk",
4
"numbers": ["+15105550100"],
5
"headers_to_attributes": {
6
"X-Customer-Id": "customer.id",
7
"X-Auth-Token": "customer.auth_token"
8
}
9
}
10
}

Then in the agent:

1
customer_id = participant.attributes.get("customer.id")
2
upstream_token = participant.attributes.get("customer.auth_token")
3
4
if customer_id and await verify_upstream_token(customer_id, upstream_token):
5
logger.info(f"Caller pre-authenticated by upstream IVR as {customer_id}")

Agent-driven verification

Finally, the agent can verify identity in the conversation itself: ask for an account number, date of birth, shared secret, or OTP and authenticate against your backend via tool calls. This is the only layer that doesn't rely on the signalling path, and the agent should call it before discussing anything account-sensitive:

1
# Verify identity based on knowledge only the user should know
2
@llm.function_tool
3
async def verify_caller_identity(
4
context: RunContext,
5
account_number: str,
6
passphrase: str,
7
date_of_birth: str,
8
) -> str:
9
"""Verify the caller's identity against their account record.
10
Call this before discussing any account-specific details."""
11
phone = context.session.participant.attributes.get("sip.phoneNumber")
12
13
ok = await crm.verify(
14
phone=phone,
15
account_number=account_number,
16
passphrase=passphrase,
17
date_of_birth=date_of_birth,
18
)
19
if ok:
20
context.session.userdata["verified"] = True
21
return "Identity verified. You may now discuss account details."
22
return "Verification failed. Do not share account-specific information."
1
# Verify identity based on SMS
2
@llm.function_tool
3
async def send_otp(context: RunContext) -> str:
4
"""Send a one-time code by SMS to the caller's number on file."""
5
phone = context.session.participant.attributes.get("sip.phoneNumber")
6
code = await otp_service.send(phone)
7
context.session.userdata["pending_otp"] = code
8
return f"A 6-digit code has been sent to {phone[-4:]}. Please read it back."

Use Tasks to verify the user's identity before handing off to another agent to handle sensitive requests.

Putting it together

Trunk and dispatch-rule controls decide who gets through and where the call lands. They're useful filters, but they lean on data the caller or upstream carrier can influence. sip.phoneNumber and custom header attributes inside the agent are good for a first-pass CRM lookup, but they carry the same caveat: none of them are proof of who is actually on the line.

Before your agent discusses anything sensitive, confirm the caller's identity in the conversation itself. Treat everything upstream of the agent as filtering and routing, and treat in-conversation verification as mandatory.