indigo-matter

Matter API Contract — Domio ↔ Indigo Plugin

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 — see domio-code DeviceHistoryService / 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}, add Authorization: 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}).

1. Conventions

2. Authentication

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.

3. Endpoints

3.1 GET …/message/com.simons-plugins.indigo-matter/status

Health 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"
}

3.2 POST …/message/com.simons-plugins.indigo-matter/commission

Submit 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:

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"
}

3.3 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 failedfailed transition, never failedsuccess; 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.

3.4 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.

3.5 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.

4. Polling Semantics

5. Idempotency

6. Rate Limits

None in v1. Single-user system. Plugin SHOULD log abnormal traffic patterns but not enforce limits.

7. Versioning

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):

Changes from v1.4 (v1.5, one behaviour change, no transport change):

Changes from v1.2 (v1.3, all clarifications/additive, no transport change):

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.

8. Future Endpoints (Not in v1)

For forward planning, these handlers are reserved:

9. Example End-to-End Sequence

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]