Skip to content

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:

# siphon.yaml
b2bua:
  default_refer_mode: terminate    # terminate | transparent  (default terminate)

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_bye does not fire for that BYE. The call is not ending, so a handler that calls call.terminate() on every BYE cannot accidentally undo the transfer. on_bye fires 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, or dial {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: TransferRequested names the hosted dialog and the channel it is bridged with (replaces.local), and accept_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 carrying Replaces to its application as a new call. StasisStart names the hosted dialog it asked to join (replaces); the takeover described under "The other half" runs only after a script's @b2bua.on_invite admitted 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:

  1. TransferRequested arrives on bob-1. replaces.local says which hosted dialog Bob named: channel is bob-2, and bridged_with is carol, the party to connect. The party on the other side of the REFER's own call is alice, which the application knows from the bridge it made.
  2. accept_refer {mode: "controller"} on bob-1. Bob's phone gets 202 and a NOTIFY saying 100 Trying. Nothing is dialled.
  3. unbridge on alice and on carol, and wait for ChannelUnbridged on each. Then bridge alice with carol.
  4. On ChannelBridged, complete_refer {code: 200} on bob-1. Bob's phone gets the NOTIFY saying 200 OK and knows the transfer is done. On BridgeFailed, report the failure instead, for example complete_refer {code: 503}, and put the bridges back.
  5. hangup on bob-1 and bob-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.

from siphon import b2bua

@b2bua.on_refer
def on_refer(call):
    call.accept_refer(mode="transparent")

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 a call. siphon sends the REFER when the handler returns.
  • b2bua.refer(call_id, target, replaces=None) — imperative twin, for event callbacks that get a call_id but no call object, 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 the call object.
  • Media & RTP profiles — anchoring media so a siphon-terminated transfer keeps the media path on siphon, and @rtpengine.on_dtmf.
  • Call reference — the full call API.