Jingle Calls and Jitsi
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
WebRTC leaves signalling to the application (see Signaling and Perfect Negotiation), and XMPP already has a standard for it: Jingle. If the Chatterbox users are on an XMPP server, calling can reuse the same connection, the same contacts and the same presence, with a standard signalling that other XMPP clients understand. This page explains the Jingle family of specifications, shows how a WebRTC SDP offer maps onto Jingle XML (with a converter that handles a real Chrome offer), covers ringing on all devices with Jingle Message Initiation, gets TURN credentials from the XMPP server with External Service Discovery, and describes how Jitsi Meet builds a conference on Prosody, multi-user chat and Jingle. The Jitsi stack itself, its Docker setup and its APIs are on Janus and Jitsi and are not repeated here.
Statuses were read from the XEP list on 2026-10-24: XEP-0166 Jingle 1.1.2, XEP-0167 Jingle RTP Sessions 1.2.3, XEP-0176 Jingle ICE-UDP Transport Method 1.1.1 and XEP-0320 Use of DTLS-SRTP in Jingle Sessions 1.0.0 are Draft; XEP-0215 External Service Discovery 1.0.0 is Draft; XEP-0353 Jingle Message Initiation 0.8.0 and XEP-0482 Call Invites 0.1.0 are Experimental. The XMPP server is ejabberd 26.09 and the browser client libraries are those of XMPP in the Browser.
The Jingle family
Jingle is a framework for negotiating a media or file session between two XMPP entities. The framework defines the session; separate specifications define what is carried and how it is transported:
| Specification | Role | Status |
|---|---|---|
XEP-0166 Jingle |
The session: |
Draft |
XEP-0167 Jingle RTP Sessions |
The application: an RTP |
Draft |
XEP-0176 Jingle ICE-UDP Transport |
The transport: ICE username fragment and password, and |
Draft |
XEP-0320 DTLS-SRTP in Jingle |
Key exchange: a DTLS |
Draft |
XEP-0338 Jingle Grouping |
The |
Draft |
XEP-0293 and XEP-0294 |
RTCP feedback and RTP header extensions. |
Draft |
XEP-0353 Jingle Message Initiation |
Ringing: a |
Experimental |
XEP-0215 External Service Discovery |
STUN and TURN services and credentials from the server. |
Draft |
XEP-0482 Call Invites |
Invitation to a call that may be joined by Jingle or by an external URI. |
Experimental |
A session has one or more contents (an audio content, a video content). Each content pairs a description (what is sent:
codecs) with a transport (how: ICE candidates and the DTLS fingerprint), and a senders attribute with the direction
(both, initiator, responder, none).
Jingle and SDP
A WebRTC SDP offer carries the same information as a Jingle session-initiate, in a different syntax. The mapping of the
terms:
| SDP | Jingle |
|---|---|
An |
|
|
The |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The converter below turns a browser SDP into a Jingle jingle element, with the @xmpp/client xml() builder. It handles
audio and video media sections, payload types and their parameters, RTCP feedback, header extensions, ICE credentials,
the DTLS fingerprint, UDP candidates and bundles. It does not handle simulcast, SSRC attributes or data channels, which Jitsi
carries in additional extensions.
import { xml } from "@xmpp/client";
const NS = {
jingle: "urn:xmpp:jingle:1",
rtp: "urn:xmpp:jingle:apps:rtp:1",
hdrext: "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0",
rtcpfb: "urn:xmpp:jingle:apps:rtp:rtcp-fb:0",
ice: "urn:xmpp:jingle:transports:ice-udp:1",
dtls: "urn:xmpp:jingle:apps:dtls:0",
group: "urn:xmpp:jingle:apps:grouping:0",
};
const SENDERS = { sendrecv: "both", sendonly: "initiator", recvonly: "responder", inactive: "none" };
function parseSections(sdp) {
const lines = sdp.split(/\r?\n/).filter(Boolean);
const session = { attrs: [] };
const media = [];
let cur = session;
for (const line of lines) {
if (line.startsWith("m=")) { cur = { m: line.slice(2).split(" "), attrs: [] }; media.push(cur); }
else if (line.startsWith("a=")) cur.attrs.push(line.slice(2));
}
return { session, media };
}
const attr = (sec, name) => sec.attrs.find((a) => a === name || a.startsWith(`${name}:`))?.slice(name.length + 1);
const attrs = (sec, name) => sec.attrs.filter((a) => a.startsWith(`${name}:`)).map((a) => a.slice(name.length + 1));
function candidate(line) {
const [foundation, component, protocol, priority, ip, port, , type, ...rest] = line.split(" ");
const out = { component, foundation, generation: "0", id: `c-${foundation}`, ip, network: "0", port, priority, protocol: protocol.toLowerCase(), type };
for (let i = 0; i < rest.length; i += 2) {
if (rest[i] === "raddr") out["rel-addr"] = rest[i + 1];
if (rest[i] === "rport") out["rel-port"] = rest[i + 1];
if (rest[i] === "generation") out.generation = rest[i + 1];
}
return out;
}
export function sdpToJingle(sdp, { sid, initiator, action = "session-initiate" }) {
const { session, media } = parseSections(sdp);
const jingle = xml("jingle", { xmlns: NS.jingle, action, sid, initiator });
const bundle = attr(session, "group")?.split(" ");
if (bundle?.[0] === "BUNDLE") {
jingle.append(xml("group", { xmlns: NS.group, semantics: "BUNDLE" }, ...bundle.slice(1).map((name) => xml("content", { name }))));
}
for (const m of media) {
const [kind, , , ...pts] = m.m;
const name = attr(m, "mid");
const direction = ["sendrecv", "sendonly", "recvonly", "inactive"].find((d) => m.attrs.includes(d)) ?? "sendrecv";
const description = xml("description", { xmlns: NS.rtp, media: kind });
for (const pt of pts) {
const [codec, clockrate, channels] = (attrs(m, "rtpmap").find((r) => r.startsWith(`${pt} `)) ?? "").slice(pt.length + 1).split("/");
const payload = xml("payload-type", { id: pt, ...(codec && { name: codec, clockrate }), ...(channels && { channels }) });
const fmtp = attrs(m, "fmtp").find((f) => f.startsWith(`${pt} `));
for (const kv of (fmtp ?? "").slice(pt.length + 1).split(";").filter(Boolean)) {
const [k, v = ""] = kv.trim().split("=");
payload.append(xml("parameter", { name: k, value: v }));
}
for (const fb of attrs(m, "rtcp-fb").filter((f) => f.startsWith(`${pt} `))) {
const [, type, subtype] = fb.split(" ");
payload.append(xml("rtcp-fb", { xmlns: NS.rtcpfb, type, ...(subtype && { subtype }) }));
}
description.append(payload);
}
if (m.attrs.includes("rtcp-mux")) description.append(xml("rtcp-mux"));
for (const e of attrs(m, "extmap")) {
const [idPart, uri] = e.split(" ");
description.append(xml("rtp-hdrext", { xmlns: NS.hdrext, id: idPart.split("/")[0], uri, senders: "both" }));
}
const [hash, fingerprint] = (attr(m, "fingerprint") ?? "").split(" ");
const transport = xml("transport", { xmlns: NS.ice, ufrag: attr(m, "ice-ufrag"), pwd: attr(m, "ice-pwd") });
if (fingerprint) transport.append(xml("fingerprint", { xmlns: NS.dtls, hash, setup: attr(m, "setup") }, fingerprint));
for (const c of attrs(m, "candidate").map(candidate)) {
if (c.protocol === "udp") transport.append(xml("candidate", c)); // the ice-udp transport carries UDP candidates only
}
jingle.append(xml("content", { creator: "initiator", name, senders: SENDERS[direction] }, description, transport));
}
return jingle;
}
Source: XEP-0167: Jingle RTP Sessions and
XEP-0176: Jingle ICE-UDP Transport Method. A headless Chrome offer for one
audio track (launched with --use-fake-device-for-media-stream --use-fake-ui-for-media-stream) produced the following
(addresses replaced with documentation addresses, and the list of payload types shortened):
<jingle xmlns="urn:xmpp:jingle:1" action="session-initiate" sid="a73sjjvkla37jfea" initiator="alice@chat.example.test/web">
<group xmlns="urn:xmpp:jingle:apps:grouping:0" semantics="BUNDLE"><content name="0"/></group>
<content creator="initiator" name="0" senders="both">
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio">
<payload-type id="111" name="opus" clockrate="48000" channels="2">
<parameter name="minptime" value="10"/>
<parameter name="useinbandfec" value="1"/>
<rtcp-fb xmlns="urn:xmpp:jingle:apps:rtp:rtcp-fb:0" type="transport-cc"/>
</payload-type>
<payload-type id="9" name="G722" clockrate="8000"/>
<payload-type id="0" name="PCMU" clockrate="8000"/>
<rtcp-mux/>
<rtp-hdrext xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" id="1" uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" senders="both"/>
</description>
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1" ufrag="LSox" pwd="change-me-ice-pwd">
<fingerprint xmlns="urn:xmpp:jingle:apps:dtls:0" hash="sha-256" setup="actpass">5C:9B:DB:80:DD:B2:E9:4C:...:55:30:EF</fingerprint>
<candidate component="1" foundation="409278917" generation="0" id="c-409278917" ip="192.0.2.10" network="0" port="62131" priority="2122194687" protocol="udp" type="host"/>
</transport>
</content>
</jingle>
The converter’s output is wrapped in an iq of type set addressed to the callee’s full JID, and each side acknowledges
every Jingle iq with an empty iq of type result (Jingle is a request and response protocol on top of XMPP, unlike
the signalling of the custom Chatterbox protocol). The reverse direction, Jingle to SDP, is the same table read backwards,
and is what the answerer does before calling setRemoteDescription. Production code usually reuses a tested converter such
as the one in lib-jitsi-meet, because the corner cases (codec parameters, SSRC groups, simulcast, header extension
ids that must stay stable) are numerous.
A Jingle call
-
Ringing, with Jingle Message Initiation. Without it, the caller has to guess which of the callee’s devices to send the
session-initiateto. With XEP-0353 the caller sends amessage(typechat, with astorehint) containing aproposeelement with a unique id and onedescriptionper media. The server delivers it to all the callee’s resources through carbons and to push-notification resources, so every device rings. The device the user answers on sendsproceed; the others receive that and stop ringing. Other outcomes arereject,retractby the caller before an answer, andfinishwith a reason when the call ends, which also leaves a record in the archive of every device. -
Session setup. The caller then sends the Jingle
session-initiateto the callee’s full JID, using theidfrom theproposeas thesid. The callee answers withsession-accept, which carries its own SDP as Jingle. -
Connectivity. Candidates found later travel in
transport-info; the peers run ICE and then DTLS-SRTP exactly as in the WebRTC pages (ICE, STUN and TURN). -
Termination.
session-terminatewith a reason such assuccess,busy,declineorconnectivity-error.
A message proposing a call looks like this:
<message to="bob@chat.example.test" type="chat">
<propose xmlns="urn:xmpp:jingle-message:0" id="ca3cf894-5325-482f-a412-a6e9f832298d">
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"/>
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="video"/>
</propose>
<store xmlns="urn:xmpp:hints"/>
</message>
Source: XEP-0353: Jingle Message Initiation and
XEP-0166: Jingle. Call Invites (XEP-0482) is a newer, related mechanism: an
invite element in the urn:xmpp:call-invites:0 namespace invites someone to a call that can be joined by Jingle or
through an external URI (a Jitsi room link, for example), and it defines how to accept, reject, retract and leave. It
suits group calls and calls hosted on a conferencing service, where JMI’s one-to-one model does not fit.
TURN credentials from the server
A client needs STUN and TURN servers for the RTCPeerConnection. Instead of hard-coding them, an XMPP client asks the
server with External Service Discovery:
import { xml } from "@xmpp/client";
// XEP-0215: ask the server for STUN and TURN services and turn them into RTCPeerConnection iceServers.
export async function getIceServers(xmpp, domain) {
const res = await xmpp.iqCaller.request(
xml("iq", { type: "get", to: domain }, xml("services", { xmlns: "urn:xmpp:extdisco:2" })), 10000);
const iceServers = [];
for (const s of res.getChild("services", "urn:xmpp:extdisco:2").getChildren("service")) {
const { type, host, port, transport = "udp", username, password } = s.attrs;
if (type !== "stun" && type !== "turn") continue;
const scheme = transport === "tls" ? `${type}s` : type;
const url = `${scheme}:${host}${port ? `:${port}` : ""}${transport === "tcp" ? "?transport=tcp" : ""}`;
iceServers.push(type === "turn" ? { urls: url, username, credential: password } : { urls: url });
}
return iceServers;
}
Source: XEP-0215: External Service Discovery. With the mod_stun_disco module
configured as on the server page, the request returned a stun service and a turn service on turn.example.test:3478;
the TURN service carried a username and password generated for the session, with an expires timestamp about twelve hours
later, and was marked restricted="true". The username has the form timestamp:identifier, the shape of the
time-limited credentials of a TURN REST service that uses a shared secret, which is the scheme coturn supports with
use-auth-secret (see ICE, STUN and TURN for the coturn side and the
ephemeral credentials endpoint). Refresh the credentials before they expire, and request them again for each call.
Jitsi Meet on XMPP
Jitsi Meet is the largest deployment of Jingle in the browser, and shows how the pieces combine in a conference:
-
Prosody is the XMPP server. The browser connects to it over WebSocket (or BOSH) using lib-jitsi-meet, which is built on Strophe.js (see XMPP in the Browser).
-
Every conference is a multi-user chat room. Joining the room is how a participant announces itself and learns who else is there; the chat in the conference is the room’s chat.
-
Jicofo, the conference focus, joins the room as a participant. When a user enters, Jicofo allocates a channel on a Jitsi Videobridge and sends the user a Jingle
session-initiatewhose contents describe the bridge’s media and transport. The browser answers withsession-accept, and media then flows between the browser and the bridge. -
lib-jitsi-meet converts between the Jingle that arrives over XMPP and the SDP that the browser’s
RTCPeerConnectionneeds, in both directions, which is the converter shown above at production scale. -
With two participants the browsers may connect directly (a peer-to-peer Jingle session between them); a third participant causes the session to move to the bridge.
-
The bridges report their load to Jicofo through a MUC room (the bridge brewery).
This arrangement is why a Jitsi deployment needs an XMPP server and why signalling in Jitsi is not a custom WebSocket protocol. For the deployment, the Docker quickstart, the IFrame API and the comparison with Janus and an SFU you build, see Janus and Jitsi.
Jingle or your own signalling
Use Jingle when the users are already on XMPP, calls should ring on all their devices through carbons and push, and interoperability with other XMPP clients matters. The signalling then costs an XML conversion layer, but no new server. Use a custom signalling protocol (see Signaling and Perfect Negotiation) when the web application owns both ends, when you use an SFU such as mediasoup or LiveKit (which bring their own protocols), or when you do not want to carry an XMPP server. Converse.js does not provide calls by itself (see Converse.js); the common ways to add calls to an XMPP chat are to embed a Jitsi room, to use a call invite that opens a Jitsi link, or to build the Jingle client with the tools above.
Common errors and pitfalls
-
Sending a Jingle
iqwithout acknowledging the peer’s. Every Jingleiqneeds aniqresult, or the peer retries and eventually terminates the session. -
Putting TCP candidates into an ICE-UDP transport. The converter keeps only UDP candidates; TCP and TLS candidates need their own transport extensions and are usually left to TURN.
-
Changing header extension or payload-type ids between offer and answer. The ids are negotiated, not free.
-
Ringing one device only. Sending the
session-initiateto a bare JID or a guessed full JID; use JMI. -
TURN credentials reused for hours. Request fresh credentials per call and treat the
expiresvalue as a hard limit. -
No TURN at all. Symmetric NATs and corporate firewalls break direct media, so a call on XMPP needs the same relay as any other WebRTC call.
-
Assuming every client supports Jingle video. Check the capabilities (
urn:xmpp:jingle:apps:rtp:video) of the contact’s resources before offering video.