Skip to content

Call

The Call object drives a back-to-back user agent (B2BUA). Unlike the proxy Request, a Call owns both legs — it can dial, fork, bridge, rewrite either leg's URIs, and anchor media. It is passed to the @b2bua.* handlers.

from siphon import b2bua

@b2bua.on_invite
async def bridge(call):
    call.dial(call.ruri)

A B2BUA call with two legs (A-leg = caller, B-leg = callee).

The call object is the primary interface for B2BUA scripts. It tracks call state and provides methods to dial, fork, reject, and terminate.

Example::

@b2bua.on_invite
def new_call(call):
    contacts = registrar.lookup(call.ruri)
    if not contacts:
        call.reject(404, "Not Found")
        return
    call.fork([c.uri for c in contacts], strategy="parallel")

@b2bua.on_bye
def call_ended(call, initiator):
    log.info(f"Call ended by {initiator.side}-leg")
    call.terminate()

active_route property

active_route: Optional[Route]

The carrier :class:~siphon_sdk.lcr.Route that won an LCR sequence (call.route(...)), or None for a non-LCR call. Read in @b2bua.on_answer / on_bye to stamp the carrier onto a CDR.

route_attempts property

route_attempts: list[dict]

Every carrier attempt that FAILED before this call settled, oldest first — the counterpart to :attr:active_route, which names only the winner.

Each entry is a dict with carrier_id, status, elapsed_ms and dialed. Empty for a non-LCR call, and for an LCR call whose first carrier answered. Available wherever the Call is, so a call that answered after burning a carrier can still record which one it burned — siphon stamps the same list onto the CDR as lcr_attempts.

dialed is False when siphon never put an INVITE on the wire for that carrier — its gateway group was unknown or entirely down, or its destination would not resolve. status is then siphon's own verdict on the route, not the carrier's answer, so filter on it before counting a failure against a carrier: a local DNS or gateway problem is not the carrier's fault and does not belong in their quality figures.

Example::

@b2bua.on_answer
def answered(call, reply):
    for attempt in call.route_attempts:
        if not attempt["dialed"]:
            continue        # siphon never reached this carrier
        log.warn(f"carrier {attempt['carrier_id']} failed "
                 f"{attempt['status']} after {attempt['elapsed_ms']}ms")

failure_response property

failure_response: Optional[FailureResponse]

In @b2bua.on_failure: the dialled leg's own final response that the handler's code was derived from, read-only, or None.

code is what the caller is sent, which is not always what the far end said: a 503 reaches the caller as 500 (RFC 3261 §16.7 step 6), and an exhausted call.route() sequence or a fork ends on the best failure of all its legs. This is that leg's response as it arrived: status_code, reason, get_header(name), get_headers(name), body and content_type.

None when siphon concluded the leg itself: a ring timeout, a B-leg INVITE that was never sent, a sequence that ended on carriers it could not dial, or a refusal of the caller's own request. None outside @b2bua.on_failure.

Example (TS 24.292 §10.4.7, CS breakout)::

@b2bua.on_failure
def failed(call, code, reason):
    response = call.failure_response
    if response is None:
        return
    cause = response.get_header("Reason") or ""
    no_audio = (response.status_code == 488
                and b"m=audio" not in (response.body or b""))
    if no_audio or (response.status_code == 500
                    and "FAILURE_CAUSE;cause=2" in cause):
        call.dial(cs_breakout_uri(call))

id property

id: str

Unique call identifier (UUID).

state property

state: str

Call state: "calling", "ringing", "answered", "terminated".

source_ip property

source_ip: str

Source IP address of the A-leg caller.

flow property

flow: Optional[Flow]

The inbound flow this call's INVITE arrived on.

The B2BUA twin of :attr:Request.flow. None when the dispatcher had no transport binding to build one from (an internally-originated call, or a Call constructed in a test without flow=).

The point of it is RFC 5626 connection reuse: a :class:Contact saved at REGISTER time carries the flow the registration arrived on, so a call can be authorised by matching the two rather than by challenging every INVITE with a 407::

@b2bua.on_invite
def on_invite(call):
    bindings = registrar.lookup(str(call.from_uri))
    if any(c.flow == call.flow for c in bindings):
        call.dial(str(call.ruri))
    else:
        call.reject(403, "Forbidden")

On a stream transport (TCP/TLS/WS/WSS) that comparison is an exact match on one accepted socket — far stronger than a source-address check, which is worthless behind carrier NAT where every subscriber shares an address. On UDP there is no connection, so the flow carries no more assurance than the address does.

The match survives the UE reusing the connection across many calls: the connection id identifies the socket, not the transaction.

auth_user property writable

auth_user: Optional[str]

Username the A-leg authenticated as, or None if never challenged.

The B2BUA twin of request.auth_user. Set by auth.require_proxy_digest(call, realm) / auth.require_www_digest(call, realm) in @b2bua.on_invite once the caller answers the challenge correctly, and carried onto the call's CDR as auth_user.

Example::

@b2bua.on_invite
async def new_call(call):
    if not await auth.require_proxy_digest(call, realm="example.com"):
        return          # 407 armed; siphon answers the A-leg
    log.info(f"call from {call.auth_user}")
    call.dial(call.ruri)

Writable, like request.auth_user. A deployment whose authentication identity is not its subscriber identity reduces the credential to the identity it wants downstream, after the challenge has been answered::

@b2bua.on_invite
async def new_call(call):
    if not await auth.require_proxy_digest(call, realm="example.com"):
        return
    call.auth_user = normalise(call.auth_user)

Assigning it before the challenge is answered asserts an identity that was never proven, so set it only on the success path.

from_uri property

from_uri: Optional[SipUri]

From URI of the A-leg INVITE.

to_uri property

to_uri: Optional[SipUri]

To URI of the A-leg INVITE.

ruri property

ruri: Optional[SipUri]

Request-URI of the A-leg INVITE.

call_id property

call_id: Optional[str]

Call-ID header value.

body property

body: Optional[bytes]

SDP body content, or None.

local_tag property

local_tag: Optional[str]

The UAS To-tag siphon minted for this call's A-leg.

siphon owns this tag: it is generated with the A-leg dialog, so it is readable from the first handler onwards — before any response goes out — and it is the tag stamped on every response the framework sends, from the progress() 18x through the answer() 2xx and on into in-dialog requests. It does not change for the life of the dialog. None only when the call is no longer live.

Read it when something outside siphon has to agree with siphon about the dialog's identity — an external media controller keying an offer/answer on (call-id, from-tag, to-tag) needs the same to-tag siphon put on the wire, and minting its own there desynchronises the media answer from the dialog.

Example::

@b2bua.on_invite
def new_call(call):
    answer_sdp = media_control.answer(
        call_id=call.call_id,
        to_tag=call.local_tag,
        offer=call.body,
    )
    call.answer(200, "OK", answer_sdp, "application/sdp")

In tests, pass local_tag= to pin it, or local_tag=None to simulate reading it on a call that has already gone away.

media property

media: MediaHandle

Handle for media anchoring operations.

Example::

call.media.anchor(engine="rtpengine", profile="wss_to_rtp")
call.media.release()

refer_to property

refer_to: Optional[str]

Refer-To URI from an incoming REFER request.

Available in @b2bua.on_refer handlers. Returns the URI the remote party wants to transfer the call to, or None if no REFER is pending.

Example::

@b2bua.on_refer
def handle_refer(call):
    log.info(f"Transfer to {call.refer_to}")
    call.accept_refer()

refer_side property

refer_side: Optional[str]

Which leg sent the REFER: "a" (caller) or "b" (callee).

Available in @b2bua.on_refer handlers, matching the initiator.side convention in @b2bua.on_bye. None when no REFER is pending.

The party that survives the transfer is the peer of this one, which is what decides the media profile the surviving pair needs — see :meth:accept_refer. At a mixed edge (SRTP on one side, plain RTP on the other) the right profile differs depending on which side is leaving.

Example::

@b2bua.on_refer
def handle_refer(call):
    a_leg_is_secure = call.from_gateway("teams")
    referrer_is_secure = a_leg_is_secure == (call.refer_side == "a")
    # The secure party leaving leaves two plain-RTP ends behind.
    profile = "rtp_passthrough" if referrer_is_secure else "srtp_to_rtp"
    call.accept_refer(mode="terminate", profile=profile)

refer_replaces property

refer_replaces: Optional[dict]

Parsed Replaces parameter from the Refer-To header.

Returns a dict with four keys — call_id, from_tag, to_tag and early_only (a bool) — if the REFER includes a Replaces header (attended transfer, RFC 3891), or None for a blind transfer. early_only is True when the Replaces carried the early-only flag (match only a dialog still in the early state).

Example::

@b2bua.on_refer
def handle_refer(call):
    repl = call.refer_replaces
    if repl:
        log.info(
            f"Attended transfer, replaces {repl['call_id']} "
            f"(early_only={repl['early_only']})"
        )

refer_replaces_call property

refer_replaces_call: Optional[dict]

What siphon knows of the call the Replaces names, when this node holds it: the facts :meth:b2bua.adopt_replaced compares with the dialog the REFER came in.

A dict with call_id, leg ("caller" or "callee": which leg of that call the dialog is), source ("ip:port"), transport, from_uri (the party's own URI on the dialog) and auth_user (None on a leg siphon dialled). None outside @b2bua.on_refer, without a Replaces, or when no call here has the dialog. A script can refuse more than siphon does; it cannot accept what siphon refuses.

In the mock, read off the calls b2bua.hold_call(...) registered.

Example::

@b2bua.on_refer
async def on_refer(call):
    held = call.refer_replaces_call
    if held is not None and held["transport"] != "tls":
        call.reject_refer(403, "Forbidden")
        return
    call.accept_refer(mode="script")
    status = await b2bua.adopt_replaced(call, "room-1")
    b2bua.complete_refer(call.call_id, status)

conference property

conference: Optional[tuple]

The room this call is seated in, as (conference_id, owner_call_id), or None when it is in none.

owner_call_id is the SIP Call-ID of the conference dialog that adopted the call (b2bua.adopt_replaced), None for a call seated with rtpengine.conference_join / conference_answer. siphon reads it from its roster, so it is there in @b2bua.on_bye and every other handler for as long as the seat is, whatever a reload did to the script.

Example::

@b2bua.on_bye
def bye(call, initiator):
    if call.conference is not None:
        room, owner = call.conference

ro_authorizations property

ro_authorizations: list[dict]

Every ro_authorize call made on this call (for test assertions).

max_duration property

max_duration: Optional[int]

The cap this call carries on how long it may stay answered, in seconds, or None when it inherits b2bua.max_call_duration_secs.

Read-only view of what max_duration= / :meth:set_max_duration recorded, for test assertions.

charging_params property

charging_params: list[tuple[str, str]]

List of (name, value) tuples stashed via :meth:set_charging_param. Test helper.

actions property

actions: list[Action]

All actions recorded (test-only).

last_action property

last_action: Optional[Action]

Most recent action, or None.

from_gateway

from_gateway(group_name: str) -> bool

Check if the A-leg source IP is a member of a gateway group.

The B2BUA equivalent of request.from_gateway — returns True when the A-leg caller's source IP is one of the resolved addresses of the gateway group group_name (configured under gateway.groups in siphon.yaml, or via gateway.add_group). This is siphon's answer to Kamailio ds_is_from_list() / OpenSIPS ds_is_in_list() — a routing-direction / trust predicate that replaces hardcoded source CIDRs.

The match is on IP only (source port ignored) against every resolved address in the group, so a hostname that round-robins across many IPs matches on any of them.

Infallible: returns False (never raises) when the group does not exist, no gateway is configured, or the source IP does not parse.

Security: on connection-oriented transports (TCP/TLS/WS/WSS) the source IP is handshake-verified and trustworthy as an authorization signal; on UDP it is spoofable, so from_gateway there is a best-effort direction hint, not an auth gate.

Parameters:

Name Type Description Default
group_name str

Name of the gateway group to test membership against.

required

Returns:

Type Description
bool

True if the A-leg source IP belongs to the group.

Example::

@b2bua.on_invite
def on_invite(call):
    if call.from_gateway("teams"):
        # Inbound from that trunk — bridge to the internal side.
        call.dial("sip:pbx.internal:5060")
    else:
        call.reject(403, "Forbidden")

source_ip_in

source_ip_in(cidr_list: list[str]) -> bool

Check if the A-leg source IP is within any of the given CIDR ranges.

The B2BUA counterpart of :meth:Request.source_ip_in. Use it to gate on a peer's published source subnets directly, when that peer sources SIP from a whole range rather than only the IPs its signalling FQDNs resolve to — the case :meth:from_gateway (which tracks the destinations' DNS) cannot cover. Accepts IPv4 and IPv6 CIDRs.

Parameters:

Name Type Description Default
cidr_list list[str]

List of CIDR strings (e.g. ["203.0.113.0/24"]).

required

Returns:

Type Description
bool

True if the A-leg source IP falls within any range; False if

bool

the source IP does not parse.

Example::

if call.source_ip_in(["203.0.113.0/24", "2001:db8::/32"]):
    ...

ro_authorize async

ro_authorize(
    *,
    subscription_id: Optional[str] = None,
    subscription_id_type: Optional[str] = None,
    role_of_node: Optional[str] = None
) -> dict

Reserve prepaid credit (Ro CCR-INITIAL) BEFORE dialing the B-leg — the reserve-before-connect gate. Await it in @b2bua.on_invite and branch:

Example::

@b2bua.on_invite
async def on_invite(call):
    decision = await call.ro_authorize()
    if not decision["authorized"]:
        call.reject(402, "Payment Required")   # no B-leg dialed
        return
    call.dial("sip:bob@carrier")               # credit reserved

On a grant siphon opens the credit-control session, re-authorizes on the OCS cadence, disconnects mid-call on exhaustion, and sends CCR-TERMINATION on BYE. subscription_id overrides the charged identity (a sip: URI is typed as a SIP URI, never as an E.164 number); when omitted it comes from the ro.charge config. role_of_node sets the Role-Of-Node (TS 32.299 clause 7.2.177): "originating", "terminating" or "forwarding" (or "orig", "term", "orig-cdiv"); when omitted it is the INVITE's session case, and an unknown value raises ValueError. Returns {"authorized": bool, "result_code": int|None, "granted_time": int|None, "session_id": str|None}.

Tests can force the outcome with :meth:set_ro_authorize_result and assert on :attr:ro_authorizations.

set_ro_authorize_result

set_ro_authorize_result(
    authorized: bool,
    *,
    result_code: Optional[int] = None,
    granted_time: Optional[int] = None,
    session_id: Optional[str] = None
) -> None

Test hook — pin what the next :meth:ro_authorize returns (e.g. a 4012 denial so a script's call.reject(402) branch is exercised).

reject

reject(
    code: int,
    reason: str,
    headers: dict[str, str] | None = None,
) -> None

Reject the call with an error response.

Parameters:

Name Type Description Default
code int

SIP status code (e.g. 404, 486, 503).

required
reason str

Reason phrase.

required
headers dict[str, str] | None

Optional {name: value} mapping added to the response. The headers siphon owns on a response it sends itself (Via, From, To, Call-ID, CSeq, Contact, Record-Route, Route, Max-Forwards, Content-Length, Content-Type, RSeq, RAck, Require, Supported, Session-Expires, Min-SE) raise ValueError, as does a value with a line break in it.

None

Example::

call.reject(486, "Busy Here")
call.reject(480, "Temporarily Unavailable",
            headers={"Retry-After": "120"})

answer

answer(
    code: int,
    reason: str,
    body: Union[str, bytes, None] = None,
    content_type: str | None = None,
    headers: dict[str, str] | None = None,
    contact: str | None = None,
    contact_params: str | None = None,
) -> None

UAS-mode answer — send a final 2xx response to the inbound INVITE immediately, without bridging to a B-leg.

The response goes on the wire the moment this is called (not deferred to when the handler returns), so an async handler can answer and then keep working — e.g. play a prompt to completion before starting echo — without delaying the 200 OK. Synchronous; no await needed.

Answering the call itself makes siphon the caller's only UAS, so a caller that Require-s an extension siphon does not implement (any but 100rel, timer, replaces, sec-agree) is refused 420 Bad Extension with Unsupported instead, whatever the header policy, and the call ends (RFC 3261 §8.2.2.3).

When a reliable provisional that carried SDP (a progress() 183 to a caller that requires 100rel) has not been PRACKed yet, the 2xx waits for the caller's PRACK and goes out right after siphon's 200 for it (RFC 3262 §3).

Parameters:

Name Type Description Default
code int

Final 2xx status code (200, 202, etc.).

required
reason str

Reason phrase.

required
body Union[str, bytes, None]

Optional response body (bytes or str) — typically SDP.

None
content_type str | None

Content-Type for the body (e.g. "application/sdp").

None
headers dict[str, str] | None

Optional {name: value} mapping added to the 2xx. The headers siphon owns raise ValueError (see reject()).

None
contact str | None

Optional SIP or SIPS URI siphon advertises as its Contact on this dialog instead of its own address: on this 2xx and on every later request and response it sends the caller in the dialog (RFC 3261 §12.1.1, the caller's remote target). A conference focus puts the conference URI here (RFC 4579 §5.1), which is where a caller that INVITEd the factory URI learns it (§5.2). The URI must reach siphon: the caller's in-dialog requests are sent to it, through the dialog's route set.

None
contact_params str | None

Optional Contact header parameters for contact, e.g. "isfocus" (RFC 3840 feature parameters).

None

Example::

@b2bua.on_invite
async def on_invite(call):
    await rtpengine.offer(call, profile="ivr")
    call.answer(200, "OK", body=call.body, content_type="application/sdp")
    await rtpengine.play_media(call, file=prompt)   # 200 already sent
    await rtpengine.echo(call)

# A conference focus answering its factory URI:
call.answer(200, "OK", body=sdp, content_type="application/sdp",
            contact=f"sip:{room}@conf.example.com",
            contact_params="isfocus")

progress

progress(
    code: int,
    reason: str = "Ringing",
    body: Union[str, bytes, None] = None,
    content_type: str | None = None,
    headers: dict[str, str] | None = None,
    contact: str | None = None,
    contact_params: str | None = None,
    anchor: bool = False,
    profile: str | None = None,
    ws_uri: str | None = None,
) -> None

UAS-mode provisional — send a 1xx response to the inbound INVITE immediately (e.g. 183 Session Progress with early-media SDP, or 180 Ringing). Does not answer the call: the handler must still answer() / dial() / reject() for a final response.

To a caller that sent Require: 100rel a 101-199 goes out reliably, and so does one carrying SDP to a caller that only sent Supported: 100rel (RFC 3262 §3): siphon adds Require: 100rel and its own RSeq, retransmits it until the caller's PRACK, answers that PRACK itself, and sends a later provisional only after it.

Parameters:

Name Type Description Default
code int

Provisional status code (must be 1xx; 100 carries no To-tag).

required
reason str

Reason phrase.

'Ringing'
body Union[str, bytes, None]

Optional response body (bytes or str) — early-media SDP.

None
content_type str | None

Content-Type for the body (e.g. "application/sdp").

None
headers dict[str, str] | None

Optional {name: value} mapping added to the 1xx, e.g. {"P-Early-Media": "sendrecv"} (RFC 5009) on an announcement's 183. The headers siphon owns raise ValueError (see reject()): Require and RSeq stay siphon's.

None
contact str | None

Optional SIP or SIPS URI siphon advertises as its Contact on the early dialog and, unless answer() names another, on the dialog after it (see answer()).

None
contact_params str | None

Optional Contact header parameters for contact.

None
anchor bool

Answer the caller's offer on the media engine (answer_local) and send the engine's answer as the body, so rtpengine.play_media plays into it and a later dial() connects the callee behind it. Takes no body.

False
profile str | None

Media profile for anchor=True.

None
ws_uri str | None

WebSocket bridge URI for anchor=True.

None

A dial() or sequential route() after the engine's own early answer offers the callee a far leg the engine binds behind it, and every later response repeats the caller's answer unchanged. A parallel fork() there, and a dial() after an early answer that is not the engine's, raise ValueError in siphon.

Example::

call.progress(183, "Session Progress", body=sdp, content_type="application/sdp")
call.progress(183, "Session Progress", body=sdp,
              content_type="application/sdp",
              headers={"P-Early-Media": "sendrecv"})

handover

handover(
    app: str,
    on_lost: str | None = None,
    deadline_ms: int | None = None,
    vars: dict[str, str] | None = None,
    answer: bool = False,
    profile: str | None = None,
    ws_uri: str | None = None,
) -> None

Hand this call over to an out-of-process control application (the ARI Stasis model). siphon holds the INVITE transaction un-dialed, sends a keep-alive 180, registers the call with the control plane, and emits a StasisStart (with the full SIP context) to the owning connection. The external app then drives the call (answer / play / dtmf / bridge / hangup) over the control WebSocket.

A handoff deadline protects against an absent/slow controller: if no controller accepts and acts in time, a default action fires (503 by default), so a dead controller degrades instead of hanging calls.

Parameters:

Name Type Description Default
app str

The control app name (must be configured under control.apps).

required
on_lost str | None

What to do if the owning connection is lost mid-call — "hangup" (end the call, the default) or "continue" (leave it running without an owner).

None
deadline_ms int | None

Handoff deadline in milliseconds; None uses control.limits.handoff_deadline_ms.

None
vars dict[str, str] | None

Per-call variables seeded into the control channel, readable + writable by the app via get_var / set_var.

None
answer bool

Answer-first (AI-park) mode. When True, siphon answers (200 OK) and anchors media to the voice_ai bridge before handing over, so the controller drives an already-connected channel (answering commits the call; declining is a BYE, not a 4xx). When False (default), the call is parked un-answered and the controller decides how to respond.

False
profile str | None

Answer-first only — the media profile to anchor with (default "voice_ai").

None
ws_uri str | None

Answer-first only — the per-call WebSocket bridge URI the media engine dials out for this leg's audio (computed per session / tenant). Supports {call_id} / {from_tag} / {from_user} / {to_user} templating; falls back to the profile's own ws_uri when omitted.

None

Raises:

Type Description
ValueError

if app is empty, on_lost is not one of "hangup" / "continue", or profile / ws_uri are passed without answer=True.

Example::

@b2bua.on_invite
async def route(call):
    if is_ai_number(call.to_uri):
        call.handover("ai-app", answer=True,
                      ws_uri="wss://ai.example/stream/{call_id}")
    elif is_ivr_number(call.to_uri):
        call.handover("ivr-app", on_lost="hangup", deadline_ms=3000,
                      vars={"queue": "support"})
    else:
        call.dial(call.ruri)

dial

dial(
    uri: str,
    timeout: int = 30,
    max_duration: Optional[int] = None,
    next_hop: Optional[str] = None,
    flow: Optional["Flow"] = None,
    header_policy: Optional[str] = None,
    copy: Optional[list[str]] = None,
    strip: Optional[list[str]] = None,
    translate: Optional[list[tuple[str, str]]] = None,
    route: Optional[list[str]] = None,
    send_socket: Optional[str] = None,
    auth_passthrough: bool = False,
    number_policy: Optional[str] = None,
    format: Optional[str] = None,
) -> None

Dial a single B-leg target.

Parameters:

Name Type Description Default
uri str

Destination URI — drives the B-leg R-URI. A SIP URI also gives the B-leg To its host[:port]. A tel: URI (RFC 3966), or one in any other scheme, has no host: it goes out as the R-URI as written, needs next_hop=, route= or flow= to be sent anywhere, and leaves the To as the caller wrote it (pin its host with :meth:set_to_host).

required
timeout int

How long the B-leg may ring before siphon gives up, in seconds. On expiry siphon CANCELs, fires @b2bua.on_failure and answers the caller 408.

30
max_duration Optional[int]

How long the call may stay answered, in seconds. A different clock from timeout: it starts at the answer, so a call that rang for 25 seconds still gets its full talk time. On expiry siphon BYEs both legs through the ordinary teardown (CDR with disconnect_initiator="timeout", Rf/Ro ACR-STOP, media released) with Reason: Q.850;cause=102; no Python handler fires, the same as for a session-timer expiry. None (the default) inherits b2bua.max_call_duration_secs; 0 opts this call out of that ceiling.

None
next_hop Optional[str]

Optional routing destination. When set, the new INVITE's R-URI is still built from uri (so the called party / IMPU shape is preserved), but the message is sent to next_hop. Mirrors proxy.send_request(next_hop=...).

None
flow Optional['Flow']

Captured inbound :class:Flow (typically contact.flow from registrar.lookup()). When set, the B-leg INVITE is sent over that connection — RFC 5626 §5.3 connection reuse, mandatory for a WebSocket callee (RFC 7118 §5) whose Contact URI is unresolvable. Bypasses DNS resolution of uri/next_hop; guard on contact.is_local first. A callee's flow yields to a route= set, which is then its Path (RFC 3327 §5.3). A registration.flow(impu) is the UE's own SA to the P-CSCF instead: the INVITE always leaves over it, and route= goes on the wire as Route headers (TS 24.229 §5.1.2A.1.1). With listen.mtu set, an INVITE over that SA that is over the threshold (RFC 3261 §18.1.1) leaves over TCP from the UE's protected client port to the P-CSCF's protected server port, with a TCP Via, and falls back to UDP when the connection cannot be opened. The leg stays on TCP for its CANCEL and in-dialog requests. A callee's flow and a flow already on a stream transport keep their transport.

None
header_policy Optional[str]

Qualified policy name selecting which header policy the framework applies when building the B-leg INVITE and forwarding responses back to the A-leg. Defaults to b2bua.default_header_policy from siphon.yaml (which itself defaults to "transparent-b2bua@2026"). Built-in presets: "transparent-b2bua@2026" (today's behaviour), "ims-intra-trust-domain@2026" (intra-trust IMS, passes P-* and end-to-end preconditions), "ims-trust-domain-boundary@2026" (BGCF/IBCF/P-CSCF edge, strict trust-boundary hygiene), "sip-trunk-edge@2026" (plain SIP trunk). An operator-defined policy from the header_policies: block of siphon.yaml is named the same way — one namespace, so a custom policy is indistinguishable from a built-in here. Reach for one of those when the posture is "that preset, except for these headers", rather than repeating copy=[…] on every call site. Whatever the policy, the B-leg INVITE's Supported and Allow are siphon's own, because siphon is that leg's UAC: Allow is siphon's method set, and Supported is replaces plus the caller's 100rel / timer, with the caller's precondition / histinfo / resource-priority only when the policy copies what that extension negotiates with in both directions (Supported + Require, History-Info, Resource-Priority out and Accept-Resource-Priority back). A value set with :meth:set_header goes out instead. Responses relayed back to the caller get the same treatment with the callee's tags: siphon's Allow, and Supported narrowed the same way plus replaces.

None
copy Optional[list[str]]

Per-call delta — headers to copy verbatim regardless of the preset's default verb (e.g. ["X-Operator-Tag"]).

None
strip Optional[list[str]]

Per-call delta — headers to strip regardless of the preset's default verb (e.g. ["History-Info"]).

None
translate Optional[list[tuple[str, str]]]

Per-call delta — [(header_name, op_name), …] pairs. op_name is one of: "rfc7044" / "diversion-to-history-info" (translate Diversion per RFC 7044). Unknown ops are logged and dropped.

None
route Optional[list[str]]

Route header set prepended to the B-leg INVITE after the A-leg Route/Record-Route are stripped. Carries the captured IMS Service-Route on MO calls so the request traverses the originating S-CSCF (RFC 3608). A UE's route set is the P-CSCF's protected URI followed by the Service-Route (TS 24.229 §5.1.2A.1.1): [f"<sip:{flow.remote_addr};lr>"] + registration.service_route(impu). With a registration.flow() the route set rides the flow; with a callee's flow it replaces the flow (see flow). Each entry goes out as one name-addr (RFC 3261 §20.34) whatever form it is written in: a bare URI ("sip:scscf.example.com:6060;lr", every parameter the URI's), a bracketed one, a full '"Name" <sip:...;lr>;param', or a received Route value with several bracketed entries, which is taken apart at its commas. An entry that is not a SIP or SIPS URI raises ValueError naming it.

None
send_socket Optional[str]

Optional egress socket pin ("<transport>:<ip>:<port>", e.g. "udp:10.0.0.1:5060") — the operator equivalent of Kamailio's force_send_socket(). Selects which of siphon's own configured listeners the B-leg INVITE leaves from on a multi-homed host; the B-leg Via advertises that listener's address. UDP pins the exact (ip, port) listener; TCP/TLS bind the source IP with an ephemeral port. Ignored when flow is set (the flow already pins egress), and when its transport doesn't match the B-leg transport. A malformed spec raises ValueError.

None
auth_passthrough bool

Relay B-leg authentication to the caller end-to-end instead of siphon answering it (RFC 3261 §22.3). When True, siphon copies Proxy-Authenticate (B→A) and Proxy-Authorization (A→B) across the B2BUA, and treats a B-leg 401/407 (with no set_credentials() on this call) as a non-terminal challenge: it forwards the challenge to the caller without firing @b2bua.on_failure, writing a failure CDR, or tearing down the anchored media — so the caller (which holds the credentials) can authenticate and re-INVITE. Use this when the endpoint, not siphon, owns the credentials (e.g. an extension authenticating to its own PBX through the B2BUA). Mutually exclusive with set_credentials(); if both are set, the stored credentials win.

False
number_policy Optional[str]

Named E.164 number policy (from number_policies:) applied as the final normalization step: reformats the A-leg identity headers that flow to the B-leg plus this dial target. Defaults to b2bua.default_number_policy from siphon.yaml when unset (no normalization if that is also unset). Use :meth:rewrite_identities for imperative per-identity control.

None

Example::

# Basic dial (uses configured default policy)
call.dial("sip:bob@10.0.0.2:5060", timeout=30)

# 30s to answer, then at most an hour of talk time.
call.dial("sip:bob@10.0.0.2:5060", timeout=30, max_duration=3600)

# Device-driven proxy auth: let the extension authenticate to the
# PBX itself; siphon just relays the challenge and credentials.
call.dial("sip:bob@pbx.example.com:5060", auth_passthrough=True)

# IMS edge: stamp canonical IMPU on R-URI, route via I-CSCF,
# apply the trust-domain-boundary preset for outbound hygiene.
call.dial(
    "sip:1000@ims.mnc001.mcc001.3gppnetwork.org",
    next_hop="sip:192.0.2.178:4060",
    header_policy="ims-trust-domain-boundary@2026",
    copy=["X-Operator-Tag"],
    strip=["History-Info"],
)

# Emergency call — keep PAI / Reason / Geolocation through a
# trunk edge that would otherwise strip them.
call.dial(
    "sip:911@psap.example.com",
    header_policy="sip-trunk-edge@2026",
    copy=["Geolocation", "Geolocation-Routing",
          "P-Asserted-Identity", "Reason"],
)

fork

fork(
    targets: list[Union[str, Contact]],
    strategy: str = "parallel",
    timeout: int = 30,
    max_duration: Optional[int] = None,
    header_policy: Optional[str] = None,
    copy: Optional[list[str]] = None,
    strip: Optional[list[str]] = None,
    translate: Optional[list[tuple[str, str]]] = None,
    send_socket: Optional[str] = None,
    auth_passthrough: bool = False,
    number_policy: Optional[str] = None,
    format: Optional[str] = None,
) -> None

Fork to multiple B-leg targets.

Parameters:

Name Type Description Default
targets list[Union[str, Contact]]

List of URI strings or :class:Contact objects. Pass Contact objects (not just .uri) for two reasons. A binding this process accepted (contact.is_local) routes its branch over the captured inbound flow — RFC 5626 §5.3 connection reuse, mandatory for a WebSocket callee (RFC 7118 §5). And a binding registered through an edge proxy gets its own RFC 3327 Path as that branch's Route set, which is also where the branch is sent (RFC 3261 §16.6 step 6) — without it the B-leg goes to the UE's own Contact, the address the Path exists to route around, and two bindings of one AoR would share the first one's route set. Bare strings keep pure Request-URI routing.

required
strategy str

"parallel" (ring all, first answer wins, and the other branches are CANCELled when one answers; the call fails only once every branch has, with the best of their failures and a single @b2bua.on_failure) or "sequential" (try in order).

'parallel'
timeout int

Per-branch ring timeout in seconds. On expiry siphon CANCELs the branches still ringing and answers the caller 408, unless a branch already failed with something that outranks a timeout (a 486, say), which is relayed instead.

30
max_duration Optional[int]

Cap on how long the call may stay answered, in seconds — same semantics as :meth:dial. Unlike timeout this is not per-branch: whichever branch answers hands over one answered call, and the cap is on that call.

None
header_policy Optional[str]

Header policy applied to every branch of the fork — same semantics as :meth:dial, built-in or operator-defined (per-branch policy is a follow-up).

None
copy Optional[list[str]]

Per-call header copy deltas — same semantics as :meth:dial.

None
strip Optional[list[str]]

Per-call header strip deltas — same semantics as :meth:dial.

None
translate Optional[list[tuple[str, str]]]

Per-call header translation deltas — same semantics as :meth:dial.

None
send_socket Optional[str]

Optional egress socket pin applied to every branch (same "<transport>:<ip>:<port>" form as :meth:dial). A per-branch captured flow still takes precedence for that branch.

None
auth_passthrough bool

Relay B-leg authentication to the caller end-to-end — same semantics as :meth:dial. Applies to every branch of the fork.

False
number_policy Optional[str]

Named E.164 number policy applied to every branch target plus the A-leg identity headers — same semantics as :meth:dial.

None

Example::

contacts = registrar.lookup(call.ruri)
# Pass Contact objects so WebSocket callees route over their flow.
call.fork(contacts, strategy="parallel", timeout=30)

route

route(
    routes: list["Route"],
    timeout: int = 30,
    max_duration: Optional[int] = None,
    send_socket: Optional[str] = None,
) -> None

Route the call across an ordered list of carrier :class:~siphon_sdk.lcr.Route objects with sequential failover — B2BUA-only LCR execution.

The carriers (from await lcr.route(call), optionally filtered / reordered) are tried cheapest-first: dial the first routable carrier (a gateway_group resolved to a healthy member, else next_hop / ruri; the dialled number shaped by the route's number_policy, else b2bua.default_number_policy, with any tech_prefix prepended to the shaped number and headers injected), and on a reroute cause advance to the next — each attempt a fresh B-leg dialog. On answer, :attr:active_route is the carrier that won.

Parameters:

Name Type Description Default
routes list['Route']

Ordered carriers (cheapest first).

required
timeout int

Default ring timeout (seconds) for a route without its own timeout_secs. Also the ring bound for a carrier that has shown progress (a 101-199): such a carrier keeps the call to the later of its own timeout_secs and this, counted from its dial, and the call then fails with 408 rather than going to the next carrier (unless the route sets reroute_after_progress). 0 leaves that ring unbounded. A sequence that ends on the ring timeout of a carrier that never sent a 101-199 fails with 503 instead: no carrier reached the callee. So does one that moves on and finds none of the carriers left can be dialled, whatever the carrier before them did.

30
max_duration Optional[int]

Cap on how long the call may stay answered, in seconds — same semantics as :meth:dial. Per call, not per attempt: timeout bounds each carrier's ring, but the carrier that answers hands over one answered call.

None
send_socket Optional[str]

Optional egress socket pin applied to every attempt.

None

Example::

@b2bua.on_invite
async def route(call):
    decision = await lcr.route(call)
    if decision and decision.routes:
        call.route(decision.routes)

terminate

terminate() -> None

Terminate the call (send BYE to both legs).

Also honoured from @b2bua.on_answer, where it fails the call rather than connecting it: the B-leg has answered by then but the A-leg has not — its 2xx is only sent once the handler returns — so the caller receives a 500, the answered B-leg is ACKed and BYEd, and no answer-time charging is reported. Use it when the handler discovers the call cannot work, typically because the media backend refused the answer. An exception out of the handler has the same effect.

Example::

@b2bua.on_bye
def call_ended(call, initiator):
    call.terminate()

@b2bua.on_answer
def answered(call, reply):
    if not media_ok(reply):
        call.terminate()

take_over

take_over(
    call_id: str,
    local_tag: str,
    remote_tag: str,
    reinvite: bool = True,
    headers: dict[str, str] | None = None,
) -> None

Take over a dialog this node hosts for the party whose INVITE this is.

The Replaces takeover (RFC 3891) for any header that names a dialog to hand over, decided by the script: an SCC AS taking the source access leg over for the INVITE an SRVCC transfer sends with Target-Dialog and Require: tdialog (RFC 4538, TS 24.237 §12.3.5). Once the handler returns, siphon answers this INVITE 200 with the SDP the surviving party last gave, BYEs the named dialog's party, keeps the surviving party's dialog, and relays between it and this INVITE's dialog from then on.

The dialog is named the way siphon holds it: its Call-ID, siphon's own tag on it, and the far party's tag on it. A Target-Dialog is written as its recipient sees the dialog (RFC 4538 §3), so siphon's tag is its local-tag and the far party's its remote-tag.

This is the script's authorisation, so it works whether or not b2bua.accept_replaces is set. Holding a dialog's identifiers is no authority to end it (RFC 3891 §5, RFC 4538 §4): check the request (its source, Require: tdialog, your own correlation) first. It is an action like dial(): the last action of the handler wins, and it runs after the handler returns. It cannot follow or precede answer() (ValueError).

Refused on the wire once the handler returns: 481 when no dialog of this node matches, 480 when the named dialog is not confirmed yet (TS 24.237 §12.3.2.3), 488 when the INVITE carries no offer.

Parameters:

Name Type Description Default
call_id str

Call-ID of the dialog to take over.

required
local_tag str

siphon's own tag on that dialog.

required
remote_tag str

the far party's tag on that dialog.

required
reinvite bool

True (default) re-INVITEs the surviving party with this INVITE's offer. False leaves it alone, for a new leg whose media is the old one's (TS 24.237 §12.3.5). The real engine raises ValueError for False on a call it anchors on a media engine.

True
headers dict[str, str] | None

Optional {name: value} mapping added to the 200 that answers this INVITE (TS 24.237 §6A.4.3A: the remote party's P-Asserted-Identity, Feature-Caps, Accept, Recv-Info). The headers siphon owns raise ValueError (see reject()).

None

Example::

@b2bua.on_invite
def on_invite(call):
    target = call.get_header("Target-Dialog")
    if target and "tdialog" in (call.get_header("Require") or ""):
        dialog_call_id, _, rest = target.partition(";")
        params = dict(p.strip().split("=", 1)
                      for p in rest.split(";") if "=" in p)
        call.take_over(dialog_call_id.strip(),
                       local_tag=params["local-tag"],
                       remote_tag=params["remote-tag"],
                       reinvite=False,
                       headers={"P-Asserted-Identity": "<tel:+15550100001>"})
        return
    call.dial(call.ruri)

set_max_duration

set_max_duration(seconds: int) -> None

Cap how long this call may stay answered, in seconds.

The same knob as max_duration= on :meth:dial / :meth:fork / :meth:route, reachable from a call that never dials — a UAS-mode :meth:answer (IVR, announcement) or a :meth:handover — which would otherwise be stuck on the configured ceiling.

The clock starts at the answer. On expiry siphon BYEs both legs through the ordinary teardown (CDR, charging stop, media release) with Reason: Q.850;cause=102; no Python handler fires. 0 means uncapped, overriding a configured b2bua.max_call_duration_secs.

Example::

@b2bua.on_invite
def ivr(call):
    call.set_max_duration(600)     # 10 minutes, then hang up
    call.answer(200, "OK")

accept_refer

accept_refer(
    target: Optional[str] = None,
    next_hop: Optional[str] = None,
    mode: Optional[str] = None,
    profile: Optional[str] = None,
    number_policy: Optional[str] = None,
    format: Optional[str] = None,
    timeout: Optional[int] = None,
) -> None

Accept an incoming REFER and honour the transfer.

Call this from a @b2bua.on_refer handler to proceed with the transfer the remote party asked for.

Parameters:

Name Type Description Default
target Optional[str]

Optionally rewrite the transfer destination before honouring it. Defaults to :attr:refer_to (the URI carried in the incoming Refer-To header).

None
next_hop Optional[str]

Optionally steer egress to a specific next-hop, exactly like dial(next_hop=...) — the R-URI is still built from target (so the referred-to identity is preserved on the wire) but the message is sent to next_hop.

None
mode Optional[str]

How siphon honours the REFER:

  • "terminate" — siphon terminates the transfer: it answers 202 Accepted locally, dials target (the Refer-To) as a brand-new leg, re-bridges the surviving leg to it, and sends BYE to the referred-away leg. The transferor drops out; siphon owns both new legs. The transferee never sees the REFER.

The new leg is dialled directly: @b2bua.on_invite does not run again for it, so any routing that handler does is this handler's to repeat. number_policy below covers the number-shaping half of that. - "transparent" — siphon re-emits the REFER on the far leg and relays the far leg's 202 Accepted plus the sipfrag NOTIFY progress reports back to the transferor, staying out of the transfer decision. - None (default) — use the configured b2bua.default_refer_mode from siphon.yaml (which itself defaults to "terminate").

None
profile Optional[str]

Media profile for the pairing the transfer creates.

Required whenever the call is anchored with a direction-bound profile — one whose offer and answer halves describe different sides, such as srtp_to_rtp at an SRTP edge. A transfer moves the party that half was written for out of the call, so inheriting the profile re-offers that party's transport to whoever remains: SRTP to a plain-RTP carrier, which answers m=audio 0. The call connects and carries no audio in either direction, and the SIP trace looks healthy.

Direction-bound built-ins: srtp_to_rtp, rtp_to_srtp, ws_to_rtp, wss_to_rtp. rtp_passthrough is symmetric and re-pairs safely. None inherits the call's profile, which is correct only when it is symmetric; siphon logs a WARN when it is not.

None
number_policy Optional[str]

Reshape the transferred leg's number, exactly as dial(number_policy=...) reshapes a dialled one: this named policy, else b2bua.default_number_policy, else no reshaping. It applies to target (and so to the R-URI and To of the triggered INVITE) and to that INVITE's identity headers.

Without it the target goes out in whatever shape the referrer named it in. A Refer-To often names +E.164, so a trunk that takes bare digits sees every ordinary call arrive as 32... and every transferred one as +32....

Terminate mode only: in "transparent" mode siphon dials nothing — it re-emits the REFER and the far end resolves the target under its own numbering — so this has no effect there.

None
format Optional[str]

The inline form of the same thing, exactly as on :meth:rewrite_identities: "e164", "plain", "international" or "national", applied over the default identity header set on the configured home locale. Use this when you shape numbers inline and have no number_policies: block to name. Pass this or number_policy, never both.

None

Raises:

Type Description
ValueError

if mode is not one of None, "terminate", or "transparent"; if number_policy names a policy that is not configured; if format is not a known format; or if both number_policy and format are given.

Example::

@b2bua.on_refer
def handle_refer(call):
    # Blind transfer — let siphon terminate + re-bridge (default).
    call.accept_refer()

@b2bua.on_refer
def handle_refer(call):
    # Steer the referred-to leg out a specific trunk, transparently.
    call.accept_refer(next_hop="sip:trunk.example.com:5060",
                      mode="transparent")

@b2bua.on_refer
def handle_refer(call):
    # SRTP edge: the SRTP party is the one leaving, so the pair
    # that remains is plain RTP on both sides.
    call.accept_refer(target=call.refer_to, mode="terminate",
                      profile="rtp_passthrough")

@b2bua.on_refer
def handle_refer(call):
    # Trunk takes bare digits; the Refer-To names +E.164.
    call.accept_refer(target=target, next_hop=gw.uri,
                      mode="terminate", profile="rtp_passthrough",
                      number_policy="carrier-plain@2026")

@b2bua.on_refer
def handle_refer(call):
    # Same thing inline, with no number_policies: block to name.
    call.accept_refer(target=target, next_hop=gw.uri,
                      mode="terminate", profile="rtp_passthrough",
                      format="plain")

mode="script": siphon transfers nothing and dials nothing. It answers 202 Accepted and sends the first NOTIFY (sipfrag 100 Trying, RFC 3515 section 2.4.4) at once, before the handler returns, so the handler can go on to do what the REFER asks itself and then report with b2bua.complete_refer(call.call_id, code). The referrer stays in its dialog: a REFER to a conference focus inside a participant's dialog (RFC 4579 section 5.5), say. timeout (seconds, default 60, at most 180) is how long the script has to report before siphon reports 503 for it. target, next_hop, profile, number_policy and format do not apply and raise ValueError; a REFER no longer awaiting a decision raises RuntimeError::

@b2bua.on_refer
async def on_refer(call):
    call.accept_refer(mode="script")     # 202 + NOTIFY 100 now
    code = await add_to_conference(call.refer_to)
    b2bua.complete_refer(call.call_id, code)

reject_refer

reject_refer(code: int, reason: str) -> None

Reject an incoming REFER.

Parameters:

Name Type Description Default
code int

SIP status code (e.g. 403, 603).

required
reason str

Reason phrase.

required

Example::

@b2bua.on_refer
def handle_refer(call):
    call.reject_refer(403, "Forbidden")

refer

refer(target: str, replaces: Optional[dict] = None) -> None

Originate an outbound REFER — siphon is the referrer.

Use this when siphon itself drives the transfer, e.g. a UAS/IVR offload that answers the call, plays a menu, then transfers the caller onward. This is the mirror of :meth:accept_refer (which honours a REFER siphon received).

Parameters:

Name Type Description Default
target str

The Refer-To URI — where the remote party should be transferred to.

required
replaces Optional[dict]

For an attended transfer, a dict identifying the dialog to replace (RFC 3891) with keys call_id, from_tag and to_tag (and an optional early_only bool). Omit / None for a blind transfer.

None

Raises:

Type Description
ValueError

if replaces is given but is missing any of call_id / from_tag / to_tag.

Note

This is a deferred call action — it is honoured after the handler returns, so it works from a call-scoped handler like @b2bua.on_answer. From an out-of-band event callback (@rtpengine.on_dtmf, a timer) where no call is in scope and deferred actions are no-ops, use the imperative :func:b2bua.refer (keyed by Call-ID) instead.

Example::

@b2bua.on_answer
def on_answer(call, reply):
    # Blind transfer the freshly answered call onward.
    call.refer("sip:+15550142@example.com")

@b2bua.on_answer
def attended(call, reply):
    call.refer(
        "sip:+15550142@example.com",
        replaces={"call_id": "held-dialog@example.com",
                  "from_tag": "ft-held",
                  "to_tag": "tt-held"},
    )

session_timer

session_timer(
    expires: int = 1800,
    min_se: int = 90,
    refresher: str = "b2bua",
) -> None

Run an RFC 4028 session timer on this call, overriding session_timer:.

siphon negotiates a session timer on each dialog of the call: the callee's, where siphon sends the INVITE, and the caller's, where siphon answers it. It refreshes the dialogs it ends up the refresher of and ends the call when a session runs out on either.

Parameters:

Name Type Description Default
expires int

Session interval in seconds (default 1800).

1800
min_se int

Smallest session interval accepted, in seconds (default 90).

90
refresher str

Who siphon would have refresh each dialog, where the negotiation leaves it the choice: "uac" (the UAC of each dialog: siphon refreshes the callee, the caller refreshes itself), "uas" (the UAS of each: the callee refreshes itself, siphon refreshes the caller) or "b2bua" (siphon refreshes both). Default "b2bua". A caller that chose its own refresher, or cannot refresh, keeps what RFC 4028 gives it.

'b2bua'

Raises:

Type Description
ValueError

refresher is not one of "uac", "uas" or "b2bua".

Example::

@b2bua.on_invite
def new_call(call):
    call.session_timer(1800, min_se=90, refresher="b2bua")
    call.dial("sip:bob@example.com")

keep_call_id

keep_call_id() -> None

Copy the A-leg Call-ID to the B-leg instead of generating a new one.

By default the B2BUA generates a fresh Call-ID for each B-leg to fully decouple the two SIP dialogs (proper topology hiding). Call this method if you need the trunk to see the same Call-ID as the originating side.

Note: the From-tag is always regenerated regardless — it must be unique per leg.

Example::

@b2bua.on_invite
def on_invite(call):
    call.keep_call_id()  # trunk sees same Call-ID
    call.dial("sip:trunk@carrier.example.com")

set_credentials

set_credentials(username: str, password: str) -> None

Set outbound credentials for B-leg digest authentication.

When the B-leg returns 401/407, SIPhon automatically retries the INVITE with these credentials instead of firing on_failure.

Parameters:

Name Type Description Default
username str

Digest username.

required
password str

Digest password.

required

Example::

@b2bua.on_invite
def on_invite(call):
    call.set_credentials("trunk_user", "s3cret")
    call.dial("sip:gw@carrier.example.com")

set_ruri_user

set_ruri_user(value: str) -> None

Set the user part of the Request-URI.

Parameters:

Name Type Description Default
value str

New user part (e.g. "+33123456789").

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_ruri_user("+33123456789")
    call.dial("sip:gw@carrier.example.com")

restrict_caller_id

restrict_caller_id() -> None

Withhold the calling party's identity on the B-leg (CLIR).

RFC 3323 §4.1 / 3GPP TS 24.607::

@b2bua.on_invite
def route(call):
    if caller_withheld(call):
        call.restrict_caller_id()
    call.dial(str(call.ruri))
  • From becomes "Anonymous" <sip:anonymous@anonymous.invalid>, keeping its dialog tag.
  • Privacy: id is asserted (RFC 3325 §7), appended to any existing Privacy value rather than replacing it.
  • P-Asserted-Identity is left intact, carrying the real identity to the trusted next hop — that is how the network stays able to identify the caller for regulatory and emergency purposes.
  • P-Preferred-Identity is removed: it is the UA's request for what to assert, and forwarding it past a privacy boundary re-leaks the number.

Setting Privacy: id by hand while leaving the real number in From leaks it to every carrier that renders From rather than PAI — which defeats CLIR while looking like it works. This moves both together.

Call it after any identity reshaping: anonymisation is the last step, or a number policy will try to reformat anonymous as a number. The LCR twin is a route's caller_id_presentation="restricted".

set_caller_id

set_caller_id(number: str) -> bool

Present number as the calling party.

Applied to From and to P-Asserted-Identity / P-Preferred-Identity where present, preserving the dialog tag — which is why this exists rather than a set_header("From", ...): the B-leg From host is rewritten after the script runs, and a From set without a tag drops the mandatory dialog tag (RFC 3261 §8.1.1.3), a failure that only surfaces later on the ACK.

Unlike a number policy, which reshapes the format of whatever number is already there, this substitutes a different one. The LCR twin is a route's caller_id.

Returns True if anything was rewritten.

set_from_user

set_from_user(value: str) -> None

Set the user part of the From header URI.

Preserves display name and tag parameter while replacing the user part.

Parameters:

Name Type Description Default
value str

New user part (e.g. "+33123456789").

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_from_user("+33123456789")
    call.dial("sip:gw@carrier.example.com")

set_to_user

set_to_user(value: str) -> None

Set the user part of the To header URI.

Mirrors :meth:set_from_user / :meth:set_ruri_user for the To header. Useful at IMS edges (BGCF inbound) where the B-leg R-URI is rewritten from a public E.164 to a short-code IMPU and downstream nodes expect To to match.

Preserves scheme/host/port and any existing To-tag — only the userpart changes. Must be called before :meth:dial for the change to take effect on the B-leg INVITE.

Parameters:

Name Type Description Default
value str

New user part (e.g. "1000").

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_ruri_user("1000")
    call.set_to_user("1000")
    call.dial("sip:1000@ims.mnc001.mcc001.3gppnetwork.org")

rewrite_identities

rewrite_identities(
    policy: Optional[str] = None,
    format: Optional[str] = None,
    headers: Optional[list[str]] = None,
    home: Optional[str] = None,
) -> int

Rewrite dialable identity userparts into a target E.164 shape.

Walks From, To, P-Asserted-Identity, P-Preferred-Identity (and any opted-in header) on the A-leg INVITE, which flows to the B-leg. Pass either a named policy from number_policies: or an inline format ("e164" | "plain" | "international" | "national") with an optional headers list and home country-code override. Returns the number of headers changed. Must be called before :meth:dial.

Example::

call.rewrite_identities("ims-e164@2026")
call.rewrite_identities(format="e164")

set_from_host

set_from_host(value: str) -> None

Pin the host part of the B-leg From header URI.

By default the B2BUA rewrites the From URI host to its own advertised address (topology hiding — masking the A-leg identity). At a multitenant edge the downstream selects the tenant from the From domain: a domainless call lands in an unauthenticated/default routing context, so the tenant domain must survive. set_from_host() opts this leg out of the From host-rewrite and pins the host to value.

Only the host changes; scheme/user/port/params and the From-tag are preserved. value is a bare host (no port). Must be called before :meth:dial for the change to take effect on the B-leg INVITE — same model as :meth:set_from_user.

Parameters:

Name Type Description Default
value str

New host (e.g. "tenant.example.com").

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_from_host("tenant.example.com")
    call.dial(str(call.ruri), next_hop="sip:pbx.example.com:5060")

set_to_host

set_to_host(value: str) -> None

Pin the host part of the B-leg To header URI.

By default the B2BUA rewrites the To URI host to the dial-target host, and leaves it as the caller wrote it when the target has none (a tel: URI). set_to_host() pins it to value instead, so the To domain does what the script says regardless of the routing next-hop (declarative replacement for the raw set_header("To", "<sip:user@host>") idiom).

Only the host changes; scheme/user/port/params and any To-tag are preserved. value is a bare host (no port). Must be called before :meth:dial — same model as :meth:set_to_user.

Parameters:

Name Type Description Default
value str

New host (e.g. "trunk.example.com").

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_to_user(callee)
    call.set_to_host(TRUNK_DOMAIN)
    call.dial(str(call.ruri))

set_from_uri

set_from_uri(value: str) -> None

Replace the entire From header URI on the B-leg INVITE.

The whole-URI form of :meth:set_from_user / :meth:set_from_host — rewrites scheme, user, host, port and URI params in one call while preserving the display name and From-tag. The host is also pinned (the B-leg builder would otherwise rewrite it to the advertised address for topology hiding — same opt-out as :meth:set_from_host). Must be called before :meth:dial.

Parameters:

Name Type Description Default
value str

New From URI, e.g. "sip:+31123@tenant.example.com:5060;transport=tcp".

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_from_uri("sip:1001@tenant.example.com:5060")
    call.dial("sip:gw@carrier.example.com")

set_to_uri

set_to_uri(value: str) -> None

Replace the entire To header URI on the B-leg INVITE.

The whole-URI form of :meth:set_to_user / :meth:set_to_host, preserving the display name and any To-tag. The host is also pinned (same opt-out as :meth:set_to_host). Must be called before :meth:dial.

Parameters:

Name Type Description Default
value str

New To URI, e.g. "sip:1000@ims.mnc001.mcc001.3gppnetwork.org".

required

set_contact_user

set_contact_user(value: str) -> None

Inject a userpart into the B-leg Contact URI, keeping siphon's advertised host:port.

The B2BUA advertises its own address as the Contact so in-dialog requests (BYE, re-INVITE) route back through siphon; by default that Contact is userless (RFC 3261 §8.1.1.8 puts no identity in the Contact userpart). set_contact_user() adds a userpart while leaving the host:port untouched, so in-dialog routing still works and the userpart rides along — e.g. a downstream that keys a tenant/extension off the Contact userpart, the way it does for a REGISTER Contact.

Pass an empty string to force a userless Contact. Must be called before :meth:dial.

Parameters:

Name Type Description Default
value str

Contact userpart (e.g. the extension).

required

Example::

@b2bua.on_invite
async def on_invite(call):
    call.set_contact_user(call.from_uri.user)
    call.dial("sip:gw@carrier.example.com")

set_contact_uri

set_contact_uri(value: str) -> None

Replace the entire B-leg Contact URI — a full override of siphon's advertised Contact.

Power tool for edge deployments that front siphon (GRUU, edge SBC). Overriding the host/port moves the in-dialog anchor off siphon, so the deployment must route the far side's in-dialog requests back to siphon or the dialog breaks. Takes precedence over :meth:set_contact_user. value is a bare URI (no angle brackets). Must be called before :meth:dial.

Parameters:

Name Type Description Default
value str

Full Contact URI, e.g. "sip:gruu-token@edge.example.com:5060".

required

get_header

get_header(name: str) -> Optional[str]

Get the first value of a header (case-insensitive).

header

header(name: str) -> Optional[str]

Alias for :meth:get_header.

set_header

set_header(name: str, value: str) -> None

Set (replace) a header value on the captured A-leg INVITE.

The B-leg INVITE is built from this message, and a header set here (or removed with :meth:remove_header / :meth:remove_headers_matching) goes out as written: the header policy, preset and copy= / strip= / translate= deltas alike, neither strips, rewrites nor translates it. The framework-managed headers (Via, Call-ID, CSeq, Max-Forwards, Content-Length, From, To, Contact, Record-Route, Route) stay siphon's, and the number policy, session-timer headers and LCR route headers still apply on top. Supported and Allow set here replace the capabilities siphon would advertise; replaces is still merged into a Supported set this way. To relay the caller's own list::

call.set_header("Supported", call.get_header("Supported"))

set_body

set_body(
    body: Union[str, bytes],
    content_type: Optional[str] = None,
) -> None

Replace the body of the captured A-leg INVITE.

Updates Content-Type and Content-Length to match. body accepts str or bytes.

The B-leg INVITE is built from this message, so a body set here is the body the callee receives — this is the dial()-time counterpart of :meth:set_ruri_user / :meth:set_from_user and must be called before :meth:dial / :meth:fork. The typical use is sanitising the SDP an anchoring step left behind: rtpengine.offer(call) rewrites the captured INVITE in place, and a script that needs to strip or rewrite attributes before the offer goes out has nowhere else to put the result.

Parameters:

Name Type Description Default
body Union[str, bytes]

Replacement body, str or bytes.

required
content_type Optional[str]

Content-Type to set. Omit to leave the existing one in place — the body changed, the type did not.

None

Example::

@b2bua.on_invite
async def new_call(call):
    await rtpengine.offer(call, profile="trunk_to_ims")
    cleaned = strip_unwanted_attributes(call.body)
    call.set_body(cleaned, "application/sdp")
    call.dial("sip:bob@example.com")

set_charging_param

set_charging_param(name: str, value: str) -> None

Stash a charging-param for the Rf B2BUA auto-emit hook.

Mirrors 🇵🇾meth:siphon_sdk.request.Request.set_charging_param for B2BUA scripts that get a Call object. Recognised names map to TS 32.299 IMS-Information AVPs; unknown names are still captured so future siphon versions can recognise more without breaking deployed scripts.

Example (BGCF as B2BUA)::

@b2bua.on_invite
async def on_invite(call):
    gw = gateway.select("connect")
    call.set_charging_param(
        "outgoing-trunk-group-id", gw.attrs["group"],
    )
    call.dial(gw.uri)

remove_header

remove_header(name: str) -> None

Remove a header entirely.

has_header

has_header(name: str) -> bool

Check if a header exists.

remove_headers_matching

remove_headers_matching(prefix: str) -> None

Remove all headers whose name starts with a prefix.

Parameters:

Name Type Description Default
prefix str

Prefix string (e.g. "X-" removes all custom headers).

required

Example::

call.remove_headers_matching("X-")

Placing a call: b2bua.originate

Every handler above is driven by a call that arrived. b2bua.originate() creates one from nothing — click-to-dial, callbacks, outbound notification — so it works from a timer or an event callback, where no Call object exists at all.

It returns as soon as the INVITE is on the wire, with the new leg's SIP Call-ID; it does not wait for the callee. Ringing and answer come back through the ordinary handlers (@b2bua.on_answer, @b2bua.on_failure, @b2bua.on_bye), and the returned Call-ID is the handle for b2bua.terminate() / b2bua.refer().

from siphon import b2bua, timer

@timer.every(seconds=60)
def reminders():
    for number in due_numbers():
        b2bua.originate(
            to=f"sip:{number}@carrier.example",
            from_uri="sip:+14035550100@siphon.example",
            from_display="Reminders",
            media=True,          # siphon anchors the leg on the media backend
            timeout=30,          # CANCELled if nobody answers in 30 s
        )

Exactly one media plan is required — an INVITE with no offer and no way to answer the callee's would connect a call with no audio: sdp= (your own offer, any backend), body= + content_type= (the same slot with the type spelled out, for an offer travelling inside a multipart/* body per RFC 5621 §3), or media=True (siphon anchors it; siphon-rtp backend). Whichever spelling, the body has to carry an SDP offer — a body that carries none raises rather than placing a call the callee can only answer by offering into silence. The full argument set and its failure modes are below; the out-of-process twin is the control plane's originate verb.

A placed call runs the RFC 4028 session timer of the session_timer: block, or the one session_timer={"expires": 1800, "min_se": 90, "refresher": "b2bua"} sets on it (keys left out default as in call.session_timer()). The INVITE asks for it, the callee's 2xx says who refreshes, and siphon refreshes the dialog or releases the call just before a session the callee let run out expires.

An application server that places a call on a user's or a service identity's behalf hands it to its S-CSCF with route=["<sip:orig@scscf.example.com;lr>"]: the entries go out as the INVITE's Route headers (and on its CANCEL and the ACK of a failure), so the S-CSCF reads the session case from its own Route entry (TS 24.229 §5.7.3). next_hop only says where the packet goes; with no next_hop the INVITE goes to the topmost Route entry.

Place an outbound call siphon owns, with no inbound INVITE behind it.

The primitive under click-to-dial, callbacks and outbound notification. Unlike call.dial() (which builds a B-leg off a call that already arrived), this creates a call from nothing.

Asynchronous. Returns as soon as the INVITE is on the wire, with the new leg's SIP Call-ID — it does not wait for the callee. Ringing and answer arrive through the ordinary handlers: @b2bua.on_answer fires with (call, reply), @b2bua.on_failure with (call, code, reason), @b2bua.on_bye on teardown. Feed the returned Call-ID to b2bua.terminate() / b2bua.refer().

Exactly one media plan is required — an INVITE with no offer and no way to answer the callee's would connect a call with no audio:

  • sdp="v=0..." — your own offer, carried verbatim as application/sdp (any backend);
  • body=…, content_type=… — the same slot with the type spelled out, for an INVITE whose offer travels as one part of a multipart/* body (RFC 5621 §3) beside a part SIP does not interpret: ISUP on a SIP-I trunk, a PIDF-LO location object, an operator-specific document. You assemble the multipart; siphon carries it verbatim and derives Content-Length from it;
  • media=True — siphon anchors the leg on the configured media backend (siphon-rtp), so rtpengine.play_media(), DTMF and the WebSocket tee all work against it.

Whichever spelling, the body must carry an SDP offer — bare application/sdp, or a multipart/* with an application/sdp part in it. Live siphon raises for one that carries none, because a callee that reads the INVITE as offerless offers in its own 2xx and this plan has nothing to answer that with (RFC 3261 §13.2.2.4).

Parameters:

Name Type Description Default
to str

called party — the Request-URI and the To URI.

required
from_uri Optional[str]

calling identity (From URI). Defaults to siphon's own advertised address.

None
from_display Optional[str]

From display name.

None
to_display Optional[str]

To display name.

None
next_hop Optional[str]

route the INVITE here while the R-URI keeps the called party's shape (IMS edge / trunk steering).

None
p_asserted_identity Optional[str]

P-Asserted-Identity for a trusted next hop.

None
privacy Optional[str]

"allowed" or "restricted". Restricted anonymises From and asserts Privacy: id, keeping the real identity in P-Asserted-Identity.

None
headers Optional[dict]

dict of extra headers applied last. Dialog-defining headers (Via/From/To/Call-ID/CSeq/Contact/Route/…) are ignored.

None
sdp Optional[str]

your own SDP offer, carried as application/sdp.

None
media bool

True to have siphon anchor the leg on the media backend.

False
profile Optional[str]

media profile for media=True (default "rtp_passthrough").

None
ws_uri Optional[str]

per-call WebSocket bridge URI for media=True.

None
timeout int

ring timeout in seconds; the call is CANCELled when it elapses. 0 disables it.

30
body Optional[Union[str, bytes]]

the INVITE body, str or bytes — the offer itself, or a multipart carrying it. Mutually exclusive with sdp.

None
content_type Optional[str]

Content-Type for body (e.g. "multipart/mixed;boundary=…"). Defaults to "application/sdp", which makes body identical to sdp.

None
session_timer Optional[dict]

the RFC 4028 session timer to run on the call, over the session_timer: block: a dict with expires, min_se and refresher, each defaulting as in call.session_timer() (1800, 90, "b2bua"). The INVITE asks for it, the callee's 2xx decides who refreshes, and siphon refreshes the dialog or ends the call at expiry. None runs the configured timer, if any.

None
route Optional[list]

a pre-loaded route set (RFC 3261 §8.1.2), e.g. ["<sip:orig@scscf.example.com;lr>"]: the INVITE's Route headers, in order, copied onto its CANCEL and the ACK of a failure. With no next_hop the INVITE goes to the topmost entry. How an application server hands a request it originates to its S-CSCF with the session case in the Route entry (TS 24.229 §5.7.3); next_hop only says where the packet goes.

None

Returns:

Name Type Description
str str

the new leg's SIP Call-ID.

Raises:

Type Description
ValueError

no media plan, two media plans, both spellings of the offer, a content_type with nothing to describe, or an unrecognised privacy value. Live siphon also raises for unparseable URIs, no route, a media plan the backend cannot serve, and a body carrying no offer — never a silent None for a call that was never placed.

In the mock, records the full argument set on originates and returns a synthetic Call-ID. Inspect via siphon.get_b2bua().originates.

Usage::

@timer.every(seconds=60)
def reminders():
    for number in due_numbers():
        b2bua.originate(
            to=f"sip:{number}@carrier.example",
            from_uri="sip:+14035550100@siphon.example",
            media=True,
        )

Joining two calls: b2bua.bridge

b2bua.originate gives a script a second call; b2bua.bridge connects it to the first. Both legs are named by SIP Call-ID, so it works from a timer or an event callback where no Call object exists.

The leg named first is the anchor: it keeps its media session, and the other joins it. The call resolves once the media has been re-pointed and the first re-INVITE is on the wire — a bridge is two RFC 3261 §14 re-INVITEs across two dialogs, and the far ends' verdict arrives on the control rail as ChannelBridged / BridgeFailed.

from siphon import b2bua

@b2bua.on_answer
async def connect_the_supervisor(call):
    supervisor = b2bua.originate(
        to="sip:+15550142@example.com",
        media=True,                 # siphon anchors the leg
    )
    # ... wait for it to answer (a @b2bua.on_answer for that leg) ...
    await b2bua.bridge(call.call_id, supervisor, on_peer_hangup="hold")

unbridge parts them without ending either call: both legs stay answered, owned and held (a=sendonly, RFC 3264 §8.4), so either can be bridged again or hung up. Every refusal raises ValueError prefixed with a stable cause token rather than returning a hollow success. The out-of-process twin is the control plane's bridge verb.

Join two answered calls siphon owns, so the two parties hear each other.

The primitive under callback-and-connect and attended hand-off: every other B2BUA verb acts on one call, this one joins two. Both legs are named by SIP Call-ID, so it works from an event callback or a timer where no call object is in scope.

Awaitable. It resolves once the media has been re-pointed and the first re-INVITE is on the wire — the same "the local action was performed" contract :meth:originate has. A bridge is two RFC 3261 §14 re-INVITEs across two dialogs; whether the far ends accept them is a far-end outcome, delivered on the control rail as ChannelBridged / BridgeFailed.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of the leg that keeps its media anchor — its ports, and anything attached to them, survive the bridge.

required
with_call_id str

SIP Call-ID of the leg joined to it. Its own media session is deleted; it becomes the second party on the first's.

required
on_peer_hangup str

what happens to the survivor when one party leaves. "hangup" (default) tears it down too; "hold" keeps it up and held so it can be bridged to somebody else.

'hangup'

Returns:

Name Type Description
bool bool

True once the bridge has been accepted and put in motion.

Raises:

Type Description
ValueError

an on_peer_hangup that is neither "hangup" nor "hold" — guessing at a teardown policy is how calls get stranded. Live siphon also raises when a leg is unknown, has not answered, is already bridged, has a re-INVITE outstanding, carries no media description, or the media backend cannot express the bridge. The message is prefixed with the stable cause token (not_found, invalid_state, bad_request, unsupported_verb, unavailable) — never a hollow success.

In the mock, records {"call_id", "with_call_id", "on_peer_hangup"} on bridges and returns True. Inspect via siphon.get_b2bua().bridges.

Usage::

# Callback-and-connect: join the parked caller to the leg siphon
# placed for them, once that leg has answered.
@b2bua.on_answer
async def connect(call, reply):
    parked = await cache.fetch("callbacks", call.call_id)
    if parked:
        await b2bua.bridge(parked, call.call_id)

Break a bridge, leaving both legs answered, owned and held.

Each leg falls back to exactly the state a freshly answered, unbridged leg is in — siphon re-offers it a=sendonly (RFC 3264 §8.4; RFC 6337 §3.1 prefers that to c=0.0.0.0), so the endpoint stops sending and hears nothing. Neither leg is hung up: that would make unbridge indistinguishable from two :meth:terminate calls. A later :meth:bridge re-offers sendrecv.

Awaitable.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of either leg of the bridge.

required
reason str

carried on the control-plane ChannelUnbridged event.

'unbridged'

Returns:

Name Type Description
bool bool

True once both legs have been held and the bridge dropped.

Raises:

Type Description
ValueError

live siphon raises when the leg is unknown, is not bridged, or its bridge is still forming. The message carries the same stable cause token as :meth:bridge.

In the mock, records {"call_id", "reason"} on unbridges and returns True. Inspect via siphon.get_b2bua().unbridges.

Usage::

@b2bua.on_bye
async def split(call, initiator):
    await b2bua.unbridge(call.call_id, reason="supervisor split")

Swapping a party mid-call: b2bua.replace_peer

bridge joins two calls the script owns. replace_peer does something different: it takes one answered call and swaps out one of its two parties for somebody new, without either party's endpoint asking for it.

This is the transfer siphon already runs when it terminates an inbound REFER (call.accept_refer(mode="terminate")), reachable when siphon is the one deciding. It dials the target as a new leg on the same call, re-anchors the surviving party's media onto it, and once the target answers promotes it into the surviving pair and BYEs the leg it replaced. An IVR that has worked out where the caller should go, a supervisor take-over, a controller handing a call from an AI to a human.

The obvious hand-rolled version — hang up one leg, then re-INVITE the other with new SDP — is worse in two ways that only show up in production. The caller hears dead air for the whole ring, because the leg is gone before the target has even been dialled. And the call's own state is left behind: no @b2bua.on_bye, no CDR, no charging stop, no media release, and a later terminate re-BYEs a dialog that is already dead. replace_peer keeps the replaced leg up until the target answers and releases it through the real teardown.

from siphon import b2bua, rtpengine

@rtpengine.on_dtmf
def zero_for_an_operator(call_id, from_tag, digit, duration_ms, volume):
    if digit == "0":
        # The caller stays connected to the IVR while the operator's phone
        # rings; if nobody picks up in 45s the call is left exactly as it was.
        b2bua.replace_peer(call_id, "sip:operator@pbx.example", timeout=45)

Every refusal raises ValueError prefixed with a stable cause token — a caller that cannot tell a refused replacement from a started one will tear down a call that is still up. The out-of-process twin is the control plane's replace_peer verb.

Replace one leg of an answered call with a freshly dialed target.

The transfer siphon already knows how to run, reachable without a remote party asking for it: it dials target as a new leg on the same call, re-anchors the surviving party's media onto it, and when the target answers promotes it into the surviving pair and BYEs the leg it replaced. An IVR that has decided where a caller goes next, a controller handing a call from an AI to a human, a supervisor take-over.

The replaced leg stays up while the target rings and is released only once the target answers, so the surviving party hears ringback rather than silence, and a target that rejects or never answers leaves the original call exactly as it was.

Acts now, so it works from an out-of-band event callback (@rtpengine.on_dtmf), a timer, or a normal handler.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of the call to act on.

required
target str

URI to dial as the replacement.

required
next_hop Optional[str]

steer egress without reshaping the R-URI, as on :meth:siphon_sdk.call.Call.dial.

None
replace_a_leg bool

False (default) replaces the callee and keeps the caller; True does the reverse.

False
profile Optional[str]

media profile for the pair this creates. Required when the call is anchored with a direction-bound profile — the inherited one describes the party that is leaving.

None
timeout int

seconds to wait for the target to answer. 0 means no ring policy, only siphon's guard against a target that never answers.

30
number_policy Optional[str]

reshape the target's number (and the triggered INVITE's identity headers) the way :meth:siphon_sdk.call.Call.dial does — this named policy, else b2bua.default_number_policy, else no reshaping. A replacement leg does not re-enter @b2bua.on_invite, so this is where a carrier's number format gets applied to it.

None
format Optional[str]

the inline form of the same thing, as on rewrite_identities(format=...): "e164", "plain", "international" or "national". Pass this or number_policy, never both.

None

Returns:

Name Type Description
bool bool

True once the INVITE is on the wire. It does not wait for the

bool

target — the replacement completes later, and @b2bua.on_bye

bool

fires for the leg that was replaced.

Raises:

Type Description
ValueError

the call is unknown, has not answered, has no peer leg to replace, already has a replacement in flight, or the target will not route. The message is prefixed with the stable cause token (not_found, invalid_state, bad_request, unavailable).

In the mock, records every argument on replacements and returns True; an empty target raises ValueError the way live siphon refuses one it cannot route. Inspect via siphon.get_b2bua().replacements.

Usage::

@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
    if digit == "0":
        b2bua.replace_peer(call_id, "sip:operator@pbx.example",
                           timeout=45)

Taking over a dialog an INVITE names: call.take_over

replace_peer is siphon deciding to swap a party out. The mirror is a new INVITE asking to take one of siphon's dialogs over. With Replaces (RFC 3891) siphon does that itself, behind b2bua.accept_replaces. Other headers name a dialog for the same purpose, and there the script decides: an SCC AS anchoring a call gets a new INVITE from the access transfer function on SRVCC, carrying Target-Dialog (RFC 4538) and Require: tdialog, naming the SCC AS's dialog with the UE (TS 24.237 §12.3.5).

call.take_over(call_id, local_tag, remote_tag, reinvite=True, headers=None) in @b2bua.on_invite runs the same takeover as Replaces. Once the handler returns, siphon answers the new INVITE 200 with the SDP the surviving party last gave, BYEs the named dialog's party (closing its leg exactly as a Replaces takeover does), keeps the surviving party's dialog, and relays between the two from then on. headers= go on that 200, checked as answer(headers=…) checks them; TS 24.237 §6A.4.3A has the SCC AS put the remote party's P-Asserted-Identity, Feature-Caps, Accept and Recv-Info there.

from siphon import b2bua

@b2bua.on_invite
def on_invite(call):
    target = call.get_header("Target-Dialog")
    if target and "tdialog" in (call.get_header("Require") or "") and from_atcf(call):
        dialog_call_id, _, rest = target.partition(";")
        params = dict(p.strip().split("=", 1) for p in rest.split(";") if "=" in p)
        # Target-Dialog names the dialog as its recipient sees it
        # (RFC 4538 §3): local-tag is siphon's tag, remote-tag the far party's.
        call.take_over(dialog_call_id.strip(),
                       local_tag=params["local-tag"],
                       remote_tag=params["remote-tag"],
                       reinvite=False,
                       headers={"P-Asserted-Identity": remote_identity(call)})
        return
    call.dial(call.ruri)

The dialog is named as siphon holds it: its Call-ID, siphon's own tag on it, and the far party's tag on it. Either leg of a call can be named.

  • The script authorises it. It works whether or not b2bua.accept_replaces is set, because calling it is the decision that switch stands in for. Knowing a dialog's identifiers is no authority to end it (RFC 3891 §5, RFC 4538 §4), so check the request first: where it came from, Require: tdialog, and your own correlation of the session.
  • Same timing as Replaces. It runs after the handler returns, in place of any routing. It is an action like dial(), so the last action of the handler wins: a later reject() or dial() replaces it. It cannot be combined with answer(), whose 2xx is already on the wire (ValueError either way round). It supersedes a takeover a Replaces on the same INVITE asked for.
  • reinvite. True re-INVITEs the surviving party with the new INVITE's offer, exactly as a Replaces takeover does (TS 24.237 §12.3.5 C a). False leaves it alone, for a new leg whose media is the old one's because an access gateway kept the ports (§12.3.5 C b); the new party is then answered with the survivor's SDP as it stands, direction included. False raises ValueError on a call siphon anchors on a media engine: the takeover moves the surviving party onto a new engine session, which only a re-INVITE can tell it. A script that anchors the INVITE after calling take_over gets the takeover refused 500 instead.
  • Refusals go on the wire once the handler returns: 481 when no dialog of this node matches, 480 when the named dialog is still early (only confirmed dialogs are taken over; TS 24.237 §12.3.2.3 answers an access transfer it cannot associate with an established dialog 480), 488 when the INVITE carries no offer, 420 for a required extension siphon does not honour. tdialog is honoured on this path.

Logging the outbound leg: b2bua.log_dial

A B2BUA call says nothing at log.level: info about where it dialled. The obvious workaround is a log.info() next to the call.dial(), and it has a real flaw: call.dial() does not dial. It records an action that the framework executes once the handler returns, so the line is written before the dial exists and still claims it when the destination fails to resolve. It also logs the string the script passed, which is not necessarily what goes on the wire — the header policy, the number policy, and LCR's tech-prefix / retarget / CLIR steps all still get a turn.

Turn the framework's own line on instead:

b2bua:
  log_dial: true      # default false
B2BUA: dialling B-leg  call_id=… b_leg_call_id=… ruri=sip:…@carrier.example
                       next_hop=Some("sip:198.51.100.7:5060")
                       destination=198.51.100.7:5060 transport=udp source=…

It is emitted from the send itself, so the R-URI is the one on the wire and b_leg_call_id is the Call-ID the far end will quote back at you. It covers every outbound INVITE — call.dial(), each call.fork() branch, each call.route() carrier attempt, and a REFER-terminate re-dial — so there is no per-call-site flag to forget on one of three dial paths.

It is off by default because it is one line per call on the busiest path siphon has, which is an operator's decision rather than an upgrade's. (The LCR failover lines log at info unconditionally — they fire only when a carrier fails, not on every call.)

Bounding a call: timeout and max_duration

A call has two clocks, and they measure different things.

timeout= bounds the ring. If nothing answers in that many seconds siphon CANCELs the outstanding legs and fires @b2bua.on_failure with 408. Unless the handler routes the call somewhere else or rejects it with a response of its own, the caller gets that 408. On a call.fork() where another branch already failed with something that outranks a timeout (a 486, say), the caller gets that failure instead (RFC 3261 §16.7, §16.8). It stops mattering the moment a 2xx lands.

On call.route() each carrier has its own timer, and it bounds the wait for that carrier to show progress (a 101-199) rather than for its answer. A carrier that has rung keeps the call up to timeout=, then the call fails with 408 instead of going to the next carrier. A sequence that ends on the ring timeout of a carrier that never sent a 101-199 fails with 503 instead, and @b2bua.on_failure gets 503: no carrier reached the callee. So does a sequence that moves on and finds none of the carriers left can be dialled, whatever the carrier before them did. See ring timeout and progress.

max_duration= bounds the talk. The clock starts at the answer, so a call that rang for 25 seconds still gets its full talk time. When it expires siphon BYEs both legs.

@b2bua.on_invite
def route(call):
    # 30s to answer, then at most an hour connected.
    call.dial(call.ruri, timeout=30, max_duration=3600)

Before this existed, an answered call was bounded by nothing but a peer BYE, a script terminate(), or an RFC 4028 session timer where one is configured and the far end honours it. A carrier leg that goes silent with its dialog still up holds a call actor, a media anchor, a charging session and an RTP port pair for as long as the process lives.

The operator backstop is the same cap applied to every call that does not ask for its own:

b2bua:
  max_call_duration_secs: 14400    # 4h; unset means uncapped

A call overrides it with max_duration=<seconds> and opts out of it entirely with max_duration=0. The kwarg is on call.dial(), call.fork() and call.route(); on a fork or an LCR sequence it is per call, not per branch or per carrier — timeout bounds each attempt's ring, but whichever one answers hands over a single answered call. A call that never dials at all — a UAS-mode call.answer(), a call.handover() — reaches the same knob through call.set_max_duration(seconds).

On expiry siphon runs the ordinary teardown: a BYE to each leg carrying Reason: Q.850;cause=102;text="Maximum call duration exceeded" (RFC 3326), a CDR with disconnect_initiator="timeout", Rf/Ro ACR-STOP, media released, StasisEnd on the control rail. No Python handler fires — @b2bua.on_bye reports which peer hung up, and here neither did, which is also how a session-timer expiry and call.terminate() behave. The CDR is the record; its sip_reason is what distinguishes a duration cut from a session-timer one.

One more timer ends an answered call the same way. siphon retransmits every 2xx it sends the caller, relayed or its own call.answer(), until the caller's ACK arrives. If none has arrived after 64×T1 (32 s), RFC 3261 §13.3.1.4 has the session ended, and siphon sends both legs a BYE with Reason: Q.850;cause=102;text="No ACK received", through the same teardown and with the same CDR disconnect_initiator="timeout". An ACK that siphon processes before the teardown runs keeps the call up.

A call that ends while its 2xx is still waiting for that ACK (the callee hangs up right after answering, or any of the teardowns above) sends the callee its BYE at once, but not the caller: RFC 3261 §15 sends no BYE on a dialog before its 2xx is ACKed. The caller keeps receiving the 2xx, and its BYE goes out right after its ACK, or at 64×T1 if the ACK never comes. The CDR, charging and media are closed when the call ends, not when that BYE goes out.

Cap how long this call may stay answered, in seconds.

The same knob as max_duration= on :meth:dial / :meth:fork / :meth:route, reachable from a call that never dials — a UAS-mode :meth:answer (IVR, announcement) or a :meth:handover — which would otherwise be stuck on the configured ceiling.

The clock starts at the answer. On expiry siphon BYEs both legs through the ordinary teardown (CDR, charging stop, media release) with Reason: Q.850;cause=102; no Python handler fires. 0 means uncapped, overriding a configured b2bua.max_call_duration_secs.

Example::

@b2bua.on_invite
def ivr(call):
    call.set_max_duration(600)     # 10 minutes, then hang up
    call.answer(200, "OK")

Limiting inbound calls: b2bua.inbound_limit

A B2BUA instance accepts every call it is sent unless told otherwise. b2bua.inbound_limit puts a ceiling on that, so there is a number to plan capacity against:

b2bua:
  inbound_limit:
    max_concurrent_calls: 20000   # calls held at once
    max_calls_per_second: 500     # new calls, with a burst of one second's worth
    reject_code: 503              # default
    retry_after_secs: 1           # default; 0 omits Retry-After

Both ceilings are unset by default, and 0 means unlimited.

The check runs on the inbound initial INVITE before @b2bua.on_invite. A call past either ceiling is answered by siphon: 503 Service Unavailable with Retry-After (RFC 3261 §21.5.4), or whatever reject_code names. No handler fires for a refused call. There is no Call, so nothing for a script to log or re-route. Two things record it:

  • a CDR with disconnect_initiator="local", the refusal's status in response_code, and refusal_scope / refusal_reason (concurrent or rate) flattened in beside the fixed fields;
  • siphon_b2bua_inbound_calls_refused_total{reason}.

Only inbound calls are refused. These still take a slot, so max_concurrent_calls stays the number of calls the instance is carrying, but are never turned away:

  • a call siphon places itself (b2bua.originate, the control plane);
  • an emergency call, whose request URI is urn:service:sos or a sub-service of it (RFC 5031). An emergency number dialled as a sip: URI is not recognised;
  • an INVITE taking over a dialog with Replaces (RFC 3891).

What to size the ceilings by:

  • max_calls_per_second counts INVITEs. A call the script challenges with auth.require_proxy_digest() arrives twice, the challenged INVITE and the retry carrying credentials, so it spends two.
  • The ceilings are per instance. N nodes behind a load balancer accept N times what is configured.
  • A 503 moves traffic. Many peers read it as "this server is down" and send everything elsewhere, for Retry-After seconds if the header is there. That is what an overloaded instance wants. reject_code: 486 or 480 is gentler.
  • The source address is not checked. Any caller can claim the emergency exemption by addressing urn:service:sos.

The limits are published as gauges (0 = unlimited), so utilisation is one division: siphon_b2bua_calls_active / siphon_b2bua_max_concurrent_calls.

Limiting one carrier: gateway.groups[].inbound_limit

The same block on a gateway group caps the calls arriving from that group:

gateway:
  groups:
    - name: "carrier-a"
      source_networks: ["203.0.113.0/24"]
      inbound_limit:
        max_concurrent_calls: 300
        max_calls_per_second: 30
        reject_code: 486
      destinations:
        - uri: "sip:gw1.carrier.example:5060"

A caller belongs to a group when its source address is one the group admits: an address its destinations resolve to, or one inside source_networks. That is the membership call.from_gateway() answers from. Calls siphon sends to the group are not counted.

Everything above holds for a group's limit too, with these differences:

  • The group is judged first. A carrier over its own limit is answered with its group's reject_code and retry_after_secs, and takes nothing from the instance. A carrier inside its own limit is still held to b2bua.inbound_limit, and that refusal is the instance's.
  • A source two limited groups admit is counted against both, and refused by whichever is full.
  • The CDR names the group: refusal_scope is gateway and gateway_group is the group's name. The counter is siphon_gateway_inbound_calls_refused_total{group,reason}, and siphon_gateway_inbound_calls_active{group} over siphon_gateway_inbound_max_concurrent_calls{group} is the carrier's utilisation. GET /admin/gateways reports the same under inbound_limit.
  • On UDP the source address can be forged, so a third party can spend a carrier's budget by sending from its address. Over TCP and TLS it cannot.
  • A changed limit applies to the calls already up. The count belongs to the group's name, so reloading a provisioned group does not restart it.

Groups a script adds with gateway.add_group() cannot carry a limit. A group provisioned through gateway.backend can: see the provisioning contract.

A retransmission of a refused INVITE is answered with the same response and is not counted again (RFC 3261 §17.2.1). A new attempt, which carries a new Via branch, is admitted or refused on its own.

Test a script against a limit with SipTestHarness.set_inbound_limit():

Enforce b2bua.inbound_limit on :meth:send_invite.

Mirrors what siphon does with::

b2bua:
  inbound_limit:
    max_concurrent_calls: 2
    max_calls_per_second: 10

A call past either ceiling is refused before @b2bua.on_invite runs: no handler fires for it, so a script cannot observe, log or re-route a refused call. The result has :attr:CallResult.was_refused set and the refusal's status in :attr:CallResult.status_code.

Two things to size the ceilings by:

  • max_calls_per_second counts INVITEs. A call the script challenges for digest credentials arrives twice (the 407, then the retry), so it spends two.
  • The ceilings are per siphon instance.

A slot is released when the call ends: :meth:send_bye, :meth:send_call_cancel, a :meth:send_failure the handler does not route again, or an on_invite that rejects, terminates or does nothing. An emergency call (request URI urn:service:sos, RFC 5031) is counted and never refused.

The rate runs on the harness's own clock, which only :meth:advance_time moves, so a test is deterministic::

harness.set_inbound_limit(max_calls_per_second=1)
assert not harness.send_invite().was_refused
assert harness.send_invite().was_refused
harness.advance_time(1.0)
assert not harness.send_invite().was_refused

Parameters:

Name Type Description Default
max_concurrent_calls Optional[int]

Calls held at once. None or 0 is unlimited.

None
max_calls_per_second Optional[int]

New calls per second, with a burst of one second's worth. None or 0 is unlimited.

None
reject_code int

Status a refused call is answered with (400-699).

503
retry_after_secs int

Retry-After on the refusal; 0 omits it.

1

Raises:

Type Description
ValueError

reject_code is not a failure response.

Headers on siphon's own responses: headers=

call.progress(), call.answer() and call.reject() send the caller a response siphon builds itself, with no B-leg response behind it for a header policy to shape. Each takes headers=, a {name: value} mapping put on that response last, replacing anything siphon put there first (Server, say). The typical use is an announcement or early-media server telling the caller's network to pass the media its 183 opens (RFC 5009 §8):

from siphon import b2bua, rtpengine

@b2bua.on_invite
async def announce(call):
    sdp = await rtpengine.answer_local(call, profile="announcement")
    call.progress(183, "Session Progress", body=sdp,
                  content_type="application/sdp",
                  headers={"P-Early-Media": "sendrecv"})
    await rtpengine.play_media(call, file="/prompts/closed.wav")
    call.reject(480, "Temporarily Unavailable",
                headers={"Reason": "SIP;cause=480", "Retry-After": "3600"})

A header siphon owns on the response raises ValueError naming it, and nothing is sent:

Headers Why they are siphon's
Via, From, To, Call-ID, CSeq copied from the INVITE, To with the dialog's tag (RFC 3261 §8.2.6.2)
Contact, Record-Route, Route, Max-Forwards the caller's remote target and route set (§12.1.1)
Content-Length, Content-Type the body framing; pass content_type=
RSeq, RAck, Require, Supported 100rel: a provisional to a caller that asked for it goes out reliably on siphon's RSeq (RFC 3262 §3)
Session-Expires, Min-SE the session timer siphon negotiates on the 2xx (RFC 4028 §9)

Compact forms (k, m, l, ...) count as their long names, and a value with a line break or NUL is refused too. The control plane's answer, ring, progress and reject verbs take the same headers object with the same rules (control plane).

The Contact of siphon's own answer: contact=

The Contact stays siphon's to build, but a script that answers the call itself can name the URI in it: call.answer() and call.progress() take contact=, a SIP or SIPS URI, and contact_params=, its header parameters. It becomes the dialog's local target, so the 2xx, an 18x, and every later request and response siphon sends the caller in that dialog carry it (RFC 3261 §12.1.1), and the caller's in-dialog requests are addressed to it. The URI must route back to siphon through the dialog's route set.

The use it is made for is a conference focus. It answers an INVITE to its factory URI with the URI of the conference it created as its Contact, with the isfocus feature parameter (RFC 4579 §5.1); the caller learns the conference URI there (§5.2), and refers the other parties to it:

call.answer(200, "OK", body=sdp, content_type="application/sdp",
            contact=f"sip:{room}@conf.example.com", contact_params="isfocus")

A URI siphon would not put in a Contact (not SIP or SIPS, in angle brackets, with anything after it) or a parameter that is not a generic-param (RFC 3261 §25.1) raises ValueError, and nothing is sent.

Hold, resume and other re-offers: @b2bua.on_reoffer

A re-INVITE or an UPDATE (RFC 3311) from either party of a bridged call renegotiates the session siphon relays between them, through the media engine when the call is anchored. @b2bua.on_reoffer sees it before it is relayed, and @b2bua.on_reoffer_answered once the offerer has its final response. Both are called with (call, offer).

It also sees an offer from the caller of a call the script answered itself with SDP the media engine holds no session for, a conference seat (rtpengine.conference_join) for one. There is no other party to relay to: offer.answer() answers it from the seat's answer with the direction the offer leaves (a hold, RFC 3264 §8.4), offer.answer(sdp=…) with the script's own, and offer.reject() refuses it. A handler that leaves it alone gets what siphon does without one: a refresh answered from the session in force, a change refused 488.

from siphon import b2bua, rtpengine

@b2bua.on_reoffer
def hold(call, offer):
    # The caller holds the callee: answer the hold here, keep the callee on
    # the media engine, and leave the callee's dialog as it is.
    if offer.kind == "hold" and offer.side == "a":
        offer.answer()

@b2bua.on_reoffer_answered
async def held(call, offer):
    if offer.kind == "hold" and offer.side == "a" and offer.status_code == 200:
        await rtpengine.play_media(call, file="/srv/moh.wav",
                                   repeat="inf", wait=False)
    elif offer.kind == "resume" and offer.status_code == 200:
        await rtpengine.stop_media(call)

What the offer says:

Attribute
offer.method "INVITE" or "UPDATE"
offer.side "a" (caller) or "b" (callee), as call.refer_side
offer.sdp the offer as sent; None for an offerless re-INVITE (the 2xx carries the other party's offer, RFC 3261 §14.1)
offer.request the request, for its headers; a header or body set on it is on the relayed request
offer.direction sendrecv / sendonly / recvonly / inactive over the streams not rejected; a 0.0.0.0 connection address receives nothing (RFC 3264 §8.4)
offer.previous_direction the same for the description that party last gave on its dialog
offer.kind "hold" (stops receiving, TS 24.610 §4.5.2), "resume", "refresh" (same streams, addresses and directions, as an RFC 4028 refresh is, even one that repeats sendonly on a held call), "change"
offer.early an UPDATE between early dialogs (RFC 3311 §5.1)

What the handler can do with it:

  • Nothing: the offer is relayed as without the hook.
  • offer.reject(code, reason) (300-699): siphon answers the offerer and relays nothing; the session stays as it was.
  • offer.answer(sdp=None): siphon answers the offerer itself and relays nothing. Without sdp the answer is the session in force toward the offerer (what siphon last sent it, the media engine's ports on an anchored call) with the formats both list and the direction the offer leaves, so a sendonly hold is answered recvonly (RFC 3264 §6.1). The media engine is not told: a two-party session cannot be answered by the engine for one side alone, and keeping it as it is keeps the other party's media on the engine, where rtpengine.play_media reaches it. An offerless re-INVITE gets that session as siphon's offer. Streams that are keyed, use ICE or are grouped cannot be answered this way and are refused 488. With sdp, that SDP is the answer. siphon puts its own o= on either (RFC 3264 §8).

Every request is answered. A handler that raises, or an async one that runs past script.handler_timeout_secs, decides nothing and the offer is relayed. A crossing offer is answered 491 Request Pending (RFC 3261 §14.1) before the script would see it. Not shown: a call siphon answered itself whose media session the engine answers for (the engine answers it), and a leg of a controller bridge. Shown, with nobody to relay to: a conference seat (above) and a call merged into a room with b2bua.adopt_replaced, whose far party's offer is answered from the session in force when the handler leaves it.

Several handlers. Every registered @b2bua.on_reoffer handler runs, in registration order, on the same offer object. Each offer.answer() or offer.reject() replaces the decision before it, so the last handler to decide wins. A handler that raises stops the ones after it, and the offer is relayed as it arrived: an earlier handler's decision is dropped with it. A script with several handlers should have each return early for calls that are not its own, a conference handler for the merged or seated calls it tracks, the ordinary hold handler for the rest:

@b2bua.on_reoffer
def conference_seats(call, offer):
    if call.call_id not in CONFERENCE_CALLS:
        return
    offer.answer()

@b2bua.on_reoffer
def hold(call, offer):
    if call.call_id in CONFERENCE_CALLS:
        return
    if offer.kind == "hold" and offer.side == "a":
        offer.answer()

@b2bua.on_reoffer_answered adds offer.status_code, offer.answer_sdp (as the offerer was sent it), offer.answer_direction and offer.answered_locally, which is true when siphon answered: the script's reject() or answer(), or a media engine that would not take the offer. It is a notification; a raise there changes nothing.

In the SDK, SipTestHarness.send_reoffer() and send_reoffer_answered() drive both handlers:

An in-dialog offer from one party of a bridged call.

Handed to @b2bua.on_reoffer before the offer is relayed and to @b2bua.on_reoffer_answered with the final response.

method property

method: str

"INVITE" for a re-INVITE, "UPDATE" for an UPDATE (RFC 3311).

side property

side: str

Which party sent the offer: "a" (caller) or "b" (callee).

request property

request: Optional[Request]

The re-INVITE or UPDATE as it arrived, for its headers.

Only in @b2bua.on_reoffer. A header or body set on it is on the request relayed to the other party. Decide the offer with :meth:reject or :meth:answer, not request.reply().

sdp property

sdp: Optional[str]

The offer as the party sent it; None for an offerless re-INVITE (the other party's 2xx then carries the offer) or an UPDATE without one.

direction property

direction: Optional[str]

"sendrecv", "sendonly", "recvonly" or "inactive" for the offer, with 0.0.0.0 counted as receiving nothing (RFC 3264 §8.4).

previous_direction property

previous_direction: Optional[str]

The same for the session in force before this offer, or None.

kind property

kind: str

"hold", "resume", "refresh" or "change", read against the session in force stream by stream (a refresh on a held call that repeats sendonly is "refresh", not a second hold). An ICE restart, new keys or a new grouping make it "change"; an attribute that renegotiates nothing, such as a=recordpref, does not.

session_sdp property

session_sdp: Optional[str]

The description siphon last sent the offering party, the session in force toward it; None before siphon has sent it one.

What :meth:answer answers from, and what a script edits when its answer differs in one attribute only (RFC 7866's a=record) on a stream :meth:answer cannot answer for itself (keyed, ICE, grouped). Repeating it answers only an offer that renegotiates nothing, which is what a "refresh" is.

early property

early: bool

Whether it is an UPDATE between early dialogs (RFC 3311 §5.1).

status_code property

status_code: Optional[int]

The final response's status, None before it.

answer_sdp property

answer_sdp: Optional[str]

The SDP the offerer was sent in the final response, if any.

answer_direction property

answer_direction: Optional[str]

Which way :attr:answer_sdp has media flow (None after a refusal).

answered_locally property

answered_locally: bool

Whether siphon answered the offer itself rather than the other party.

decision property

decision: tuple

Mock-only: ("relay",), ("reject", code, reason) or ("answer", sdp).

reject

reject(code: int, reason: str) -> None

Refuse the offer with code (300-699); nothing is relayed and the session stays as it was (RFC 3261 §14.1).

Raises:

Type Description
ValueError

for a code outside 300-699.

RuntimeError

in @b2bua.on_reoffer_answered.

answer

answer(sdp: Optional[str] = None) -> None

Answer the offer here and relay nothing.

Without sdp siphon answers from the session in force toward the offerer (the media engine's on an anchored call), with the direction the offer leaves (a sendonly hold is answered recvonly). The media engine is not told, so the other party can be played an announcement. sdp is sent as the answer instead.

Raises:

Type Description
RuntimeError

in @b2bua.on_reoffer_answered.

An announcement, then the callee

A caller that hears a prompt in early media and is then put through to the callee already holds an answer: the engine's, in siphon's own 183. A caller takes the first SDP it receives as the answer (RFC 3261 §13.2.1) and an answer changes only through a new offer (RFC 3264 §8), so the callee is connected behind the answer the caller has rather than through a new one:

from siphon import b2bua, rtpengine

@b2bua.on_invite
async def announce_then_connect(call):
    call.progress(183, "Session Progress", anchor=True, profile="announcement",
                  headers={"P-Early-Media": "sendrecv"})
    await rtpengine.play_media(call, file="/prompts/pre-call.wav")
    call.dial(call.ruri)

call.progress(anchor=True) runs rtpengine.answer_local and sends the engine's answer, like the control plane's anchored progress; profile= and ws_uri= pick the media plan. rtpengine.answer_local followed by call.progress(183, body=sdp) with that same SDP is the same thing.

What the dial does differently:

  • The callee is offered a far leg the engine binds behind the caller's answer (siphon-rtp offer_far), never the caller's own offer. It leads with the codec the caller was answered with, so a callee that takes it is a plain relay; another codec it offered is transcoded.
  • The callee's first SDP (early media or the 2xx) is that leg's answer, and the prompt stops. The caller is sent its own answer again, byte for byte with the same o=, in the 2xx and in a relayed 18x, or no SDP in an 18x once it holds the answer reliably (RFC 6337 §3.1.1, RFC 3262 §5).
  • A callee that refuses or does not answer gives its far leg back before @b2bua.on_failure runs, so the handler finds the caller on the engine again: it can play another prompt, dial() someone else, reject(), or answer() (the 2xx repeats the held answer). A sequential route() offers each carrier the far leg, afresh when an earlier carrier's SDP took it.
  • A callee that answers a codec the engine's offer did not list is released (ACK and BYE) and the call fails 488.

Two dials are refused with ValueError: a parallel fork(), since the engine connects one callee behind the answer at a time, and a dial() or route() after an early answer that is not the engine's (SDP a script took from elsewhere), since nothing can re-point that media. The engine side needs siphon-rtp 0.15.0 or later (offer_far), and a plain RTP caller (not SRTP or ICE).

MediaHandle

Returned by call.media — controls RTP anchoring for the call.

Handle for media anchoring operations on a B2BUA call.

Accessible as call.media. In the mock, anchor() and release() are recorded as actions.

is_active class-attribute instance-attribute

is_active: bool = False

True if media is currently anchored through an RTP engine.

anchor

anchor(
    engine: str = "rtpengine", profile: str = "srtp_to_rtp"
) -> None

Anchor media through an RTP engine.

Parameters:

Name Type Description Default
engine str

Engine name (currently only "rtpengine").

'rtpengine'
profile str

RTP profile — "srtp_to_rtp", "ws_to_rtp", "wss_to_rtp", or "rtp_passthrough".

'srtp_to_rtp'

release

release() -> None

Release media anchor, returning to direct RTP flow.

ByeInitiator

Identifies which side ended an answered call (surfaced on @b2bua.on_bye).

Passed to @b2bua.on_bye handlers indicating which side sent BYE.

Attributes:

Name Type Description
side str

"a" (caller) or "b" (callee).

side instance-attribute

side: str

"a" for the A-leg (caller) or "b" for the B-leg (callee).

Conference roster

Siphon keeps who is seated in each conference room (RFC 4575 needs it for its NOTIFY bodies) and a script reads it; nothing of it is the script's, so it is the same after a reload.

call.conference is (conference_id, owner_call_id) for a call seated in a room, else None. owner_call_id is the SIP Call-ID of the conference dialog that adopted the call with b2bua.adopt_replaced, None for a call seated with rtpengine.conference_join / conference_answer. It reads in @b2bua.on_bye and every other handler for as long as the seat is.

b2bua.conference_roster(conference_id) returns a list of dicts in the order the seats were taken:

Key Value
call_id the SIP Call-ID of the dialog with the seated party
from_tag the seat's From-tag
party_uri the seated party's own URI, or None
role talker, listener, muted, whisper or monitor
dtmf "events" or "forward": whether the room sends the seat's RFC 4733 digits on to the other seats
joined_at unix seconds, a float
kind "joined" (conference_join / conference_answer) or "adopted" (adopt_replaced)
owner_call_id the conference dialog that adopted the call; None for a joined seat

An unknown room is an empty list.

@b2bua.on_conference_change is called as fn(conference_id, owner_call_id, change, call_id, roster), sync or async, once for each seat that comes in (change == "joined") and once for each that goes out ("left"): a delete, conference_leave, the adopted call's BYE, a credit or duration teardown, a silent seat (media_timeout), or the conference dialog ending the calls it adopted. call_id is the seat that changed and roster the room after the change. It runs off the SIP message path from siphon's state; a raise is logged and counted, and the next change is still delivered.

b2bua.adopt_replaced(call, conference_id, role="talker", on_owner_leave="end", on_last_leave="keep"): on_last_leave="end_owner" BYEs the conference dialog (Reason: Q.850;cause=16) once the last call adopted under it has left and no seat but its own remains in the room; any other value raises ValueError.

rtpengine.conference_join(..., dtmf=None), rtpengine.conference_offer(..., dtmf=None) and b2bua.adopt_replaced(..., dtmf=None) take "events" (default) or "forward"; rtpengine.conference_route(..., dtmf=None) switches it live and None keeps the seat's setting. Any other value raises ValueError. An engine that does not list conference_dtmf refuses "forward" and an explicit route dtmf (RuntimeError, 501 for a merge) with nothing sent. conference_route on a seat the room does not hold is an error (RuntimeError).