Status: Authoritative for v1 Version: 1.5 Last updated: 2026-08-09
This document is the single source of truth for the HTTP API between Domio (iOS) and the indigo-matter plugin running on the Indigo Server. Both Domio and the plugin implementations MUST conform to this spec. Any change requires a co-ordinated update on both sides — treat as a versioned interface, not an implementation detail.
This is not the matter-server protocol — that’s a separate seam owned by the plugin (see plugin Implementation Notes for details).
Transport (v1.1, changed from v1.0): the plugin does not run its own HTTP server. It is served by the Indigo Web Server (IWS) as a set of hidden-action handlers, reached at
…/message/com.simons-plugins.indigo-matter/{handler}/…. This is the idiomatic Indigo mechanism (the same one Domio already uses for the device-history and HTML-pages APIs — seedomio-codeDeviceHistoryService/HtmlPageService), and it rides the Reflector for remote access with the same Bearer API key Domio already uses. Consequences vs v1.0: base path is/message/{bundleId}/{handler}(not/matter/…); only GET and POST are available (so decommission is a POST, not DELETE); and authentication is enforced by Indigo/Reflector before the handler runs — the plugin never sees or validates the credential. The job/state/error semantics below are unchanged from v1.0.IWS path-arg quirk (important): IWS populates
action.props["file_path"](the trailing path components) only for GET requests, never for POST. So a POST endpoint cannot receive an id in the URL path — it must take the id as a query item. This is why decommission uses?nodeId=…(v1.2), not/decommission/{nodeId}as v1.1 specified.Note for the Domio implementer: build these calls exactly like the existing
IndigoRESTService.pluginRequest()/DeviceHistoryService.performRequest()— resolve the reflector, build{base}/message/com.simons-plugins.indigo-matter/{handler}, addAuthorization: Bearer {credential}. Pass all parameters, including the nodeId for POST handlers, as URL query items; a trailing/{pathArg}works only for GET (e.g.GET …/commission/{jobId}).
Content-Type: application/json).url_query_args (and body_params if form-encoded).2026-05-15T14:23:45+01:00).nodeId — string, the Matter node identifier as managed by the Indigo fabric. Stored as a string to avoid 64-bit JSON number issues.jobId — string UUID v4, generated by the plugin.indigoDeviceId — integer, the standard Indigo device ID.…/message/com.simons-plugins.indigo-matter/{handler}.
https://{indigo-host}:8176/message/com.simons-plugins.indigo-matter/{handler} — IWS serves HTTPS on 8176 (self-signed cert); local requests authenticate with Indigo’s Digest auth.https://{reflector}.indigodomo.net/message/com.simons-plugins.indigo-matter/{handler} — Bearer API key. This is the path Domio uses.{handler} are positional arguments on GET only — the plugin reads them from action.props["file_path"], which IWS populates for GET but not POST. E.g. GET …/commission/{jobId} puts jobId as the path arg. A POST endpoint must instead carry its id as a query item (see §3.4).Because the endpoints are served by IWS over the Reflector, they reuse Indigo’s existing
Reflector / API-key authentication. Domio includes the same Authorization: Bearer {key}
header it already sends for Indigo REST/WebSocket calls. Indigo enforces the credential
before the request reaches the plugin handler — the plugin does not implement its own
auth check. A missing/invalid credential is rejected by Indigo with its standard
401 Unauthorized (not generated by this plugin). No new credential type for v1.
GET …/message/com.simons-plugins.indigo-matter/statusHealth check. Domio calls this at the start of every commissioning flow as a precheck.
Request: no parameters.
Response 200:
{
"ready": true,
"controllerVersion": "2026.0.1",
"matterServerReachable": true,
"matterServerVersion": "0.6.2",
"fabricId": "0x0123456789ABCDEF",
"nodeCount": 7,
"bleAvailable": false,
"uptime": 14523
}
| Field | Type | Notes |
|---|---|---|
ready |
bool | True only if the plugin is fully initialised and matter-server is reachable. |
controllerVersion |
string | The indigo-matter plugin version (YYYY.R.P). |
matterServerReachable |
bool | False if the WebSocket to matter-server is currently down. |
matterServerVersion |
string | matter-server’s reported version. May be unknown if WS just connected. |
fabricId |
string | Hex-encoded 64-bit fabric ID. |
nodeCount |
int | Number of Matter nodes currently on the Indigo fabric. |
bleAvailable |
bool | Whether the host Mac has BLE available to matter-server. Affects fallback commissioning paths but not the standard Domio flow. |
uptime |
int | Plugin uptime in seconds. |
Response 503: if the plugin is loaded but matter-server is unreachable.
{
"ready": false,
"matterServerReachable": false,
"error": "matter_server_unreachable",
"message": "Cannot reach matter-server at ws://localhost:5580"
}
POST …/message/com.simons-plugins.indigo-matter/commissionSubmit a setup code for commissioning into the Indigo fabric as a second admin (multi-admin handoff; workspace ADR-0004). The setup code Domio forwards here can come from either supported flow:
domioNodeId and expectedFabricSlots are absent.MatterSupport/MatterExtension, then opens a commissioning window itself and auto-forwards the fresh setup code — no third-party ecosystem involved, and the device never joins Apple Home. Domio additionally sends domioNodeId (its own fabric’s node id, for log correlation) and may send expectedFabricSlots (a hint that the device should still have at least N fabric slots free after Indigo joins).Either way the plugin’s join is identical: a setup code is a setup code, indistinguishable by source, and joins over IP as the Indigo fabric’s second (or third, under C3) admin.
Request parameters (URL query items):
| Param | Type | Required | Notes |
|---|---|---|---|
setupCode |
string | yes | Either MT:... QR payload or 11-digit numeric pairing code, as returned by Domio’s OpenCommissioningWindow. |
discriminator |
int | no | Matter discriminator. If absent, the plugin extracts it from the setup code. |
suggestedName |
string | yes | User-chosen friendly name. Applied to all created Indigo devices (with “ (endpoint N)” suffix for multi-endpoint nodes). |
suggestedRoom |
string | no | Indigo room name. Devices are placed here on creation. If the room doesn’t exist, the plugin creates it. |
domioNodeId |
string | no | Domio’s nodeId for this device on its own fabric. Logged for correlation only. |
expectedFabricSlots |
int | no | Hint from Domio (C3 only) that the device should still have at least N fabric slots free after this join. The plugin computes available slots as the interviewed node’s Operational Credentials SupportedFabrics − CommissionedFabrics and logs a warning (job still succeeds) if that’s fewer than N, or stays silent if the node’s interview snapshot didn’t expose those attributes. |
Response 202: commissioning started.
{
"jobId": "8c9d3a14-7e1b-4f8e-9b2a-1d5c8e9f3a7b",
"estimatedDurationSeconds": 30
}
Response 409: an identical request is already in flight (deduplication by setupCode).
{
"error": "duplicate",
"existingJobId": "8c9d3a14-7e1b-4f8e-9b2a-1d5c8e9f3a7b",
"message": "Commissioning for this setup code is already in progress"
}
Response 400: malformed request.
{
"error": "invalid_setup_code",
"message": "Setup code must be either MT:... QR payload or 11-digit numeric"
}
Response 503: matter-server is unreachable (v1.3). The job is not created — the request would otherwise be accepted (202) only for the worker to fail with a generic internal_error at its first WebSocket call. Domio should treat this like the status-precheck 503 and prompt the user to retry.
{
"error": "matter_server_unreachable",
"message": "Not connected to matter-server; check it is running and reachable, then retry"
}
GET …/message/com.simons-plugins.indigo-matter/commission/{jobId}Poll commissioning job status. jobId is the trailing path component. Domio polls every 1s, with a 120s soft timeout.
Response 200 (in progress):
{
"jobId": "8c9d3a14-7e1b-4f8e-9b2a-1d5c8e9f3a7b",
"status": "reading_descriptors",
"progress": 0.6,
"message": "Discovering device capabilities…",
"startedAt": "2026-05-15T14:23:45+01:00",
"elapsedSeconds": 12
}
status is one of:
| Status | Meaning | progress range |
|---|---|---|
pending |
Job accepted, not yet started | 0.0 |
commissioning |
matter-server is performing PASE/CASE handshake | 0.1–0.5 |
reading_descriptors |
Interviewing the node for clusters/endpoints | 0.5–0.7 |
creating_devices |
Plugin is creating Indigo devices and subscribing | 0.7–0.95 |
success |
Done | 1.0 |
failed |
Terminal failure | — |
Response 200 (success):
{
"jobId": "8c9d3a14-7e1b-4f8e-9b2a-1d5c8e9f3a7b",
"status": "success",
"progress": 1.0,
"result": {
"nodeId": "0xABCDEF0123456789",
"vendorId": 4488,
"productId": 4660,
"vendorName": "TP-Link",
"productName": "Tapo P125M",
"indigoDeviceIds": [1234567890],
"primaryDeviceId": 1234567890,
"nodeDeviceId": 1234567891,
"endpointCount": 1
}
}
result.primaryDeviceId is nullable and result.indigoDeviceIds may be empty
(v1.3): primaryDeviceId is null when the device joined the fabric but exposes no
cluster the plugin maps yet — the device is on the fabric and will gain Indigo devices
when support is added. The job still reports success. Domio MUST handle a null
primaryDeviceId (e.g. skip navigating to a device detail view).
result.nodeDeviceId is also nullable (v1.4, additive) — it is the id of the node’s own
synthetic matterNode Indigo device (ADR-0008), distinct from primaryDeviceId/
indigoDeviceIds (the per-endpoint devices). It is null when: the node hasn’t
cleared the ADR-0003 creation gate yet on this pass (its BasicInformation
AttributeList doesn’t positively evidence NodeLabel or SoftwareVersionString —
unknown is not yes, and the next reconcile tries again); the node’s device was
previously deleted by hand and is tombstoned (issue #204) and not yet restored via
the “Recreate Matter node devices…” menu item; or the node’s endpoint plan is empty
(issue #105’s empty-bridge case — nothing for a node device to be the root of). The
job still reports success in every one of these cases. Domio MUST handle a null
nodeDeviceId the same way it already handles a null primaryDeviceId.
Response 200 (failed):
{
"jobId": "8c9d3a14-7e1b-4f8e-9b2a-1d5c8e9f3a7b",
"status": "failed",
"progress": 0.4,
"error": {
"code": "commissioning_failed",
"message": "Device did not respond to PASE handshake within 30 seconds",
"matterErrorCode": 50
}
}
Response 404: unknown jobId (expired or never created). Plugin retains jobs for 15 minutes after terminal state.
{ "error": "job_not_found" }
matterErrorCode is matter-server’s reported error code, which for v0.6.2 may or may not
match Matter spec error codes — verify against matter-server’s source. The code and
message fields are the authoritative parts; matterErrorCode is advisory.
Standard error codes:
| Code | Meaning |
|---|---|
commissioning_failed |
matter-server returned a failure during commissioning. Can also appear on a job that first failed commissioning_timeout, if matter-server’s own answer to the commission RPC later arrives and says the attempt failed — see “Late failure” below. (v1.4) |
commissioning_timeout |
matter-server did not finish commissioning within the plugin’s commission timeout (300s). The device may still join — matter-server keeps commissioning in the background. Check Indigo before retrying; see the reconcile note below. (v1.3, additive) |
device_unreachable |
Device couldn’t be reached on the network |
pase_failed |
PASE handshake failed (usually wrong setup code or device already commissioned) |
case_failed |
CASE session establishment failed |
interview_failed |
Couldn’t read device descriptors after commissioning |
unsupported_device |
Device has no clusters mappable by v1 plugin |
internal_error |
Plugin or matter-server internal error; check logs |
Timeout reconcile (v1.3): a job that failed with commissioning_timeout is not
necessarily over — matter-server keeps commissioning in the background (observed
~124s real-world vs the old 60s client timeout). If the node joins within ~5 minutes
of the timeout, the plugin reconciles: it applies the job’s suggestedName/
suggestedRoom to the created Indigo devices and flips the job back to success
with the full result payload. A client still polling past its own soft timeout
will therefore see failed → (possibly creating_devices) → success. Clients
that stopped polling should treat commissioning_timeout as “may still finish —
check Indigo”, which matches Domio’s existing 120s soft-timeout message. The
plugin’s job-level commission deadline is 300s and may legitimately outlive
Domio’s 120s poll window. The plugin reconciles only when the joining node can
be attributed unambiguously, and the ambiguity check only considers jobs whose
node is still unidentified: a candidate already identified with a
different node drops out of the ambiguity set entirely (e.g. job A is
already identified with node X and job B is still unidentified, both
in-window — node Y joins and B alone is claimed, unambiguously). Among the
still-unidentified in-window jobs, if two of them are for different setup
codes, neither is claimed (both stay failed, and the devices are created
with the device’s own product name). Retries of the same setup code are one
device and reconcile normally. The v1.4 exact-identity claim (see “Late
failure” below) works regardless of how many setup codes are waiting
unidentified, because it matches on node id directly rather than by
elimination. If the window closes with no join, the event log records that
definitively — no contract change, since a still-failed job’s payload is
unaffected.
Late failure (v1.4): the commission RPC itself can also answer late — after the
job has already gone failed/commissioning_timeout — and say the attempt did not
succeed. matter-server has no separate “commissioning failed” event; this RPC answer
is the only definitive word the plugin will ever get on why a timed-out commission
actually ended, so the job’s error is corrected once it arrives: error.code becomes
commissioning_failed and error.message explains a late answer arrived. This is
the one case where a job’s fields change after going terminal while it stays
failed (the v1.3 timeout reconcile above also changes a terminal job’s fields, but
by leaving failed for success) — this is a failed → failed transition, never
failed → success; a late success answer is not surfaced to Domio at all — it
only helps the plugin attribute a subsequent node_added (see the reconcile
paragraph above), and, internally, feeds the v1.4 exact-identity attribution and the
orphan-node cleanup that runs when the reconcile window closes unclaimed — the
already-claimed-node removal guard is consulted there, with the late id as its
argument, but recording the id does not by itself make the still-failed job hold
the node (that guard only counts non-failed jobs) — neither of which is itself
surfaced to Domio. A client that already stopped polling a failed job will simply
never see the correction; one still polling sees the error fields update in place.
POST …/message/com.simons-plugins.indigo-matter/decommission?nodeId={nodeId}Decommission a device. nodeId is a query parameter. Removes the plugin’s fabric from
the device (sends RemoveFabric via matter-server) and deletes the associated Indigo
devices. In the share model this removes only Indigo’s administration — the device remains
commissioned in any other fabric (e.g. Apple Home).
v1.0 specified this as
DELETE /matter/devices/{nodeId}. IWS exposes only GET/POST to plugin handlers, so it became a POST in v1.1. In v1.2 the id moved from a path component to a query param (?nodeId=…): IWS does not deliver trailing path components to POST handlers, so the v1.1/{nodeId}path form could never work. Semantics are identical and idempotent. (The plugin still accepts a/{nodeId}path component as a fallback for any transport that supplies one.)
Request parameters (URL query items):
| Param | Type | Required | Notes |
|---|---|---|---|
nodeId |
string | yes | Matter node id (0x… hex or decimal), as returned by commission. |
Response 200:
{
"nodeId": "0xABCDEF0123456789",
"removedIndigoDeviceIds": [1234567890],
"fabricRemoved": true
}
fabricRemoved is false if the device was unreachable; in that case the plugin removes its local state but the device may retain stale fabric credentials until factory-reset.
Response 404: unknown nodeId.
GET …/message/com.simons-plugins.indigo-matter/diagnostics/{nodeId}Diagnostic snapshot for a single device. nodeId is the trailing path component (a ?nodeId=… query param is also accepted). Used by a future Domio device-detail view.
Response 200:
{
"nodeId": "0xABCDEF0123456789",
"reachable": true,
"lastSeen": "2026-05-15T14:25:01+01:00",
"vendorId": 4488,
"productId": 4660,
"vendorName": "TP-Link",
"productName": "Tapo P125M",
"softwareVersion": "1.2.3",
"hardwareVersion": "1.0",
"fabrics": [
{ "fabricIndex": 1, "fabricId": "0x0123456789ABCDEF", "label": "Indigo" },
{ "fabricIndex": 2, "fabricId": "0xFEDCBA9876543210", "label": "Domio" }
],
"network": {
"type": "wifi",
"rssi": -52,
"ipv6": ["fe80::1234:5678:9abc:def0"]
},
"endpoints": [
{
"endpointId": 1,
"deviceType": "OnOffPlugInUnit",
"clusters": ["OnOff", "Descriptor", "Identify"],
"indigoDeviceId": 1234567890
}
]
}
For Thread devices, network.type is "thread" with fields routerId, linkQualityIn, linkQualityOut, borderRouter.
Response 404: the node is genuinely unknown to matter-server.
Response 503 matter_server_unreachable (v1.5): the socket to matter-server
is down, or the read timed out. These are the only two conditions that produce
a 503 here — both mean “the server did not answer”, so Domio’s retry prompt is
the right response.
Response 500 internal_error (v1.5): matter-server answered but the answer
could not be used — a protocol error, or a fault in the plugin. Previously
these were also reported as 503, which sent Domio away to retry against a
server that was up and answering, over a fault that would recur on every
attempt. A 500 here should not be retried automatically; the plugin’s event
log names the cause.
status value repeats for >10s.jobId without race conditions.POST …/commission is idempotent on setupCode: identical setup code in flight returns the existing jobId (409).POST …/decommission/{nodeId} is idempotent: decommissioning an unknown node returns 404 but is harmless.None in v1. Single-user system. Plugin SHOULD log abnormal traffic patterns but not enforce limits.
This contract is v1.4. Changes from v1.3 (additive, no transport change; the one
case where a job’s fields change after going terminal while it stays failed
— the v1.3 timeout reconcile below also changes a terminal job, but by leaving
failed for success):
failed job’s error can now be corrected in place if matter-server’s own
answer to the commission RPC arrives late and reports the attempt failed —
error.code becomes commissioning_failed (see the error table note and the “Late
failure” paragraph). status never changes (failed stays failed); a late
success answer does not update error and is not otherwise surfaced to Domio.result.nodeDeviceId — the id of the node’s synthetic matterNode
device (ADR-0008), nullable for the same reasons primaryDeviceId is (see the
nullability paragraph below the sample). Additive, no version bump per §7’s own
rule for new optional fields.Changes from v1.4 (v1.5, one behaviour change, no transport change):
diagnostics now returns 500 internal_error where it used to
return 503 for a matter-server protocol error or a plugin fault. 503 is
reserved for the two conditions that genuinely mean “no answer” — the socket
being down, and the read timing out. Domio should keep prompting a retry on
503 and should NOT auto-retry a 500.Changes from v1.2 (v1.3, all clarifications/additive, no transport change):
commissioning_timeout — the commission RPC to matter-server timed out (300s) but the device may still join; a job that failed this way can later flip back to success when the node arrives (timeout reconcile, see §3.3).result.primaryDeviceId is documented as nullable and result.indigoDeviceIds as possibly empty — null/empty when the commissioned device exposes no cluster the plugin maps yet (the job still reports success).POST …/commission returns 503 matter_server_unreachable (instead of accepting the job) when the plugin’s WebSocket to matter-server is down.error/message envelope fields are confirmed as required (they were documented in v1.2 but not emitted by the plugin).Changes from v1.1 (v1.2): decommission’s nodeId moved from a path component to a ?nodeId= query param (the path form never worked — IWS drops trailing path components on POST); diagnostics additionally accepts ?nodeId=; the local base URL is HTTPS, not HTTP. v1.1 itself was a transport change from v1.0 (semantics unchanged). Breaking changes require a version bump and a transition period where both versions are supported. Additive changes (new optional params, new handlers) do not require a version bump.
Domio includes X-Matter-API-Version: 1.4 in every request — it has not been
bumped to 1.5, and does not need to be. The plugin does not validate the
header, and 1.3, 1.4 and 1.5 clients are all wire-compatible: every v1.4 change
is additive (a client still sending 1.3 simply ignores the new optional
field), and v1.5 changes no payload at all — only which status code §3.5
diagnostics uses for a fault it was already reporting. A client that has not
mirrored v1.5 is not broken by it; it just retries a 500 it need not retry.
For forward planning, these handlers are reserved:
POST …/open-commissioning-window/{nodeId} — generate a new setup code for sharing to another fabric.POST …/identify/{nodeId} — trigger Matter Identify cluster (blink the device).GET …/fabrics — list fabrics across all nodes.POST …/backup — create a fabric backup zip.Domio Plugin (via IWS / Reflector)
GET …/message/com.simons-plugins.indigo-matter/status
200 { ready: true, ... }
[Domio invokes MatterAddDeviceRequest, iOS handles
commissioning into Domio's fabric. Result: nodeId 0x1234...]
[Domio sends OpenCommissioningWindow command to device.
Setup code generated: "12345678901". Window valid 180s.]
POST …/message/com.simons-plugins.indigo-matter/commission
?setupCode=12345678901&suggestedName=Office%20Fan&suggestedRoom=Office
202 { jobId: "8c9d..." }
GET …/message/com.simons-plugins.indigo-matter/commission/8c9d...
200 { status: "commissioning", progress: 0.3 }
GET …/commission/8c9d... (1s later)
200 { status: "reading_descriptors", progress: 0.6 }
GET …/commission/8c9d... (1s later)
200 { status: "creating_devices", progress: 0.9 }
GET …/commission/8c9d... (1s later)
200 { status: "success", result: { ... } }
[Domio refreshes Indigo device list, navigates to indigoDeviceId 1234567890]