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: session-initiate, session-accept, session-terminate, content-add, content-modify, content-remove, transport-info, and others, sent as iq stanzas with a jingle element.

Draft

XEP-0167 Jingle RTP Sessions

The application: an RTP description with payload-type codecs, rtcp-mux and header extensions.

Draft

XEP-0176 Jingle ICE-UDP Transport

The transport: ICE username fragment and password, and candidate elements over UDP.

Draft

XEP-0320 DTLS-SRTP in Jingle

Key exchange: a DTLS fingerprint and setup role inside the transport.

Draft

XEP-0338 Jingle Grouping

The BUNDLE grouping of contents onto one transport.

Draft

XEP-0293 and XEP-0294

RTCP feedback and RTP header extensions.

Draft

XEP-0353 Jingle Message Initiation

Ringing: a message that proposes a session before the Jingle iq.

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 m=audio media section, with a=mid

content with name, and description media="audio"

a=sendrecv, a=sendonly, a=recvonly

The senders attribute with the value both, initiator or responder

a=rtpmap:111 opus/48000/2 and a=fmtp:111 minptime=10

payload-type id="111" name="opus" clockrate="48000" channels="2" with parameter children

a=rtcp-fb:111 transport-cc

rtcp-fb element (XEP-0293) in the payload type

a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level

rtp-hdrext id="1" uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level" (XEP-0294)

a=rtcp-mux

rtcp-mux in the description

a=ice-ufrag, a=ice-pwd

ufrag and pwd attributes of the ICE-UDP transport

a=fingerprint:sha-256 with the hash, and a=setup:actpass

fingerprint hash="sha-256" setup="actpass" (XEP-0320)

a=candidate lines

candidate elements (UDP only), with component, foundation, ip, port, priority, type

a=group:BUNDLE 0 1

group semantics="BUNDLE" (XEP-0338)

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

sequenceDiagram autonumber participant A as Alice browser participant S as XMPP server participant B as Bob phone participant B2 as Bob browser A->>S: message propose (JMI, audio) S-->>B: propose (carbons) S-->>B2: propose (carbons) B->>S: message proceed (accepts on the phone) S-->>A: proceed from Bob phone S-->>B2: proceed (stop ringing) A->>S: iq set session-initiate (SDP as Jingle) S-->>B: session-initiate B->>S: iq set session-accept (answer) S-->>A: session-accept A->>B: ICE connectivity checks and DTLS-SRTP (UDP) A->>S: iq set transport-info (trickle candidate) S-->>B: transport-info A->>B: audio and video (SRTP) B->>S: iq set session-terminate S-->>A: session-terminate
  1. Ringing, with Jingle Message Initiation. Without it, the caller has to guess which of the callee’s devices to send the session-initiate to. With XEP-0353 the caller sends a message (type chat, with a store hint) containing a propose element with a unique id and one description per 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 sends proceed; the others receive that and stop ringing. Other outcomes are reject, retract by the caller before an answer, and finish with a reason when the call ends, which also leaves a record in the archive of every device.

  2. Session setup. The caller then sends the Jingle session-initiate to the callee’s full JID, using the id from the propose as the sid. The callee answers with session-accept, which carries its own SDP as Jingle.

  3. 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).

  4. Termination. session-terminate with a reason such as success, busy, decline or connectivity-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-initiate whose contents describe the bridge’s media and transport. The browser answers with session-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 RTCPeerConnection needs, 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 iq without acknowledging the peer’s. Every Jingle iq needs an iq result, 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-initiate to a bare JID or a guessed full JID; use JMI.

  • TURN credentials reused for hours. Request fresh credentials per call and treat the expires value 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.