Call transfer (REFER)¶
A B2BUA sits between two dialogs, so when one party asks to transfer the call
(a REFER, RFC 3515 / 3891 / 5589) siphon has to decide what that means for the
other leg. SIPhon gives you three modes, picked per call from a single
@b2bua.on_refer handler — plus a fourth shape, b2bua.replace_peer()
(scenario 6), for when siphon
decides to swap a party with no REFER on the wire at all:
| Mode | What siphon does | Use it for |
|---|---|---|
| siphon-terminated (default) | Answers 202 + sends the sipfrag NOTIFYs itself, dials Refer-To (or target=) as a new leg, re-bridges the surviving party, and BYEs the referred-away leg. |
Trunk-facing SBCs and media-anchored calls — the endpoints never see the transfer, media stays anchored on siphon. |
| transparent | Re-emits the REFER on the far leg's own dialog and relays the far end's 202 + message/sipfrag NOTIFYs back to the referrer. |
UA-to-UA / PBX transfers where you want the endpoints to run the transfer themselves. |
| siphon-originated | siphon sends a REFER to a leg — call.refer(target) (deferred, from a handler) or b2bua.refer(call_id, target) (imperative, from an event callback). |
IVR / TAS offload — answer, play a prompt, then hand the caller off. |
With no @b2bua.on_refer handler registered, siphon rejects every in-dialog
REFER on a tracked call locally with 603 Decline and relays nothing. That is
the loop-safe default: an in-dialog REFER on a bridged call must never be blind
proxy-relayed (it can loop back through the B2BUA), so you opt in to transfer
handling by registering the handler.
@b2bua.on_refer takes ONE argument
The handler is def on_refer(call): — one argument, no reply object. A
REFER is a request, not a response, so there is nothing to answer with
rtpengine.answer(reply). Writing def on_refer(call, reply): and calling
rtpengine.answer(reply) — the reflex from the @b2bua.on_early_media /
@b2bua.on_answer handlers — is wrong here and will fail at call time. Read
the transfer target off the call object (call.refer_to,
call.refer_replaces) and act with call.accept_refer() /
call.reject_refer().
The call object during a REFER¶
call.refer_to # str | None — the Refer-To URI
call.refer_replaces # dict | None — attended-transfer Replaces target, keys:
# {"call_id": str, "from_tag": str,
# "to_tag": str, "early_only": bool}
call.accept_refer(target=None, next_hop=None, mode=None)
# Accept the transfer.
# target= rewrite the destination (default: call.refer_to verbatim)
# next_hop= steer egress without changing the R-URI shape
# mode= "terminate" | "transparent" | "script" | None
# None -> b2bua.default_refer_mode (config; default "terminate")
# "script": 202 + NOTIFY 100 now, nothing dialled; the script
# reports with b2bua.complete_refer() (section 4b)
call.reject_refer(code, reason) # decline the transfer (e.g. 603 Decline)
Config default for mode=None:
Throughout the ladders below: Alice sip:alice@example.com (A-leg),
Bob sip:bob@example.com (B-leg), transfer target Carol
sip:+15550142@example.com, siphon at 198.51.100.1.
1. Inbound blind transfer, siphon-terminated¶
Alice and Bob are bridged by siphon with media anchored. Alice's phone starts a
blind transfer to Carol — it sends a REFER (Refer-To: Carol) inside the
Alice-leg dialog. siphon answers it itself, dials Carol as a fresh leg, bridges
Bob onto Carol, and drops Alice.
from siphon import b2bua, log
@b2bua.on_refer
def on_refer(call):
log.info(f"[{call.id}] blind transfer to {call.refer_to}")
call.accept_refer() # siphon-terminated (the config default)
One argument, no reply
The handler is def on_refer(call): — one argument, no reply object
(REFER is a request).
Alice (A-leg) siphon 198.51.100.1 Bob (B-leg) Carol (target)
| | | |
|<===== bridged, media anchored on siphon =============>| |
| | | |
| REFER Refer-To:Carol | | |
|------------------------->| | |
| 202 Accepted | | |
|<-------------------------| | |
| NOTIFY sipfrag 100 | (accept_refer default) | |
|<-------------------------| | |
| | INVITE (new leg) | |
| |------------------------------------------->|
| | 200 OK |
| |<-------------------------------------------|
| | ACK |
| |------------------------------------------->|
| | re-INVITE (re-bridge) | |
| |<==========================>| |
| NOTIFY sipfrag 200 OK | | |
|<-------------------------| | |
| BYE (referred away) | | |
|<-------------------------| | |
| 200 OK | Bob <===== bridged =====> Carol |
|------------------------->| | |
When the transferor hangs up first¶
The ladder above shows siphon BYEing the referrer once the target answers, but a
transferor is entitled to leave the moment its REFER is accepted (RFC 5589 §7),
and several real ones do — a Microsoft Teams blind transfer BYEs a few hundred
milliseconds after the 202, well before the target picks up:
Alice (referrer) siphon Carol (target)
| REFER / 202 / NOTIFY 100 | |
|------------------------->| INVITE (new leg) --------->| (ringing)
| BYE | |
|------------------------->| |
| 200 OK | |
|<-------------------------| 200 OK |
| (Alice is gone) |<---------------------------|
| | ACK ---------------------->|
| | Bob <== bridged ==> Carol
siphon treats that as the transferor leaving, not as the end of the call: the
BYE is answered 200, the surviving party stays up, and the target is dialled
through and bridged as normal. The terminating sipfrag NOTIFY and the referrer
BYE are simply skipped, because the implicit subscription died with the dialog
(RFC 3515 §2.4.4).
Two consequences worth knowing when you write handlers:
@b2bua.on_byedoes not fire for that BYE. The call is not ending, so a handler that callscall.terminate()on every BYE cannot accidentally undo the transfer.on_byefires later, when the transferred call really ends.- If the target then fails, the surviving party has nobody left to talk to, so siphon releases it and tears the call down rather than stranding it.
When the REFER arrives twice¶
A REFER over UDP is retransmitted until its final response gets through, so
the same request can arrive after siphon has already acted on it. It is the same
request (same Call-ID, CSeq and Via branch) and is answered with the same final
response again, for 32 s: @b2bua.on_refer runs once per REFER, and the
target is dialled, or the REFER relayed, once. A REFER with a new CSeq is a
new request.
When a second REFER arrives during a transfer¶
A call carries out one transfer at a time. While the target of an accepted
transfer is still ringing, and while a relayed REFER has not been answered by
the far end, a new REFER on the call (from either party) is answered
491 Request Pending and @b2bua.on_refer does not run for it: accepting it
would dial a second target for the same pair. The referrer may send it again
(RFC 3261 §21.4.27), and once the first transfer has succeeded, failed or timed
out the handler runs for a new REFER as usual.
When the target cannot be dialled¶
The 202 goes out before the target is dialled, so a target siphon can send no
INVITE to (it does not resolve, or none of its contacts can be reached) is found
out after the referrer was told the transfer is under way. The referrer is not
left waiting: its subscription is ended with a NOTIFY whose sipfrag says
503 Service Unavailable (Subscription-State: terminated;reason=noresource,
RFC 3515 §2.4.5), and the call keeps both its parties.
Alice (referrer) siphon
| REFER Refer-To: <target> |
|------------------------->|
| 202 Accepted |
|<-------------------------|
| NOTIFY sipfrag 100 |
|<-------------------------| (no INVITE can be sent)
| NOTIFY sipfrag 503 |
|<-------------------------| Alice <== still bridged ==> Bob
Rewrite the destination or steer egress without touching what the endpoints see:
@b2bua.on_refer
def on_refer(call):
# Send the new leg to a specific trunk, keep the dialled URI shape intact.
call.accept_refer(target="sip:+15550142@example.com",
next_hop="sip:trunk.example.com:5060")
Number shape across a transfer — read this before deploying on a trunk¶
A siphon-terminated transfer dials the target directly. @b2bua.on_invite
does not run again for the replacement leg, so nothing that handler does to
shape a call is repeated: not the number formatting, not the route selection.
That matters because the two ends disagree about number format by default. The
referrer names the target in its own format — a Microsoft Teams Refer-To
names +E.164 — while the carrier the new leg is dialled at expects whatever
the trunk speaks. Left alone, every ordinary call on that trunk goes out as
15550142 and every transferred one arrives as +15550142, from the same
siphon, on the same trunk, in the same call.
number_policy= closes it, resolving exactly as call.dial(number_policy=…)
does — the named policy, else b2bua.default_number_policy, else no reshaping —
and applying to the target URI (so to the triggered INVITE's R-URI and To) and
to that INVITE's identity headers:
@b2bua.on_refer
def on_refer(call):
call.accept_refer(
target=f"sip:{user}@{carrier_domain}",
next_hop=gateway.select("carriers").uri,
mode="terminate",
profile="rtp_passthrough",
number_policy="carrier-plain@2026", # same shape every dialled leg gets
)
If you shape numbers inline rather than through named policies, format= is the
same thing without the config block, exactly as on rewrite_identities():
call.accept_refer(target=target, next_hop=gw.uri, mode="terminate",
profile="rtp_passthrough", format="plain")
"e164", "plain", "international" and "national" are formats;
number_policy= wants a name from number_policies:. Pass one or the other,
never both. Set b2bua.default_number_policy and transfers pick it up with no
argument at all. An unknown name or format raises ValueError in the handler
rather than silently dialling the target unreshaped. call.dial(),
call.fork() and b2bua.replace_peer() all take the same pair.
The routing half is still the handler's own: a transfer that must leave via
a particular carrier needs next_hop= (or a target= naming the right host),
because no gateway selection runs for it either.
All of this is terminate mode. In "transparent" mode siphon dials nothing —
it re-emits the REFER and the far end resolves the target under its own
numbering — so number_policy has no effect there.
Media profiles across a transfer — read this before deploying an SRTP edge¶
A transfer re-pairs the call. A direction-bound profile does not follow.
A media profile has two halves, and for profiles like srtp_to_rtp they
describe specific sides of the call:
srtp_to_rtp:
offer: { transport_protocol: "RTP/AVP", direction: ["teams", "carrier"] }
answer: { transport_protocol: "RTP/SAVP", direction: ["carrier", "teams"] }
The answer half exists to talk to the SRTP party. A transfer moves that
party out of the call — so applying the same profile afterwards re-offers
SRTP to whoever is left, and a plain-RTP carrier answers m=audio 0.
The call connects, both parties think they are talking, and there is no
audio in either direction.
Pass profile= naming the profile for the pair that remains. The rule
is: the survivor is the peer of the referrer, and the profile describes
survivor → target. So the answer depends on which side transferred, which
call.refer_side ("a"/"b", matching on_bye's initiator.side) tells
you:
@b2bua.on_refer
def on_refer(call):
# from_gateway() answers for the A-leg; refer_side says which leg
# referred. They agree exactly when the SRTP party is the transferor.
a_leg_is_secure = call.from_gateway("teams")
referrer_is_secure = a_leg_is_secure == (call.refer_side == "a")
# The SRTP party leaving leaves two plain-RTP ends behind. The SRTP
# party SURVIVING keeps the asymmetric pairing.
profile = "rtp_passthrough" if referrer_is_secure else "srtp_to_rtp"
call.accept_refer(target=target, next_hop=gw.uri, mode="terminate",
profile=profile)
In practice the secure side is nearly always the transferor — a carrier
rarely sends REFER — but an SBC should not fall over the day one does.
That snippet assumes the target is on the plain-RTP side. It need not be: the SRTP party can transfer its caller to another user on its own side, and then the new leg has to go back out the secure trunk with the mirror profile. Neither follows from the original call, so decide both from the pair that remains — who survives, and which side the target is on:
| Survivor | Target | next_hop |
profile |
|---|---|---|---|
| plain RTP | plain RTP | carrier | rtp_passthrough |
| plain RTP | SRTP | secure trunk | rtp_to_srtp |
| SRTP | plain RTP | carrier | srtp_to_rtp |
| SRTP | SRTP | secure trunk | a symmetric SRTP profile of your own |
PROFILES = { # (survivor is secure, target is secure)
(False, False): "rtp_passthrough",
(False, True): "rtp_to_srtp",
(True, False): "srtp_to_rtp",
(True, True): "srtp_to_srtp", # custom, see the media cookbook
}
@b2bua.on_refer
def on_refer(call):
a_leg_is_secure = call.from_gateway("teams")
survivor_is_secure = a_leg_is_secure != (call.refer_side == "a")
target_is_secure = targets_teams(call.refer_to) # your own test
gw = gateway.select("teams" if target_is_secure else "carrier")
call.accept_refer(target=call.refer_to, next_hop=gw.uri, mode="terminate",
profile=PROFILES[(survivor_is_secure, target_is_secure)])
How a target is recognised as secure-side is deployment-specific: the
Refer-To naming one of the trunk's own hosts, or a number range. There is
no from_gateway() for a URI, so compare the host against
gateway.list(group) yourself. Pass such a target verbatim — its URI
parameters belong to the far end — and keep number_policy= / format= for
the carrier rows. examples/teams_sbc.py
is the complete handler.
siphon logs a WARN naming the profile when a transfer inherits a
direction-bound one, but it cannot pick the replacement for you — only the
script knows what the surviving pair looks like.
Which profiles are direction-bound? Any whose two halves differ: a different
transport_protocol (every SRTP/DTLS edge), or a direction: pair, or different
DTLS handling. The built-ins srtp_to_rtp, rtp_to_srtp, ws_to_rtp and
wss_to_rtp all are. rtp_passthrough is symmetric and re-pairs safely.
The same applies to anything else the profile pins to one side — a transcoding or codec-shaping policy chosen because that party needed it does not automatically suit the party that replaces it. If the two halves of your profile are not interchangeable, name the profile explicitly on transfer.
This is not specific to REFER: an inbound INVITE with Replaces re-pairs the
call the same way. There the profile is named the way it is for any other call,
by anchoring the INVITE in @b2bua.on_invite: the takeover runs after that
handler and completes the media session it opened, so the profile passed to
rtpengine.offer(call, profile=…) describes the new pair, the newcomer as the
offerer and the survivor as the answerer. A script that anchors every INVITE
needs nothing extra. One that leaves the taking-over INVITE unanchored makes the
takeover inherit the replaced call's profile, and siphon warns when that one is
direction-bound.
Media anchoring (terminate mode)¶
Terminate mode re-bridges the media plane by offering the surviving party's media to the transfer target and re-INVITEing the survivor with the target's answer (RFC 3261 §14), so both directions of RTP follow the transfer:
- Media-anchored (the call was anchored with rtpengine or siphon-rtp): siphon re-anchors the survivor↔target pair on a fresh media session — it offers the survivor's media to the target through the anchor, answers with the target's SDP, re-INVITEs the survivor onto the anchored session, and tears down the old survivor↔referrer anchor. The anchor stays in the media path across the transfer (LI, transcoding, NAT preserved). This is the normal production shape.
- Not anchored (a raw B2BUA where the endpoints exchange their own SDP): siphon offers the survivor's real SDP to the target and re-INVITEs the survivor with the target's answer, so media is aimed correctly end to end.
siphon also owns the SDP o= line per leg (a stable session-id with a monotonic
version), so a re-anchor presents a strictly greater version under the same
session identity and a strict RFC 3264 §8 answerer re-negotiates cleanly rather
than treating a changed offer as unchanged.
The signalling plane (202, sipfrag NOTIFYs, dialog identity, teardown) is correct in every mode. In-repo tests cover the signalling and — for anchored transfers — that the media-control commands are issued; that RTP actually bridges survivor↔target through the anchor is validated against a real media engine.
2. Inbound attended transfer (Replaces)¶
Attended transfer: Alice consults Carol on a second call first, then transfers
Bob into the Alice-Carol call with a REFER carrying a Replaces header
(RFC 3891) that names the Alice-Carol dialog. siphon reads it off
call.refer_replaces and dials the transfer target with that Replaces on the
INVITE, rewritten to the identifiers the target knows its own dialog by. The
target performs the takeover: it answers the new INVITE in place of the
consultation dialog and ends that one itself (RFC 3891 §3).
So the INVITE has to reach the target. siphon does not join two calls it hosts on its own: it dials, and the far end replaces.
A Refer-To that names siphon needs a target
A transferor builds Refer-To from the remote target of its consultation
call (RFC 5589 §7.3). Behind a B2BUA that remote target is siphon's own
Contact, so the URI names this node, not Carol. Dialled as written, siphon
INVITEs itself, the rewritten Replaces matches nothing on the way back in,
and the transfer fails 481.
Pass the target's real address: call.accept_refer(target="sip:carol@…")
from a script, or accept_refer {target} from a controller, where target
may be {aor} to reach a registered phone over the connection it holds.
An AoR with several registered contacts rings them all; the first to answer
is kept and the rest are CANCELled.
A controller finds out who that is from TransferRequested: when the
Replaces names a dialog this node hosts, replaces.local carries the
call, the channel controlling it and the leg.
A transferor that puts the target's address-of-record in Refer-To
instead needs none of this.
from siphon import b2bua, log
@b2bua.on_refer
def on_refer(call):
replaces = call.refer_replaces
if replaces:
log.info(f"[{call.id}] attended transfer replacing "
f"call_id={replaces['call_id']} "
f"from_tag={replaces['from_tag']} to_tag={replaces['to_tag']} "
f"early_only={replaces['early_only']}")
call.accept_refer() # dials Refer-To with the Replaces rewritten for it
One argument, no reply
The handler is def on_refer(call): — one argument, no reply object
(REFER is a request).
If call.refer_replaces is always None, look at the advertisement
A transferor decides whether it can offer an attended transfer by reading
Supported: replaces off the responses it gets (RFC 5589 §7.3). It is not a
negotiation you can force from the handler: a transferor that does not see
the tag falls back to a basic transfer and sends a REFER whose Refer-To
carries no Replaces at all, so the handler above sees None and dials the
target as an unrelated new call — the transferred party ends up connected
and correct, while the transferor is left holding a consultation call with
nowhere to go and usually reports the transfer as failed.
siphon advertises replaces on the A-leg 2xx, the B-leg INVITE and the 202
to a REFER, so this works by default. On the B-leg INVITE the tag is
merged into whatever Supported goes out, one your script set with
call.set_header("Supported", …) included, so setting the header wholesale
cannot drop it by accident.
Note this is the transferee half and is always on. It is separate from
b2bua.accept_replaces below, which governs the unrelated question of
whether an inbound INVITE may take one of siphon's own dialogs over.
The other half: a transferee that calls in
The flow above is siphon placing the transferred call itself. The mirror is a
transferee that calls siphon with a Replaces naming the dialog it is taking
over — which is what an endpoint does when it runs the transfer on its own.
siphon hands the existing call over: the named party is BYE'd, the caller
takes its place, and the party on the other side is re-INVITEd onto the new
media without ever seeing a new call. The named dialog may be either leg, so
both "the caller transferred it" and the everyday "answered, then transferred
it" work.
It is off unless you enable it (b2bua.accept_replaces: true). Holding a
dialog's identifiers is not authority to end that dialog — the transferor
hands them to the transferee by design, and anyone who can see unprotected
signalling reads them off the wire — so this is a capability you opt into.
Left off, a Replaces naming a dialog siphon hosts is declined 603 instead
of being ignored, so it still never turns into an unrelated second call.
That takeover runs after @b2bua.on_invite, not before. RFC 3891 §5 makes
Replaces a way to hijack a call for anyone who learns its dialog
identifiers, so it has to clear the same admission as any other INVITE: an
auth.require_proxy_digest() or a call.reject() in that handler stops the
takeover. When the handler admits it, siphon performs the handover instead of
the routing the handler asked for — an INVITE with Replaces is a request to
join an existing call, not a new one to route.
On a node with no @b2bua.on_invite that hands its calls to
control.inbound, accept_replaces is the whole admission: the takeover
runs instead of the handover, as it does after a script's call.handover().
The Replaces is rewritten for the target
The INVITE siphon triggers towards the transfer target carries the Replaces
naming the dialog to be taken over (RFC 3891 §3). The referrer names that
dialog with the identifiers of the leg facing itself, which on a B2BUA
are not the ones the target knows — so when siphon hosts the replaced call
the reference is rewritten to the far leg's Call-ID and tag pair before it
goes out. A dialog siphon does not host crosses unchanged, which is the
right best-effort behaviour when the replaced call never traversed this
node.
Alice siphon 198.51.100.1 Bob Carol
| | | |
|<==== call 1: Alice <-> Bob (bridged) ===============>| |
|<==== call 2: Alice <-> Carol (consult, tracked) =====================>|
| | | |
| REFER Refer-To:Carol | | |
| Replaces=call2 dialog | | |
|------------------------->| | |
| 202 Accepted | INVITE, Replaces=Carol's own dialog |
|<-------------------------|------------------------------------------>|
| | 200 OK (Carol replaces her dialog) |
| |<------------------------------------------|
| | re-INVITE Bob onto Carol | |
| |-------------------------->| |
| NOTIFY sipfrag 200 OK | | |
|<-------------------------| BYE (call 2, Carol's old dialog) |
| BYE (call 1, Alice) |<------------------------------------------|
|<-------------------------| | |
| | Bob <==== bridged ====> Carol |
early_only is set when the Replaces header carried the early-only
parameter — the transfer must only match a dialog still in an early (pre-2xx)
state (RFC 3891 §3). On this path siphon carries the flag through to the target,
whose match it is; on an INVITE that names one of siphon's own dialogs siphon
does the matching, and declines 486 when the dialog is confirmed.
Two cases this does not cover, so they are not read into the above:
- A call a controller bridged from two legs (
bridge, ordial {on_answer: "bridge"}) is two calls joined at the media engine, and the party that stays when one is transferred is on the other call. A siphon-terminated transfer re-pairs the legs of one call, so it does not move that party. The application does it instead:TransferRequestednames the hosted dialog and the channel it is bridged with (replaces.local), andaccept_refer {mode: "controller"}lets it re-bridge the two remaining channels and report the result. See the recipe below. - A script-free deployment (
control.inbound) hands an INVITE carryingReplacesto its application as a new call.StasisStartnames the hosted dialog it asked to join (replaces); the takeover described under "The other half" runs only after a script's@b2bua.on_inviteadmitted the INVITE.
Attended transfer between calls a controller bridged¶
Alice called in and the application bridged her to Bob. Bob then called Carol through the application too, and now wants Alice and Carol talking. That is four calls, each with a channel of its own, in two bridges:
alice <==== bridge ====> bob-1 (Bob talking to Alice)
bob-2 <==== bridge ====> carol (Bob talking to Carol)
Bob's phone sends a REFER on bob-1 whose Refer-To carries a Replaces
naming his dialog on bob-2. siphon cannot do this transfer by re-pairing
the legs of one call, because the two parties who stay are on two other
calls. The application can, and tells Bob's phone how it went:
TransferRequestedarrives onbob-1.replaces.localsays which hosted dialog Bob named:channelisbob-2, andbridged_withiscarol, the party to connect. The party on the other side of the REFER's own call isalice, which the application knows from the bridge it made.accept_refer {mode: "controller"}onbob-1. Bob's phone gets202and aNOTIFYsaying100 Trying. Nothing is dialled.unbridgeonaliceand oncarol, and wait forChannelUnbridgedon each. Thenbridgealicewithcarol.- On
ChannelBridged,complete_refer {code: 200}onbob-1. Bob's phone gets theNOTIFYsaying200 OKand knows the transfer is done. OnBridgeFailed, report the failure instead, for examplecomplete_refer {code: 503}, and put the bridges back. hanguponbob-1andbob-2.
As frames on the control connection, with the event that each step waits for:
<- event TransferRequested channel=bob-1
replaces.local = {channel: "bob-2", leg: "a", bridged_with: "carol"}
-> command accept_refer channel=bob-1 {"mode": "controller", "timeout": 30}
-> command unbridge channel=alice
-> command unbridge channel=carol
<- event ChannelUnbridged channel=alice
<- event ChannelUnbridged channel=carol
-> command bridge channel=alice {"with": "carol"}
<- event ChannelBridged channel=alice
-> command complete_refer channel=bob-1 {"code": 200}
-> command hangup channel=bob-1
-> command hangup channel=bob-2
The order of the last three commands matters. The NOTIFY that
complete_refer sends travels on bob-1's dialog, so it has to go before
bob-1 is hung up. Afterwards there is no dialog to send it in and
complete_refer answers not_found; Bob's phone never hears the transfer
worked and may keep the consultation call on hold until its own timer
runs out.
If the application takes longer than timeout (default 60 s, at most 180),
siphon reports 503 to the referrer itself and tells the application with a
TransferTimedOut event on bob-1 ({reason: "timeout", code: 503,
referrer_leg}), after which complete_refer is refused. If Bob hangs up before the
report, the subscription ends with his call and there is nothing to
report. Full rules are in the
control plane reference.
3. Inbound transparent transfer¶
Let the endpoints run the transfer. siphon re-emits the REFER on the far leg's
own dialog and relays the far end's 202 and message/sipfrag NOTIFYs back to
the referrer. Nothing is re-resolved locally — good for UA-to-UA or PBX
deployments where the far side owns the transfer logic.
One argument, no reply
The handler is def on_refer(call): — one argument, no reply object
(REFER is a request).
Alice (A-leg) siphon 198.51.100.1 Bob (B-leg)
| | |
|<===== bridged ==========>|<========= bridged =========>|
| | |
| REFER Refer-To:Carol | |
|------------------------->| REFER (re-emit on B dialog)|
| |--------------------------->|
| | 202 Accepted |
| 202 Accepted |<---------------------------|
|<-------------------------| |
| | NOTIFY sipfrag 100/200 |
| NOTIFY sipfrag 100/200 |<---------------------------|
|<-------------------------| |
| (Bob's UA now places the call to Carol itself)
4. No handler, or an explicit reject¶
With no @b2bua.on_refer handler registered, siphon answers every in-dialog
REFER on a tracked call with 603 Decline locally and egresses nothing — the
loop-safe default. You never have to write anything to be safe.
To allow transfers only from a trusted source and decline the rest, register a
handler and call reject_refer:
from siphon import b2bua
@b2bua.on_refer
def on_refer(call):
if not call.from_gateway("trusted-pbx"):
call.reject_refer(603, "Decline") # same wire result as no handler
return
call.accept_refer()
One argument, no reply
The handler is def on_refer(call): — one argument, no reply object
(REFER is a request).
Alice (A-leg) siphon 198.51.100.1
| |
| REFER Refer-To:Carol |
|------------------------->|
| 603 Decline | (no handler, or reject_refer(603,...))
|<-------------------------| nothing egresses to the far leg
| ACK |
|------------------------->|
4b. A REFER the script carries out itself¶
A call siphon answered itself has no far leg to transfer, and a REFER in its
dialog may ask for something else: a participant asking a conference focus to
add the party in its Refer-To to the conference (RFC 4579 §5.5), and staying
where it is. accept_refer(mode="script") answers 202 Accepted and sends the
first NOTIFY (SIP/2.0 100 Trying) at once, before the handler returns, and
dials nothing. The handler does the work and reports it with
b2bua.complete_refer(call_id, code, reason=None), the NOTIFY that ends the
subscription (RFC 3515 §2.4.5):
from siphon import b2bua
@b2bua.on_refer
async def on_refer(call):
call.accept_refer(mode="script", timeout=60)
code = await add_to_conference(call.refer_to) # e.g. 200, 486, 408
b2bua.complete_refer(call.call_id, code)
A second REFER on the call while the first awaits its report is answered
491 Request Pending. A script that has not reported after timeout seconds
(default 60, at most 180) is reported for with 503 Service Unavailable.
complete_refer returns False when there is nothing left to report on.
When the Refer-To carries a Replaces naming a call this same node holds (a
handset merging its calls into a conference here), await
b2bua.adopt_replaced(call, room) carries it out and resolves to the code to
report: see Merging calls this node already
holds.
5. Outbound: IVR / TAS offload¶
siphon can also originate a REFER. Answer the call, play a prompt, then hand
the caller off to Carol by REFER-ing the caller's own leg — siphon drops out and
the caller reaches Carol directly.
Two entry points:
call.refer(target, replaces=None)— deferred, from a@b2bua.*handler where you hold acall. siphon sends the REFER when the handler returns.b2bua.refer(call_id, target, replaces=None)— imperative twin, for event callbacks that get acall_idbut nocallobject, e.g.@rtpengine.on_dtmf.
Still no reply object
This is the outbound path — siphon sends the REFER, so there is no inbound
@b2bua.on_refer here. When you do handle an inbound REFER (scenarios 1-4),
remember the handler is def on_refer(call): — one argument, no reply object
(REFER is a request).
from siphon import b2bua, rtpengine
@b2bua.on_invite
async def on_invite(call):
call.answer(200, "OK") # siphon owns the leg
await rtpengine.play_media(call, file="/var/lib/siphon/prompts/menu.wav")
@rtpengine.on_dtmf
def on_dtmf(call_id, from_tag, digit, duration_ms, volume):
if digit == "1":
# Imperative — no `call` in scope here, only a call_id.
b2bua.refer(call_id, "sip:+15550142@example.com")
Caller (Alice) siphon 198.51.100.1 Carol (target)
| | |
| INVITE | |
|------------------------->| |
| 200 OK (siphon answers) | |
|<-------------------------| |
|<===== prompt / IVR media (rtpengine.play_media) ======|
| | |
| RTP DTMF "1" | |
|------------------------->| b2bua.refer(call_id, Carol)|
| REFER Refer-To:Carol | |
|<-------------------------| |
| 202 Accepted | |
|------------------------->| |
| (Alice's UA now places the call to Carol itself)
| INVITE ------------------------------------------------>|
The deferred form reads the same but fires from a handler that already holds
the call. Use it when a transfer decision is made at answer time rather than on
a later event:
@b2bua.on_answer
def on_answer(call, reply):
# Deferred: siphon sends the REFER once the leg is up and the handler returns.
call.refer("sip:+15550142@example.com")
Pass replaces= (a dict with call_id / from_tag / to_tag, optionally
early_only) to originate an attended transfer that replaces a specific
dialog.
6. No REFER at all: siphon decides (replace_peer)¶
Every scenario above starts with somebody asking. replace_peer is the case
where nobody does: the IVR has worked out where the caller should go, a
supervisor takes a call over, a controller moves the caller off an AI and onto a
human. There is no REFER on the wire in either direction.
It runs the same machinery as scenario 1 — dial the target as a new leg on the
call, re-anchor the surviving party's media onto it, promote it into the pair
when it answers, BYE the leg it replaced — minus the 202 and the sipfrag
NOTIFYs, because there is no referrer to send them to.
from siphon import b2bua, rtpengine
@rtpengine.on_dtmf
def zero_for_an_operator(call_id, from_tag, digit, duration_ms, volume):
if digit != "0":
return
# The caller stays connected to the IVR while the operator's phone rings.
b2bua.replace_peer(call_id, "sip:operator@pbx.example", timeout=45)
Alice (caller) siphon (B2BUA) IVR Operator
| ==== talking ========== |========================| |
| DTMF "0" | | |
|------------------------->| replace_peer | |
| | INVITE --------------------------->|
| (still hears the IVR)| | 180 |
| |<-----------------------------------|
| | | 200 OK |
| |<-----------------------------------|
| | ACK ------------------------------>|
| | BYE ----------------->| |
| | 200 OK <--------------| |
| re-INVITE (new media) | | |
|<-------------------------| | |
| 200 OK / ACK | | |
|========================> |==================================> |
Why not just hang up and re-INVITE? That is the obvious hand-rolled version
and it is worse in two ways that only show up in production. The caller hears
dead air for the whole ring, because the IVR leg is gone before the operator 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.
A target that rejects, or never answers before timeout, leaves the call
exactly as it was — the IVR is still there and the caller never knew. That is
also why timeout matters: the target here is a human, and "nobody picked up" is
an ordinary outcome, not an error case.
A target no INVITE can be sent to is refused on the spot (bad_request, a
ValueError in a script), with nothing dialled and nothing left pending on the
call, so another replace_peer can be tried right away.
replace_a_leg=True reverses the direction (replace the caller, keep the
callee). Pass profile= when the call is anchored with a direction-bound media
profile, for the same reason accept_refer(profile=…) needs it in scenario 1:
the inherited profile describes the party that is leaving.
Out of process, the control plane has the same verb — see
replace_peer,
whose outcome arrives as a PeerReplaced / ReplaceFailed event rather than in
the reply.
7. Proxy mode (passthrough)¶
Everything above is B2BUA (@b2bua.*). In proxy mode there is nothing to do:
a REFER is an ordinary in-dialog request, so as long as siphon record-routed the
dialog-forming INVITE it loose-routes the REFER to the far end and relays the far
end's 202 + message/sipfrag NOTIFYs straight back. The transfer subscription
lives directly between the two endpoints; siphon owns no transfer state and the
@b2bua.on_refer handler never fires. The default proxy script already does this
with the generic in-dialog branch:
@proxy.on_request
def route(request):
# ... out-of-dialog handling (auth, registrar lookup, record_route) ...
if request.in_dialog:
if request.loose_route():
request.relay() # REFER, NOTIFY, BYE all take this path
else:
request.reply(404, "Not Here")
return
Alice (referrer) siphon (proxy) Bob (transferee)
| INVITE (record-route) | |
|-------------------------->|--------------------------->|
| 200 OK / ACK | 200 OK / ACK |
|<========================>|<==========================>|
| REFER (Route: siphon) | |
|-------------------------->| loose-route to Bob |
| |--------------------------->|
| 202 Accepted | 202 Accepted |
|<--------------------------|<---------------------------|
| NOTIFY sipfrag ... | NOTIFY sipfrag ... |
|<--------------------------|<---------------------------|
| BYE / 200 | BYE / 200 |
|<========================>|<==========================>|
The REFER is loose-routed to the far end exactly once and never proxy-relayed
by Request-URI, so it cannot loop (this is the failure that motivated intercepting
REFER on tracked B2BUA calls in the first place). siphon does not re-anchor media
or re-resolve Refer-To in proxy mode; if you need any of that, run the call
through the B2BUA and use one of the modes above.
See also¶
- SBC (B2BUA) — the
@b2bua.*handlers and thecallobject. - Media & RTP profiles — anchoring media so a siphon-terminated
transfer keeps the media path on siphon, and
@rtpengine.on_dtmf. - Call reference — the full
callAPI.