WebSockets API v3¶
In this guide
New in 2026.1 v3 is a single WebSocket connection carrying every object type you ask for, instead of one
connection per type. Open the socket, send one subscribe, and the server sends you the
current state of everything you asked for and then pushes changes as they happen. Commands for
any namespace go back down the same socket.
This is the feed to write new clients against. The seven v2 feeds are unchanged, fully supported, and are not going away, so existing integrations keep working exactly as they do today. But a client that wanted more than one object type had to hold one connection per type, each with its own handshake, keepalive, and failure mode. v3 carries the same information over one connection, and it also carries two kinds of change that v2 structurally cannot: tag recolors and folder renames, neither of which rewrites an object.
The frame vocabulary is v2's, unchanged, so if you already have a v2 client its message handling ports across directly. See Migrating from v2 at the end.
Connecting¶
The default port is 8176.
Authentication¶
Two ways in:
- An API key. The best way to pass it is an
Authorization: Bearer <key>header. If you can't set headers, put it on the URL as?api-key=<key>. Manage keys in the Authorizations section of your Indigo Account, or create a "local secret" — a kind of API key that doesn't pass through the Indigo reflector. You can use both kinds at once. - A session — the cookie set by a successful load of the index page. This is how the built-in web UI authenticates, and why it needs no key of its own.
Prefer a key over your Indigo Server username and password. A key can be revoked at any moment, which immediately breaks whatever is using it and nothing else, so your protections are far more granular. A key also carries less authority: someone holding one can control devices but cannot modify your database, which is reserved for clients using the username and password.
Over HTTPS — which is what you get through your reflector — the key is protected by TLS in both forms. A local or port-forwarded HTTP connection is not secure.
Warning
Don't share your API keys with anyone who isn't authorized to use them, especially in posts to the user forums.
An authentication failure does not fail the upgrade
The socket opens, and is then closed with nothing sent: close code 4401, reason
authentication failed. So "the connection opened" is not proof that you are authenticated —
the first frame, hello, is. A client that treats onopen as success will sit in a reconnect
loop against a server that is refusing it, forever.
On 4401, stop reconnecting and fix the credentials; retrying with the same key cannot succeed. Any other close, including 1006 (the connection dropped without a close frame), is worth a reconnect with backoff — see Staying connected.
A session lives in the Indigo Web Server's memory, so restarting it invalidates every open page. An existing socket is closed and reconnecting will not help; the page has to authenticate again.
The hello frame¶
Before reading anything from you, the server sends:
{
"message": "hello",
"protocol": 3,
"features": ["tagBroadcasts", "folderBroadcasts"],
"server": {"version": "2026.1.0", "apiVersion": "3.9"}
}
| Field | Meaning |
|---|---|
protocol |
the WebSocket protocol version this socket speaks |
features |
capabilities added within v3, so later additions are discoverable without a version bump - test for membership, and don't assume the order or that the list is complete |
server |
the same object GET /api/capabilities reports |
Receiving hello is the only reliable signal that you are both connected and authenticated. Reset
your reconnect backoff here, not in onopen.
Detecting v3 before you connect¶
GET /api/capabilities is unauthenticated and takes no body:
{
"server": {"version": "2026.1.0", "apiVersion": "3.9"},
"apis": {
"http": {"versions": [2], "paths": {"2": "/v2/api"}},
"websocket": {"versions": [2, 3], "paths": {"2": "/v2/api/ws/<type>-feed",
"3": "/v3/api/ws/full-feed"}},
"mcp": {"versions": [3], "paths": {"3": "/v3/api/mcp"}}
}
}
Fetch it, then pick the highest WebSocket version you and the server have in common. A 404 means
the server predates Indigo 2026.1 and speaks only v2. Both this endpoint and the hello frame
are new in 2026.1, so their absence is itself the answer.
If you ship through an app store, detect rather than assume
A 2026.1 server has to keep serving v2 for as long as older builds of your client are in the field, and the server your build talks to will not always be the one you tested against.
Messages you send¶
Every message is a JSON object with a message key. An id is echoed back on anything that
replies, so supply one you can correlate.
message |
Fields | Effect |
|---|---|---|
subscribe |
id, objectTypes, optional lineCount |
adds types to this connection, replies reply, then sends each newly added type's state - idempotent |
unsubscribe |
id, objectTypes |
removes types, replies reply - removing something you never had is not an error |
refresh |
id, objectType, optional objectId, optional lineCount |
re-sends state on demand, whether or not you are subscribed |
ping |
optional id |
replies pong |
| anything else | id, plus the command's own fields |
treated as an Indigo command - see Commands |
Subscribable object types¶
indigo.Device indigo.Variable indigo.ActionGroup
indigo.Schedule indigo.Trigger indigo.ControlPage
indigo.LogEvent
A fresh connection is subscribed to nothing. Unknown types come back in rejected without
failing the valid ones, so a newer client asking for a type an older server doesn't have still gets
everything else:
{"id": "s1", "message": "subscribe", "objectTypes": ["indigo.Device", "indigo.Nonsense"]}
{"id": "s1", "message": "reply", "subscribed": ["indigo.Device"], "rejected": ["indigo.Nonsense"]}
indigo.Folder and indigo.Tag are not subscribable names. Folders arrive with their own
type's state and changes; the tag library is implicit. See Folders and Tags.
What subscribing sends you¶
Once per connection, on your first subscribe to any taggable type, you get the whole tag library.
Then, for each newly added type: that type's object list, followed by that type's folder list,
each as a refresh carrying a list. For indigo.LogEvent you get the recent log lines as a
single refresh with a list.
Re-subscribing to a type you already hold is accepted and sends nothing further, so you can call
subscribe freely without worrying about duplicate state arriving.
These frames carry no id
They are unsolicited state, not an answer. The reply already acknowledged your request.
Ordering is guaranteed one way: you will never receive a patch for a type before that type's list. You may receive a patch for an object the list already reflects, if the change raced the snapshot. Applying it is harmless.
Log lines¶
lineCount on subscribe or refresh sets how many lines of backlog you get. The default is
25 and the maximum is 500; anything larger is quietly reduced. The backlog arrives as one frame carrying a
list, not as N separate add frames the way v2's log feed did. That is what lets you tell backlog from live traffic:
seed your buffer from the list, then append each subsequent add.
Messages the server sends¶
message |
Carries | Meaning |
|---|---|---|
refresh |
list, or objectDict, or folder, or tags |
current state |
add |
objectDict |
a new object, or one that just became visible |
patch |
objectId, patch |
a dictdiffer delta to apply to the object you hold |
delete |
objectId |
gone, or no longer visible to remote clients |
reply |
subscribed and rejected (subscribe), or unsubscribed and subscribed (unsubscribe) |
acknowledgment - see below |
pong |
— | liveness |
hello |
protocol, features, server |
the first frame - see above |
Every pushed frame carries objectType. That is what you demultiplex on.
Replies¶
In both replies, subscribed is every type this connection holds after the request, sorted —
not just the ones this request added — so either reply is enough to keep your own record of the
subscription set. rejected lists the requested types the socket doesn't know, and unsubscribed
lists only the requested types you actually held:
{"id": "s1", "message": "reply", "subscribed": ["indigo.Device", "indigo.LogEvent"], "rejected": ["indigo.Nonsense"]}
{"id": "u1", "message": "reply", "unsubscribed": ["indigo.LogEvent"], "subscribed": ["indigo.Device"]}
Errors¶
An error frame is any frame with an error key, whatever else it carries. Check for error
before dispatching on message: most error frames have no message key, and a failed refresh
has "message": "refresh", so a client that switches on message alone will mistake it for a
refresh with nothing in it.
{"error": "invalid message received - message isn't valid JSON", "invalidMessage": "..."}
{"id": "c1", "error": "invalid command payload received, id: c1", "validationErrors": {"objectId": "id is not a valid Indigo Device"}}
{"id": "c1", "error": "error executing command, id: c1", "errorText": "..."}
{"id": "r1", "message": "refresh", "objectType": "indigo.Device", "error": "object id 123 of type 'indigo.Device' is not in the Indigo database"}
idechoes theidof the message that failed. A message that isn't valid JSON has noidto echo, so that frame carries the raw text asinvalidMessageinstead.- The detail is in
validationErrors(a map of field to problem) orerrorText. - A failed
refresh(an unknown type, or an object id that doesn't exist) also carriesobjectType, so you can tell which refresh failed.
An error never closes the socket. Send something valid next and it answers.
What's inside an objectDict — the fields of a device, variable, action group, schedule, trigger
or control page — is documented in API Messages, which describes the objects
themselves and applies to every transport.
Visibility rides add and delete
An object or folder whose remote display is turned off arrives as a delete, and turning it
back on arrives as an add. There is no "still there but hidden" state to track: if you can
see it, it is in your list.
Folders¶
Folder frames use objectType: "indigo.<Type>.Folder" and are keyed by folder id. The wire form is
exactly four keys:
They go to whoever is subscribed to the folder's own type. You got that folder list with your
initial state, so you get its changes too. A rename is a patch; a visibility change is an add
or a delete.
Tags¶
The library is a map of tag name to tag record, and a record is an object rather than a bare color string, so a tag can gain attributes without the shape changing:
{"message": "refresh", "objectType": "indigo.server.tags",
"tags": {"kitchen": {"color": "4A90D9", "text_color": "0B2E53", "border_color": "2365AB"},
"upstairs": {"color": "FFA500", "text_color": "412702", "border_color": "BF812C"}}}
color is the color the user chose and the only one stored. text_color and border_color are
derived from it by the server and are read-only. Draw a tag's text and border with them rather than
working out your own contrast: every Indigo interface derives them with one shared implementation,
so using them is what makes your tags look like Indigo's. See Tags for
the model, and the tag library in the
scripting reference.
Individual changes arrive under objectType: "indigo.Tag", keyed by name in objectId,
because a tag has no id:
add objectDict: {"name": "kitchen", "color": "4A90D9", "text_color": "0B2E53", "border_color": "2365AB"}
patch objectId: "kitchen", patch: [["change", "color", ["4A90D9", "FF0000"]]]
delete objectId: "kitchen"
A rename is a delete of the old name followed by an add of the new record, in that order,
because there is no stable key to patch against. Apply them in sequence and you are correct;
applying the add first would briefly leave you holding both names. A recolor moves all three
colors together.
A rename or delete also patches every object carrying the tag
Objects hold tag names, so renaming a tag rewrites the tags list on each object that has
it, and deleting one removes it from each. The server broadcasts those as ordinary object
changes, so you get an indigo.Tag frame pair and one patch per affected object — a
rename of a tag on twelve devices is fourteen frames. Nothing special is needed to handle
them; they are the same object patches you already apply. Just don't assume one tag change
means one frame.
The object patches and the indigo.Tag frames are not ordered against each other, so during
a rename you may briefly hold an object carrying a tag name that isn't in your library — the
new name before its add arrives, or the old one after its delete. Draw such a tag in a
default style rather than treating it as an error; the matching library frame arrives in the
same burst.
A recolor is the exception, and the reason it is the exception is worth knowing: the color
lives on the library entry, not on the objects, so recoloring rewrites no object and produces
no patches at all. The indigo.Tag frame is the only notice you get.
Objects carry tag names only, never colors
Resolve names against the library, the same way you resolve folderId against the folder list.
A color welded onto a cached object goes stale the moment someone recolors the tag — and
because a recolor rewrites no object, nothing would ever tell you to refresh it. That is
exactly what the indigo.Tag frames are for, and it is the change v2 cannot deliver at all.
Commands¶
Send the IOM call as the message. Any namespace works on the one socket, unlike v2, where each
typed feed refuses anything outside its own:
{"id": "c1", "message": "indigo.device.turnOn", "objectId": 498764526}
{"id": "c2", "message": "indigo.dimmer.setBrightness", "objectId": 498764526, "parameters": {"value": 75}}
{"id": "c3", "message": "indigo.variable.updateValue", "objectId": 812682129, "parameters": {"value": "on"}}
{"id": "c4", "message": "indigo.server.log", "messageText": "hello from my client"}
{"id": "c5", "message": "indigo.server.create_tag", "tag": "kitchen", "parameters": {"color": "4A90D9"}}
Note the three shapes of first argument: most commands take objectId, the log commands take
messageText, and the tag library commands take tag.
Success is silent. Nothing comes back. The resulting patch is your confirmation, and it is
the thing you were going to act on anyway — an extra acknowledgment would only tell you the server
received the message, which you already knew.
Failure replies, carrying your id (all the error shapes are under Errors):
{"id": "c1", "error": "invalid command payload received, id: c1",
"validationErrors": {"objectId": "id is not a valid Indigo Device"}}
{"id": "c1", "error": "error executing command, id: c1", "errorText": "..."}
waitUntilDone is refused here
It comes back as a validation error pointing at POST /v2/api/command. This socket is a
one-way stream plus broadcasts, and a command that blocks waiting for a plugin to answer would
stall every other client's traffic. Request and response belong to the HTTP API.
Commands sent on this socket are not written to the event log. The HTTP and MCP surfaces log per request because they are transactional, answering one caller; a stream is not, and v2's sockets log nothing either.
Staying connected¶
The server sends protocol-level pings, which keep proxies awake and let the server notice a dead client. A browser cannot see those, so a browser client needs its own probe: a connection that died without a close frame, because the machine slept or the Wi-Fi moved, is indistinguishable from a quiet one.
The pattern the built-in web UI uses, and a reasonable default:
- Treat any incoming frame as proof of life.
- After about 30 seconds of silence, send
{"message": "ping"}. - If nothing arrives within about 10 seconds, close the socket and reconnect.
- Back off between attempts, say 1 second doubling to 30 with jitter, and reset the backoff on
hello, not ononopen— see Connecting. - On reconnect, just
subscribeagain. State comes back automatically and replaces your lists. There is no separate resync protocol and nothing to reconcile.
Nothing is dropped in transit: each connection has an unbounded queue and the server applies backpressure rather than discarding. So a patch you missed does not exist. If you are connected, you have everything.
A complete session¶
(connect)
{"message":"hello","protocol":3,"features":["tagBroadcasts","folderBroadcasts"],"server":{…}}
{"id":"1","message":"subscribe",
"objectTypes":["indigo.Device","indigo.Variable","indigo.LogEvent"],"lineCount":250}
{"id":"1","message":"reply","subscribed":["indigo.Device","indigo.LogEvent","indigo.Variable"],"rejected":[]}
{"message":"refresh","objectType":"indigo.server.tags","tags":{…}}
{"message":"refresh","objectType":"indigo.Device","list":[…]}
{"message":"refresh","objectType":"indigo.Device.Folder","list":[…]}
{"message":"refresh","objectType":"indigo.Variable","list":[…]}
{"message":"refresh","objectType":"indigo.Variable.Folder","list":[…]}
{"message":"refresh","objectType":"indigo.LogEvent","list":[…250…]}
{"message":"patch","objectType":"indigo.Device","objectId":498764526,
"patch":[["change","states.brightnessLevel",[0,75]]]}
{"message":"patch","objectType":"indigo.Tag","objectId":"kitchen",
"patch":[["change","color",["4A90D9","FF0000"]]]}
{"message":"add","objectType":"indigo.LogEvent","objectDict":{…}}
{"id":"2","message":"indigo.device.turnOff","objectId":498764526}
{"message":"patch","objectType":"indigo.Device","objectId":498764526,
"patch":[["change","onState",[true,false]]]}
Minimal clients¶
Python¶
import json, ssl, websocket # pip install websocket-client
conn = websocket.create_connection(
"wss://localhost:8176/v3/api/ws/full-feed?api-key=YOUR-KEY",
sslopt={"cert_reqs": ssl.CERT_NONE}, # only for a self-signed development certificate
)
print("hello:", json.loads(conn.recv()))
conn.send(json.dumps({"id": "1", "message": "subscribe",
"objectTypes": ["indigo.Device"]}))
while True:
frame = json.loads(conn.recv())
if frame.get("message") == "patch":
print(frame["objectType"], frame["objectId"], frame["patch"])
JavaScript¶
const socket = new WebSocket('wss://localhost:8176/v3/api/ws/full-feed');
// In a browser the session cookie authenticates; elsewhere append ?api-key=...
socket.addEventListener('close', (event) => {
if (event.code === 4401) { /* wrong credentials: stop, don't reconnect */ return; }
/* anything else: reconnect with backoff */
});
socket.addEventListener('message', (event) => {
const frame = JSON.parse(event.data);
if (frame.error) { console.warn(frame); return; } // before the switch - see Errors
switch (frame.message) {
case 'hello':
// Authenticated and connected. Reset any reconnect backoff HERE, not in onopen.
socket.send(JSON.stringify({
id: '1', message: 'subscribe',
objectTypes: ['indigo.Device', 'indigo.LogEvent'], lineCount: 250,
}));
break;
case 'refresh': /* seed from frame.list / frame.tags */ break;
case 'patch': /* apply frame.patch to the object with frame.objectId */ break;
case 'add': /* insert frame.objectDict */ break;
case 'delete': /* drop frame.objectId */ break;
}
});
Migrating from v2¶
| v2 | v3 |
|---|---|
| Seven sockets, one per type | One socket; subscribe names the types |
| Connect, then refresh the objects, the folders and the tag library | subscribe sends all three unprompted |
| Each feed accepts only its own namespace's commands | Any namespace, including the tag library commands v2 cannot send at all |
Log backlog as 25 separate add frames |
One refresh with a list, lineCount up to 500 |
| A tag recolor or folder rename never arrives | indigo.Tag and indigo.<Type>.Folder frames |
waitUntilDone unsupported and silently ignored |
Rejected with an error naming the HTTP API |
| Socket closes, so reload the page | Reconnect and subscribe again |
Your refresh, add, patch and delete handling ports unchanged. The work is: open one
socket instead of seven, send subscribe instead of three refreshes, drop the per-feed namespace
assumption, and demultiplex on objectType.
Migrating a WebSocket Client from v2 to v3 is the step-by-step version of that, with before-and-after code for each change, an explicit list of what must not change, a symptom-to-cause table, and a verification checklist.