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" | None
    #             None -> b2bua.default_refer_mode (config; default "terminate")
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.

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.

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, and warns the same way. Transfers across an SRTP or transcoding boundary want a symmetric profile on the surviving pair.

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, matches the dialog it is already tracking, bridges Bob onto it, and BYEs the now-redundant old legs.

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()          # siphon matches the replaced dialog + re-bridges

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.

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            |  match Replaces -> call 2 |               |
  |<-------------------------|                           |               |
  |                          |  re-bridge Bob <-> Carol  |               |
  |                          |<==========================|==============>|
  |  NOTIFY sipfrag 200 OK   |                           |               |
  |<-------------------------|                           |               |
  |  BYE (call 1, Alice)     |     BYE (call 2, 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). siphon honours it when matching.

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

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.

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.