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.
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
¶
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
¶
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
¶
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))
flow
property
¶
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
¶
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.
local_tag
property
¶
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
¶
Handle for media anchoring operations.
Example::
call.media.anchor(engine="rtpengine", profile="wss_to_rtp")
call.media.release()
refer_to
property
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Every ro_authorize call made on this call (for test assertions).
max_duration
property
¶
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
¶
List of (name, value) tuples stashed via
:meth:set_charging_param. Test helper.
from_gateway
¶
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
|
|
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
¶
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. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
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 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 |
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 ( |
None
|
content_type
|
str | None
|
Content-Type for the body (e.g. |
None
|
headers
|
dict[str, str] | None
|
Optional |
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
|
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 ( |
None
|
content_type
|
str | None
|
Content-Type for the body (e.g. |
None
|
headers
|
dict[str, str] | None
|
Optional |
None
|
contact
|
str | None
|
Optional SIP or SIPS URI siphon advertises as its Contact
on the early dialog and, unless |
None
|
contact_params
|
str | None
|
Optional Contact header parameters for |
None
|
anchor
|
bool
|
Answer the caller's offer on the media engine
( |
False
|
profile
|
str | None
|
Media profile for |
None
|
ws_uri
|
str | None
|
WebSocket bridge URI for |
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 |
required |
on_lost
|
str | None
|
What to do if the owning connection is lost mid-call —
|
None
|
deadline_ms
|
int | None
|
Handoff deadline in milliseconds; |
None
|
vars
|
dict[str, str] | None
|
Per-call variables seeded into the control channel, readable +
writable by the app via |
None
|
answer
|
bool
|
Answer-first (AI-park) mode. When |
False
|
profile
|
str | None
|
Answer-first only — the media profile to anchor with
(default |
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 |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
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 |
required |
timeout
|
int
|
How long the B-leg may ring before siphon gives up,
in seconds. On expiry siphon CANCELs, fires
|
30
|
max_duration
|
Optional[int]
|
How long the call may stay answered, in seconds.
A different clock from |
None
|
next_hop
|
Optional[str]
|
Optional routing destination. When set, the new
INVITE's R-URI is still built from |
None
|
flow
|
Optional['Flow']
|
Captured inbound :class: |
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 |
None
|
copy
|
Optional[list[str]]
|
Per-call delta — headers to copy verbatim regardless of
the preset's default verb (e.g. |
None
|
strip
|
Optional[list[str]]
|
Per-call delta — headers to strip regardless of the
preset's default verb (e.g. |
None
|
translate
|
Optional[list[tuple[str, str]]]
|
Per-call delta — |
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): |
None
|
send_socket
|
Optional[str]
|
Optional egress socket pin
( |
None
|
auth_passthrough
|
bool
|
Relay B-leg authentication to the caller
end-to-end instead of siphon answering it (RFC 3261 §22.3).
When |
False
|
number_policy
|
Optional[str]
|
Named E.164 number policy (from |
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: |
required |
strategy
|
str
|
|
'parallel'
|
timeout
|
int
|
Per-branch ring timeout in seconds. On expiry siphon
CANCELs the branches still ringing and answers the caller
|
30
|
max_duration
|
Optional[int]
|
Cap on how long the call may stay answered, in
seconds — same semantics as :meth: |
None
|
header_policy
|
Optional[str]
|
Header policy applied to every branch of the fork —
same semantics as :meth: |
None
|
copy
|
Optional[list[str]]
|
Per-call header copy deltas — same semantics as :meth: |
None
|
strip
|
Optional[list[str]]
|
Per-call header strip deltas — same semantics as :meth: |
None
|
translate
|
Optional[list[tuple[str, str]]]
|
Per-call header translation deltas — same semantics as
:meth: |
None
|
send_socket
|
Optional[str]
|
Optional egress socket pin applied to every branch
(same |
None
|
auth_passthrough
|
bool
|
Relay B-leg authentication to the caller
end-to-end — same semantics as :meth: |
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: |
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
|
30
|
max_duration
|
Optional[int]
|
Cap on how long the call may stay answered, in
seconds — same semantics as :meth: |
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 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
|
headers
|
dict[str, str] | None
|
Optional |
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
¶
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: |
None
|
next_hop
|
Optional[str]
|
Optionally steer egress to a specific next-hop, exactly
like |
None
|
mode
|
Optional[str]
|
How siphon honours the REFER:
The new leg is dialled directly: |
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 Direction-bound built-ins: |
None
|
number_policy
|
Optional[str]
|
Reshape the transferred leg's number, exactly as
Without it the target goes out in whatever shape the
referrer named it in. A Terminate mode only: in |
None
|
format
|
Optional[str]
|
The inline form of the same thing, exactly as on
:meth: |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
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 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
¶
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 |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
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
¶
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: |
'b2bua'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
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
¶
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 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 the user part of the Request-URI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New user part (e.g. |
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
¶
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))
Frombecomes"Anonymous" <sip:anonymous@anonymous.invalid>, keeping its dialog tag.Privacy: idis asserted (RFC 3325 §7), appended to any existingPrivacyvalue rather than replacing it.P-Asserted-Identityis 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-Identityis 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
¶
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 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. |
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 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. |
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
¶
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. |
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
¶
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. |
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
¶
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.
|
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
¶
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.
|
required |
set_contact_user
¶
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
¶
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. |
required |
get_header
¶
Get the first value of a header (case-insensitive).
set_header
¶
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
¶
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, |
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
¶
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_headers_matching
¶
Remove all headers whose name starts with a prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
str
|
Prefix string (e.g. |
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 asapplication/sdp(any backend);body=…, content_type=…— the same slot with the type spelled out, for an INVITE whose offer travels as one part of amultipart/*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), sortpengine.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]
|
|
None
|
privacy
|
Optional[str]
|
|
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 |
None
|
media
|
bool
|
True to have siphon anchor the leg on the media backend. |
False
|
profile
|
Optional[str]
|
media profile for |
None
|
ws_uri
|
Optional[str]
|
per-call WebSocket bridge URI for |
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, |
None
|
content_type
|
Optional[str]
|
Content-Type for |
None
|
session_timer
|
Optional[dict]
|
the RFC 4028 session timer to run on the call, over
the |
None
|
route
|
Optional[list]
|
a pre-loaded route set (RFC 3261 §8.1.2), e.g.
|
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 |
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'
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True once the bridge has been accepted and put in motion. |
Raises:
| Type | Description |
|---|---|
ValueError
|
an |
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 |
'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: |
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: |
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: |
None
|
format
|
Optional[str]
|
the inline form of the same thing, as on
|
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 |
|
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 ( |
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_replacesis 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 likedial(), so the last action of the handler wins: a laterreject()ordial()replaces it. It cannot be combined withanswer(), whose2xxis already on the wire (ValueErroreither way round). It supersedes a takeover aReplaceson the same INVITE asked for. reinvite.Truere-INVITEs the surviving party with the new INVITE's offer, exactly as aReplacestakeover does (TS 24.237 §12.3.5 C a).Falseleaves 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.FalseraisesValueErroron 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 callingtake_overgets the takeover refused500instead.- Refusals go on the wire once the handler returns:
481when no dialog of this node matches,480when 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 dialog480),488when the INVITE carries no offer,420for a required extension siphon does not honour.tdialogis 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: 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:
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 inresponse_code, andrefusal_scope/refusal_reason(concurrentorrate) 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:sosor a sub-service of it (RFC 5031). An emergency number dialled as asip:URI is not recognised; - an INVITE taking over a dialog with
Replaces(RFC 3891).
What to size the ceilings by:
max_calls_per_secondcounts INVITEs. A call the script challenges withauth.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-Afterseconds if the header is there. That is what an overloaded instance wants.reject_code: 486or480is 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_codeandretry_after_secs, and takes nothing from the instance. A carrier inside its own limit is still held tob2bua.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_scopeisgatewayandgateway_groupis the group's name. The counter issiphon_gateway_inbound_calls_refused_total{group,reason}, andsiphon_gateway_inbound_calls_active{group}oversiphon_gateway_inbound_max_concurrent_calls{group}is the carrier's utilisation.GET /admin/gatewaysreports the same underinbound_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_secondcounts 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
|
max_calls_per_second
|
Optional[int]
|
New calls per second, with a burst of one
second's worth. |
None
|
reject_code
|
int
|
Status a refused call is answered with (400-699). |
503
|
retry_after_secs
|
int
|
|
1
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
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. Withoutsdpthe 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 asendonlyhold is answeredrecvonly(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, wherertpengine.play_mediareaches 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 refused488. Withsdp, that SDP is the answer. siphon puts its owno=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.
request
property
¶
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
¶
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
¶
"sendrecv", "sendonly", "recvonly" or "inactive" for the
offer, with 0.0.0.0 counted as receiving nothing (RFC 3264 §8.4).
previous_direction
property
¶
The same for the session in force before this offer, or None.
kind
property
¶
"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
¶
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.
answer_sdp
property
¶
The SDP the offerer was sent in the final response, if any.
answer_direction
property
¶
Which way :attr:answer_sdp has media flow (None after a refusal).
answered_locally
property
¶
Whether siphon answered the offer itself rather than the other party.
decision
property
¶
Mock-only: ("relay",), ("reject", code, reason) or
("answer", sdp).
reject
¶
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 |
answer
¶
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 |
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_failureruns, so the handler finds the caller on the engine again: it can play another prompt,dial()someone else,reject(), oranswer()(the 2xx repeats the held answer). A sequentialroute()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
¶
True if media is currently anchored through an RTP engine.
anchor
¶
Anchor media through an RTP engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
str
|
Engine name (currently only |
'rtpengine'
|
profile
|
str
|
RTP profile — |
'srtp_to_rtp'
|
ByeInitiator¶
Identifies which side ended an answered call (surfaced on @b2bua.on_bye).
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).