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")

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']})"
        )

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 Microsoft Teams — bridge to the PBX.
        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
) -> 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. 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) -> 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

Example::

call.reject(486, "Busy Here")

answer

answer(
    code: int,
    reason: str,
    body: Union[str, bytes, None] = None,
    content_type: 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

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)

progress

progress(
    code: int,
    reason: str = "Ringing",
    body: Union[str, bytes, None] = None,
    content_type: 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

Example::

call.progress(183, "Session Progress", body=sdp, content_type="application/sdp")

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 SIP URI — drives the B-leg R-URI.

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.

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). Each entry is a full route value, e.g. "<sip:scscf.ims.example.com:6060;lr>" — pass the list returned by registration.service_route(impu).

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()

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,
) -> 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 a Teams/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 Teams Refer-To 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")

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. 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.

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

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)

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")

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).