Janus API walkthrough
The Janus API is how a client talks to the gateway: JSON messages over HTTP or WebSockets (or MQTT, RabbitMQ, Unix sockets, nanomsg: transports). Every transcript on this page was recorded from the Rust binary by docs-site/scripts/record-api.mjs; the ids differ on every run.
The shape of a conversation
Everything is a JSON object with a janus member naming the request or reply, and a transaction string you choose. The server copies your transaction into every reply to that request, so you can match them.
client server
── {"janus":"create"} ──────────────────▶
◀── {"janus":"success","data":{"id":S}} ── a session
── {"janus":"attach","plugin":"…"} ──────▶
◀── {"janus":"success","data":{"id":H}} ── a handle (one plugin)
── {"janus":"message","body":{…}} ───────▶
◀── {"janus":"ack"} ────────────────────── "got it, answer follows"
◀── {"janus":"event","plugindata":…} ───── the answer, later| Object | What it is | Lives until |
|---|---|---|
| Session | One client's connection to the gateway. | destroy, or session_timeout seconds (60 by default) with no request. A keepalive resets the clock. |
| Handle | The session's link to one plugin instance. One session can hold many. Also carries one PeerConnection. | detach, or its session ends. |
| Transaction | A string you pick per request. | One request/reply pair. |
Requests
janus | Where | What it does |
|---|---|---|
create | base | Make a session. Reply data.id is the session id. |
attach | session | Make a handle for plugin (a name like janus.plugin.videoroom). Optional opaque_id. |
message | handle | Send body to the plugin, optionally with a jsep (SDP offer or answer). |
trickle | handle | Send a candidate (or {"completed":true}) of the browser's ICE gathering. |
hangup | handle | Close the handle's PeerConnection but keep the handle. |
detach | handle | Destroy the handle. |
keepalive | session | Reset the session timeout. Answered with ack. |
destroy | session | End the session and everything in it. |
claim | session | Take over a session whose transport went away (needs reclaim_session_timeout). |
info | base | The server's description (also GET /janus/info). |
ping | base | Answered with pong (WebSockets; HTTP has GET /janus/ping). |
Replies and events
janus | Meaning |
|---|---|
success | The request is done. For plugins that answer at once (a list, say) plugindata holds the answer. |
ack | Accepted, the real answer will come as an event with the same transaction. |
event | A plugin event. Has sender (the handle), plugindata, and often a jsep. |
error | The request failed. error.code and error.reason: see error codes. |
webrtcup, media, slowlink, hangup, detached, trickle | Events about the handle's PeerConnection, pushed without a transaction: see WebRTC from a browser. |
timeout | The session timed out. |
Where ids go
Over HTTP the ids are in the path: POST /janus to create, POST /janus/<session> for session requests, POST /janus/<session>/<handle> for handle requests. Over WebSockets and every other transport they go in the message as session_id and handle_id.
Server info
curl -s http://127.0.0.1:8088/janus/info// → GET /janus/info
// ← 200 OK
{
"janus": "server_info",
"name": "Janus WebRTC Server",
"version": 1404,
"version_string": "1.4.4",
"author": "Meetecho s.r.l.",
"commit-hash": "not-a-git-repo",
"compile-time": "Wed Nov 4 14:40:20 UTC 2026",
"log-to-stdout": true,
"log-to-file": false,
"data_channels": true,
"accepting-new-sessions": true,
"session-timeout": 60,
"reclaim-session-timeout": 0,
"candidates-timeout": 45,
"server-name": "MyJanusInstance",
"local-ip": "192.0.2.10",
"ipv6": false,
"ice-lite": false,
"ice-tcp": false,
"ice-nomination": "aggressive",
"mdns-enabled": true,
"min-nack-queue": 200,
"twcc-period": 200,
"dtls-mtu": 1200,
"api_secret": false,
"auth_token": false,
"event_handlers": false,
"transports": {
"janus.transport.http": {
"name": "JANUS REST (HTTP/HTTPS) transport plugin",
"version_string": "0.0.2"
},
"janus.transport.websockets": {
"name": "JANUS WebSockets transport plugin",
"version_string": "0.0.1"
}
},
"plugins": {
"janus.plugin.audiobridge": {
"name": "JANUS AudioBridge plugin",
"version_string": "0.0.13"
},
"janus.plugin.echotest": {
"name": "JANUS EchoTest plugin",
"version_string": "0.0.7"
},
"janus.plugin.nosip": {
"name": "JANUS NoSIP plugin",
"version_string": "0.0.2"
},
"janus.plugin.recordplay": {
"name": "JANUS Record&Play plugin",
"version_string": "0.0.5"
},
"janus.plugin.sip": {
"name": "JANUS SIP plugin",
"version_string": "0.0.10"
},
"janus.plugin.streaming": {
"name": "JANUS Streaming plugin",
"version_string": "0.0.11"
},
"janus.plugin.textroom": {
"name": "JANUS TextRoom plugin",
"version_string": "0.0.2"
},
"janus.plugin.videocall": {
"name": "JANUS VideoCall plugin",
"version_string": "0.0.6"
},
"janus.plugin.videoroom": {
"name": "JANUS VideoRoom plugin",
"version_string": "0.0.10"
}
}
}
// (trimmed: the real reply also has descriptions and author for every plugin and transport, and transaction)plugins lists what is compiled in and loaded; transports lists what started. Use it as a readiness check and to see which plugins you can attach to.
Session, handle and a synchronous plugin call
// 1. Create a session. The id is chosen by the server.
// → POST /janus
{
"janus": "create",
"transaction": "create-1"
}
// ← 200 OK
{
"janus": "success",
"transaction": "create-1",
"data": {
"id": 3700673590697963
}
}
// 2. Attach a plugin handle to it. The path now carries the session id.
// → POST /janus/3700673590697963
{
"janus": "attach",
"plugin": "janus.plugin.videoroom",
"transaction": "attach-1"
}
// ← 200 OK
{
"janus": "success",
"session_id": 3700673590697963,
"transaction": "attach-1",
"data": {
"id": 1437518550528597
}
}
// 3. Talk to the plugin. VideoRoom answers `list` at once, so the reply is `success` with plugindata.
// → POST /janus/3700673590697963/1437518550528597
{
"janus": "message",
"body": {
"request": "list"
},
"transaction": "msg-1"
}
// ← 200 OK
{
"janus": "success",
"session_id": 3700673590697963,
"transaction": "msg-1",
"sender": 1437518550528597,
"plugindata": {
"plugin": "janus.plugin.videoroom",
"data": {
"videoroom": "success",
"list": [
{
"room": 1234,
"description": "Demo Room",
"pin_required": false,
"is_private": false,
"max_publishers": 6,
"bitrate": 512000,
"fir_freq": 10,
"require_pvtid": false,
"require_e2ee": false,
"dummy_publisher": false,
"notify_joining": false,
"audiocodec": "opus",
"videocodec": "vp8",
"opus_fec": true,
"record": false,
"lock_record": false,
"num_participants": 0,
"audiolevel_ext": true,
"audiolevel_event": false,
"videoorient_ext": true,
"playoutdelay_ext": true,
"transport_wide_cc_ext": true
}
]
}
}
}
// 4. Keep the session alive. Sessions with no request for session_timeout seconds (60 by default) are reaped.
// → POST /janus/3700673590697963
{
"janus": "keepalive",
"transaction": "ka-1"
}
// ← 200 OK
{
"janus": "ack",
"session_id": 3700673590697963,
"transaction": "ka-1"
}
// 5. Detach the handle, then destroy the session.
// → POST /janus/3700673590697963/1437518550528597
{
"janus": "detach",
"transaction": "detach-1"
}
// ← 200 OK
{
"janus": "success",
"session_id": 3700673590697963,
"transaction": "detach-1"
}
// → POST /janus/3700673590697963
{
"janus": "destroy",
"transaction": "destroy-1"
}
// ← 200 OK
{
"janus": "success",
"session_id": 3700673590697963,
"transaction": "destroy-1"
}
// 6. The id is gone: a second destroy is refused.
// → POST /janus/3700673590697963
{
"janus": "destroy",
"transaction": "destroy-2"
}
// ← 200 OK
{
"janus": "error",
"session_id": 3700673590697963,
"transaction": "destroy-2",
"error": {
"code": 458,
"reason": "No such session 3700673590697963"
}
}The VideoRoom list request answers at once, so the reply is success and the plugin's answer is in plugindata.data. Note that a plugin-level error, such as a missing argument, also comes back as success: look inside plugindata for error_code (errors).
An asynchronous plugin call with an SDP offer
Most WebRTC work is asynchronous. The plugin needs time, so the POST returns ack and the answer arrives later as an event with the same transaction, carrying the SDP answer in jsep.
// An asynchronous plugin request. EchoTest takes a JSEP offer. The POST is answered with `ack` right away;
// the plugin's real answer comes later, as an `event` with the same transaction.
// → POST /janus/3840587520145426/5309477979916266
{
"janus": "message",
"transaction": "echo-1",
"body": {
"audio": true,
"video": true
},
"jsep": {
"type": "offer",
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\na=msid-semantic: WMS stream0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\na=ice-options:trickle\r\na=fingerprint:sha-256 00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF\r\na=setup:actpass\r\na=mid:0\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10;useinbandfec=1\r\na=ssrc:1111 cname:test\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\na=ice-options:trickle\r\na=fingerprint:sha-256 00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF\r\na=setup:actpass\r\na=mid:1\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=ssrc:2222 cname:test\r\n"
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 3840587520145426,
"transaction": "echo-1",
"hint": "I'm taking my time!"
}
// The event arrives on the long poll of the session (GET /janus/<session>). Other transports push it on the connection.
// ← GET /janus/<session>?maxev=5 → 200 OK
{
"janus": "event",
"session_id": 3840587520145426,
"transaction": "echo-1",
"sender": 5309477979916266,
"plugindata": {
"plugin": "janus.plugin.echotest",
"data": {
"echotest": "event",
"result": "ok"
}
},
"jsep": {
"type": "answer",
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 192.0.2.10\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\na=ice-options:trickle\r\na=fingerprint:sha-256 1C:E7:EC:18:10:59:42:38:1D:5A:F2:28:34:34:2D:55:89:51:10:9A:CA:AD:4B:EB:F4:34:59:1B:BD:74:4C:31\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS *\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 192.0.2.10\r\na=sendrecv\r\na=mid:0\r\na=rtcp-mux\r\na=ice-ufrag:ridq\r\na=ice-pwd:b5R/Q+deF/CMguKbhXj98A\r\na=ice-options:trickle\r\na=setup:active\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 useinbandfec=1\r\na=msid:janus janus0\r\na=ssrc:376228451 cname:janus\r\na=candidate:1 1 udp 2015363327 192.0.2.10 63124 typ host\r\na=end-of-candidates\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 192.0.2.10\r\na=sendrecv\r\na=mid:1\r\na=rtcp-mux\r\na=ice-ufrag:ridq\r\na=ice-pwd:b5R/Q+deF/CMguKbhXj98A\r\na=ice-options:trickle\r\na=setup:active\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtcp-fb:96 goog-remb\r\na=msid:janus janus1\r\na=ssrc:3775652199 cname:janus\r\na=candidate:1 1 udp 2015363327 192.0.2.10 63124 typ host\r\na=end-of-candidates\r\n"
}
}On HTTP you collect those events with a long poll; the next section shows how.
Long polling over HTTP
HTTP is request/reply, so events wait for you. Send GET /janus/<session> and the server holds the request open until an event is ready or about 30 seconds pass, then answers with a keepalive object. Add ?maxev=N to take up to N events at once.
// A GET on an unknown session is a 404 with an empty body.
// → GET /janus/999999?maxev=1
// ← 404 Not Found
// maxev caps how many events one poll returns. With nothing queued a poll waits (up to 30 s) and then answers with a keepalive.
// Here an event is queued first, by a plugin message that gets an ack and then an event.
// → POST /janus/6266982182713396/6347401048802179
{
"janus": "message",
"transaction": "queued-1",
"body": {
"request": "join",
"ptype": "publisher",
"room": 1234,
"display": "poller",
"id": 4242
}
}
// ← 200 OK
{
"janus": "ack",
"session_id": 6266982182713396,
"transaction": "queued-1"
}
// → GET /janus/<session>?maxev=1
// ← 200 OK
{
"janus": "event",
"session_id": 6266982182713396,
"transaction": "queued-1",
"sender": 6347401048802179,
"plugindata": {
"plugin": "janus.plugin.videoroom",
"data": {
"videoroom": "joined",
"room": 1234,
"description": "Demo Room",
"id": 4242,
"private_id": 2953291000,
"publishers": []
}
}
}A poll for a session that does not exist is 404 with an empty body, exactly as in the C server. Long polling doubles as a keepalive: a session that is being polled does not time out.
The same thing over WebSockets
Connect with the sub-protocol janus-protocol. There are no paths and no polling: the ack and the event arrive as separate frames on the socket.
// Connect to ws://127.0.0.1:8188 with the sub-protocol "janus-protocol". Session and handle ids go in the message, not in a path.
// → text frame
{
"janus": "ping",
"transaction": "p1"
}
// ← text frame
{
"janus": "pong",
"transaction": "p1"
}
// → text frame
{
"janus": "create",
"transaction": "c1"
}
// ← text frame
{
"janus": "success",
"transaction": "c1",
"data": {
"id": 6515984339476388
}
}
// → text frame
{
"janus": "attach",
"session_id": 6515984339476388,
"plugin": "janus.plugin.videoroom",
"transaction": "a1"
}
// ← text frame
{
"janus": "success",
"session_id": 6515984339476388,
"transaction": "a1",
"data": {
"id": 2395047907385659
}
}
// An async plugin request: the ack and the event are two frames on the same socket.
// → text frame
{
"janus": "message",
"session_id": 6515984339476388,
"handle_id": 2395047907385659,
"transaction": "m1",
"body": {
"request": "join",
"ptype": "publisher",
"room": 1234,
"display": "ws-user",
"id": 777
}
}
// ← text frame
{
"janus": "ack",
"session_id": 6515984339476388,
"transaction": "m1"
}
// ← text frame
{
"janus": "event",
"session_id": 6515984339476388,
"transaction": "m1",
"sender": 2395047907385659,
"plugindata": {
"plugin": "janus.plugin.videoroom",
"data": {
"videoroom": "joined",
"room": 1234,
"description": "Demo Room",
"id": 777,
"private_id": 192673860,
"publishers": []
}
}
}
// → text frame
{
"janus": "destroy",
"session_id": 6515984339476388,
"transaction": "d1"
}
// ← text frame
{
"janus": "success",
"session_id": 6515984339476388,
"transaction": "d1"
}When it goes wrong
// Unknown request type (453).
// → POST /janus
{
"janus": "frobnicate",
"transaction": "e1"
}
// ← 200 OK
{
"janus": "error",
"transaction": "e1",
"error": {
"code": 457,
"reason": "Unhandled request 'frobnicate' at this path"
}
}
// Missing transaction (456). With no transaction there is nothing to echo, so the reply has none either.
// → POST /janus
{
"janus": "create"
}
// ← 200 OK
{
"janus": "error",
"error": {
"code": 456,
"reason": "Missing mandatory element (transaction)"
}
}
// Body that is not JSON (454).
// → POST /janus
{not json
// ← 200 OK
{
"janus": "error",
"error": {
"code": 454,
"reason": "JSON error: on line 1: '[' or '{' expected near '\"{not json\"'"
}
}
// Plugin that does not exist (460).
// → POST /janus/8589897810869151
{
"janus": "attach",
"plugin": "janus.plugin.nope",
"transaction": "e2"
}
// ← 200 OK
{
"janus": "error",
"session_id": 8589897810869151,
"transaction": "e2",
"error": {
"code": 460,
"reason": "No such plugin 'janus.plugin.nope'"
}
}
// Handle that does not exist (459).
// → POST /janus/8589897810869151/12345
{
"janus": "message",
"transaction": "e3",
"body": {}
}
// ← 200 OK
{
"janus": "error",
"session_id": 8589897810869151,
"transaction": "e3",
"error": {
"code": 459,
"reason": "No such handle 12345 in session 8589897810869151"
}
}
// Session that does not exist (458).
// → POST /janus/12345
{
"janus": "keepalive",
"transaction": "e4"
}
// ← 200 OK
{
"janus": "error",
"session_id": 12345,
"transaction": "e4",
"error": {
"code": 458,
"reason": "No such session 12345"
}
}
// A plugin error is not a Janus error: the reply is a normal `success` and the error is inside plugindata.
// → POST /janus/8589897810869151/8608268508942444
{
"janus": "message",
"transaction": "e5",
"body": {
"request": "exists"
}
}
// ← 200 OK
{
"janus": "success",
"session_id": 8589897810869151,
"transaction": "e5",
"sender": 8608268508942444,
"plugindata": {
"plugin": "janus.plugin.videoroom",
"data": {
"videoroom": "event",
"error_code": 429,
"error": "Missing mandatory element (room)"
}
}
}Authentication
If api_secret is set in janus.jcfg every request must carry "apisecret": "<value>". With token_auth set, requests carry "token" instead: stored tokens are added through the Admin API (add_token), signed tokens are checked with token_auth_secret. Both are in the janus.jcfg reference. The Bench has an apisecret field on its Threshold page.
Try it in the Bench
The Raw console page sends any of these requests, fills and for you, and keeps a history. Every frame also appears, as sent and as received, in the transcript on the right of the app.