Skip to content

Migrating a WebSocket Client from v2 to v3

In this guide

New in 2026.1 A complete procedure for porting an existing client from the seven v2 feeds to the single v3 feed. The short version: you replace connection setup and subscription, and you leave your message handling alone. Most of a v2 client does not change.

This page is written to be worked through in order, whether by a person or by a coding assistant editing the client for you. Each step states the change, shows the before and after, and says how to tell it worked.

You do not have to migrate

The seven v2 feeds are unchanged in Indigo 2026.1, fully supported, and are not going away. Port when you want one connection instead of seven, or when you need tag recolors and folder renames, which v2 cannot deliver at all. Nothing breaks if you don't.

What does not change

Read this before editing anything. The most common way to get this migration wrong is to rewrite more than it requires.

  • Your refresh / add / patch / delete handlers. The frame vocabulary is identical. If you have a function that applies a dictdiffer patch to a cached device, it is already correct.
  • The object dictionaries. A device is the same device, with the same fields. See API Messages.
  • Your command payloads. {"message": "indigo.device.turnOn", "objectId": 123} is byte for byte what you already send.
  • Authentication. The same API keys and local secrets, passed the same two ways.
  • Your data model and cache. Keyed by object id, same as before.

What does change is the connection setup, how you ask for data, and how you route incoming frames to the right handler. That is the whole job.

Step 1: Replace seven connections with one

v2 requires one socket per object type, each with its own URL, handshake, keepalive and failure handling. v3 has one socket for everything.

Before — v2:

import asyncio, json, websockets

BASE = "wss://YOUR-REFLECTOR-NAME.indigodomo.net/v2/api/ws"
KEY = "YOUR-API-KEY"

async def run_feed(feed, object_type):
    url = f"{BASE}/{feed}?api-key={KEY}"
    async with websockets.connect(url) as ws:
        # Ask for the objects, then the folders, as two separate messages.
        await ws.send(json.dumps({"message": "refresh", "objectType": object_type}))
        await ws.send(json.dumps({"message": "refresh", "objectType": f"{object_type}.Folder"}))
        async for raw in ws:
            handle(json.loads(raw))

async def main():
    await asyncio.gather(
        run_feed("device-feed", "indigo.Device"),
        run_feed("variable-feed", "indigo.Variable"),
        run_feed("action-feed", "indigo.ActionGroup"),
        # ...and one more for each type you care about
    )

After — v3:

import asyncio, json, websockets

URL = "wss://YOUR-REFLECTOR-NAME.indigodomo.net/v3/api/ws/full-feed"
KEY = "YOUR-API-KEY"

async def main():
    async with websockets.connect(f"{URL}?api-key={KEY}") as ws:
        hello = json.loads(await ws.recv())        # always the first frame
        assert hello["message"] == "hello"

        await ws.send(json.dumps({
            "id": "sub1",
            "message": "subscribe",
            "objectTypes": ["indigo.Device", "indigo.Variable", "indigo.ActionGroup"],
        }))

        async for raw in ws:
            handle(json.loads(raw))

How to tell it worked: one connection in your logs instead of several, and handle() receives a hello frame first.

Step 2: Replace refreshes with one subscribe

A v2 client asked for each thing separately: the objects, then that type's folders, and then, if it wanted tags, the tag library. v3 sends all of it unprompted in response to subscribe.

v2 v3
refresh with objectType: "indigo.Device" covered by subscribe
refresh with objectType: "indigo.Device.Folder" covered by subscribe
refresh with objectType: "indigo.server.tags" covered by subscribe, once per connection

Delete those refresh calls. Keep the handler that processes the refresh frames they used to produce, because v3 answers with the same frames.

The names you can subscribe to are:

indigo.Device      indigo.Variable    indigo.ActionGroup
indigo.Schedule    indigo.Trigger     indigo.ControlPage
indigo.LogEvent

indigo.Folder and indigo.Tag are not subscribable. Folders arrive with their own type; the tag library is implicit.

A fresh v3 connection is subscribed to nothing

In v2, connecting to the device feed meant you were going to get devices. In v3, connecting and then never sending subscribe leaves you with a silent socket that looks broken.

How to tell it worked: after subscribe, you receive a reply naming what you subscribed to, then the tag library, then a list per type, then that type's folder list.

Step 3: Route on objectType, not on which socket it came from

This is the step most likely to be missed, because a v2 client usually doesn't look at objectType at all — it didn't need to. The device feed only ever sent devices, so the socket was the type.

Before — v2, where the handler is bound to a feed and the type is implicit:

async def run_device_feed(ws):
    async for raw in ws:
        frame = json.loads(raw)
        if frame["message"] == "patch":
            apply_patch(devices, frame["objectId"], frame["patch"])   # devices, always

After — v3, where one handler serves every type:

CACHES = {
    "indigo.Device": devices,
    "indigo.Variable": variables,
    "indigo.ActionGroup": action_groups,
}

def handle(frame):
    kind = frame.get("objectType")
    if frame["message"] == "patch":
        apply_patch(CACHES[kind], frame["objectId"], frame["patch"])

Every pushed frame carries objectType. Frames you did not have in v2 also arrive here, so handle or ignore them deliberately: indigo.<Type>.Folder for folder changes and indigo.Tag for tag library changes.

How to tell it worked: changing a variable updates your variable cache rather than corrupting your device cache.

Step 4: Send commands for any namespace on the one socket

Each v2 feed rejected anything outside its own namespace, so a client that controlled devices and set variables had to pick the right socket for each command. In v3 there is one socket and no such rule.

Before — v2:

await device_ws.send(json.dumps({"message": "indigo.device.turnOn", "objectId": 123}))
await variable_ws.send(json.dumps({"message": "indigo.variable.updateValue",
                                   "objectId": 456, "parameters": {"value": "on"}}))

After — v3:

await ws.send(json.dumps({"message": "indigo.device.turnOn", "objectId": 123}))
await ws.send(json.dumps({"message": "indigo.variable.updateValue",
                          "objectId": 456, "parameters": {"value": "on"}}))

The payloads are unchanged. Only the socket you send them on changes.

Two behaviors worth knowing while you are in this code. Success is silent — the resulting patch is your confirmation, exactly as in v2. And waitUntilDone is now refused with a validation error pointing at POST /v2/api/command, where v2 ignored it silently. If your client sets it, move that call to the HTTP API.

Step 5: Rework the log backlog

Skip this step if your client does not consume the log feed.

v2's log feed sent the backlog as 25 separate add frames, indistinguishable from live traffic. v3 sends it as one refresh carrying a list, and lineCount on subscribe sets the size — default 25, maximum 500, anything larger quietly reduced.

Before — v2, where backlog and live lines are the same shape:

# Every line, backlog or live, arrives as an `add`. There is no way to tell them apart.
if frame["message"] == "add" and frame["objectType"] == "indigo.LogEvent":
    buffer.append(frame["objectDict"])

After — v3, where you seed from the list and then append:

if frame["objectType"] == "indigo.LogEvent":
    if frame["message"] == "refresh":
        buffer = list(frame["list"])        # the backlog, all at once
    elif frame["message"] == "add":
        buffer.append(frame["objectDict"])  # a live line

Step 6: Reset backoff on hello, not on onopen

v3 sends a hello frame before reading anything from you:

{"message": "hello", "protocol": 3, "features": ["tagBroadcasts", "folderBroadcasts"],
 "server": {"version": "2026.1.0", "apiVersion": "3.9"}}

This matters for a reason that is not obvious: an authentication failure does not fail the WebSocket upgrade. The socket opens and is then closed with nothing sent. So onopen firing proves nothing, and a client that resets its reconnect backoff there will hammer a server that is refusing it, forever.

Receiving hello is the only reliable proof you are both connected and authenticated. Reset backoff there.

v3 does say why it closed: a refused key gets close code 4401, reason authentication failed. On 4401, stop reconnecting and ask for new credentials. (v2's feeds just drop the connection, which a client sees as 1006 — the same as a network drop.)

On reconnect, just subscribe again. State comes back automatically and replaces your lists. There is no resync protocol and nothing to reconcile.

Step 7: Handle tags and folder changes, if you draw them

Skip this step if your client shows neither tags nor folder names.

These are changes v2 could not deliver at all, which is why there is no "before" code: a tag recolor and a folder rename rewrite no object, so nothing on the typed feeds ever fired for them.

if frame["objectType"] == "indigo.Tag":
    if frame["message"] == "add":
        tags[frame["objectDict"]["name"]] = frame["objectDict"]
    elif frame["message"] == "patch":
        apply_patch(tags, frame["objectId"], frame["patch"])   # keyed by NAME, not id
    elif frame["message"] == "delete":
        tags.pop(frame["objectId"], None)

Three things to get right, each of which is a real bug if missed:

  1. Tag frames are keyed by name, in objectId, because a tag has no id.
  2. A rename is a delete then an add, in that order. Apply them in sequence. Reordering them leaves you holding both names.
  3. Objects carry tag names only, never colors. Resolve names against the library the way you resolve folderId against the folder list. A color copied onto a cached object goes stale on the next recolor and nothing will tell you to refresh it.

Folder frames arrive as indigo.<Type>.Folder, keyed by folder id, and go to whoever subscribed to the folder's own type. A rename is a patch; a visibility change is an add or a delete.

Step 8: Detect the server version, if one build talks to many servers

Skip this step if you control which server your client connects to.

If you ship through an app store, a 2026.1 server has to keep serving v2 for as long as older builds of your client are in the field — and your new build will meet servers older than 2026.1. Ask before you connect:

import urllib.request, json

def websocket_version(host):
    """Return the highest WebSocket version this server and we both speak."""
    try:
        with urllib.request.urlopen(f"https://{host}/api/capabilities") as r:
            caps = json.load(r)
    except urllib.error.HTTPError as exc:
        if exc.code == 404:
            return 2          # endpoint is new in 2026.1, so 404 means an older server
        raise
    return max(set(caps["apis"]["websocket"]["versions"]) & {2, 3})

GET /api/capabilities is unauthenticated. Its absence is itself the answer.

When something is wrong

Symptom Cause
Socket opens, then closes, nothing received Authentication failed. The upgrade succeeds regardless — check the key, and wait for hello rather than trusting onopen.
Connected, hello received, then silence You never sent subscribe. A fresh connection is subscribed to nothing.
Some types arrive, one does not That type came back in rejected on the reply. Check the spelling against the subscribable list.
Variable changes corrupt the device cache Still routing by socket rather than on objectType — see Step 3.
A command returns a validation error naming POST /v2/api/command waitUntilDone is refused on this socket. Move that call to the HTTP API.
Tag colors go stale after someone recolors a tag Colors were copied onto cached objects. Resolve names against the library instead.
A renamed tag appears twice The add was applied before the delete. Apply the pair in the order received.
Reconnect loop against a server that refuses you Backoff is being reset in onopen. Reset it on hello.
Log backlog and live lines are indistinguishable Still treating the backlog as add frames. In v3 it is one refresh with a list.

Verification checklist

  • [ ] One WebSocket connection, not several.
  • [ ] hello is received and asserted before anything else.
  • [ ] Reconnect backoff is reset on hello, not onopen.
  • [ ] Exactly one subscribe, naming every type the client needs.
  • [ ] The old refresh calls for objects, folders and tags are gone; the handlers for those frames are still there.
  • [ ] Every incoming frame is routed on objectType.
  • [ ] Commands for every namespace go down the one socket.
  • [ ] waitUntilDone is gone, or moved to the HTTP API.
  • [ ] Log backlog seeds from the refresh list, then appends add frames.
  • [ ] Tag frames are keyed by name, and a rename is applied delete-then-add.
  • [ ] Objects resolve tag names against the library rather than caching colors.

Reference

  • WebSockets API v3 — the full reference for the feed you are porting to.
  • WebSockets API v2 — the seven typed feeds, still supported.
  • API Messages — the object dictionaries and command messages, unchanged by this migration and shared by every transport.
  • HTTP API — where waitUntilDone and other request-and-response work belongs.