Inbound SIP — sip.h. header attributes intermittently missing when agent reads participant attributes*

Hi LiveKit team,

We’re running a self-hosted LiveKit setup (livekit-server v1.9.0, livekit-sip v1.4.0, livekit-agents Python v1.5.16) receiving inbound calls from Genesys over SIP. We depend on the X-User-To-User header from the INVITE, which we read from the sip.h.x-user-to-user participant attribute in our agent.

The problem: on some calls the sip.h.* attributes are there, and on others they’re completely missing — same trunk, same dispatch rule, same code. When they’re missing, sip.callStatus is missing too. All we see are the base attributes set at participant creation.

Failing call (read immediately after wait_for_participant() returns):

{'sip.trunkID': 'ST_xxxx', 'sip.callID': 'SCL_xxxx',
'sip.phoneNumber': '+61XXXXXXXXX', 'sip.ruleID': 'SDR_xxxx',
'sip.trunkPhoneNumber': '+61XXXXXXXXX'}

Successful call, identical extraction code:

{'sip.h.x-user-to-user': '', 'sip.h.contact': '...',
'sip.h.to': '...', 'sip.h.from': '...', 'sip.h.via': '...',
'sip.callStatus': 'ringing',
'sip.trunkID': 'ST_xxxx', 'sip.callID': 'SCL_xxxx',
'sip.phoneNumber': '+61XXXXXXXXX', ...}


It’s not load-related (no CPU/memory pressure) and it doesn’t look like Genesys dropping the header — if it were, we’d still see the other sip.h.* headers like To/From/Via, but the entire set is absent together along with sip.callStatus.

Our working theory is a race: the participant is created with the base attributes, and the sip.h.* headers plus sip.callStatus arrive in a follow-up attribute update. If the agent reads participant.attributes right after wait_for_participant() resolves, it sometimes wins the race and never sees the headers at extraction time.

Could you please help us out

adding @darryncampbell

Pretty sure in version 1.9.0 that those headers are parsed/passed async. If you upgrade to at least v1.9.1 I think it will be consistent.

Otherwise, you could try wait_for_attr:

async def wait_for_attr(room: rtc.Room, participant: rtc.RemoteParticipant,
                        key: str, timeout: float = 3.0) -> str | None:
    if key in participant.attributes:
        return participant.attributes[key]
    fut = asyncio.get_running_loop().create_future()

    def on_changed(changed: dict[str, str], p: rtc.Participant):
        if p.identity == participant.identity and key in p.attributes and not fut.done():
            fut.set_result(p.attributes[key])

    room.on("participant_attributes_changed", on_changed)
    try:
        return await asyncio.wait_for(fut, timeout)
    except asyncio.TimeoutError:
        return None
    finally:
        room.off("participant_attributes_changed", on_changed)