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/deletehandlers. 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:
- Tag frames are keyed by name, in
objectId, because a tag has no id. - A rename is a
deletethen anadd, in that order. Apply them in sequence. Reordering them leaves you holding both names. - Objects carry tag names only, never colors. Resolve names against the library the way you
resolve
folderIdagainst 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.
- [ ]
hellois received and asserted before anything else. - [ ] Reconnect backoff is reset on
hello, notonopen. - [ ] Exactly one
subscribe, naming every type the client needs. - [ ] The old
refreshcalls 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.
- [ ]
waitUntilDoneis gone, or moved to the HTTP API. - [ ] Log backlog seeds from the
refreshlist, then appendsaddframes. - [ ] 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
waitUntilDoneand other request-and-response work belongs.