Skip to content

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

wss://<host>:<port>/v3/api/ws/full-feed

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"}
  • id echoes the id of the message that failed. A message that isn't valid JSON has no id to echo, so that frame carries the raw text as invalidMessage instead.
  • The detail is in validationErrors (a map of field to problem) or errorText.
  • A failed refresh (an unknown type, or an object id that doesn't exist) also carries objectType, 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:

{"id": 1552926800, "class": "indigo.Folder", "name": "Insteon", "remoteDisplay": true}

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 on onopen — see Connecting.
  • On reconnect, just subscribe again. 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.