Skip to main content
Version: 3.0

MQTT Write Endpoint

Let sensors, PLCs and gateways publish values into the UNS over MQTT through their own inbox topic, checked like any REST API write.

Enabled
By default
Max payload
1 MiB
Reject feed
Alerts › Ingress rejects

External MQTT clients (sensors, PLCs, SCADA gateways) can publish data into the Unified Namespace. The rule that makes this safe: reading the UNS is a projection; writing the UNS is a function call. Devices never write data-node topics directly. Instead, every device publishes into its own inbox, and MaestroHub admits the value through the same validation chain the REST API uses: schema, permissions, quality, alerts, history. The broker's data-node topics carry only admitted values, so every MQTT subscriber sees exactly what the catalog stores.

info

The write endpoint ships with UNS and is enabled by default (ingress.mqttWriteEnabled). Disabling it leaves the topic tree write-protected (external clients still cannot publish to data nodes), but nothing consumes the inbox.

The inbox grammar​

To write a signal, publish to its own path under your inbox:

<version>/<workspace-slug>/_ingress/<your-client-id>/<topic-path>

For example, device press-07 writing enterprise/site/temp in the workspace acme:

topic:    mHv1.0/acme/_ingress/press-07/enterprise/site/temp
payload: 23.5

The inbox segment must equal your MQTT client id: the broker enforces it, which is what makes per-device attribution trustworthy. Publishing to another device's inbox, or to any data-node topic, is refused. On the built-in broker the refusal is in the reject feed (under the device's workspace) and on the device's _errors topic: an MQTT 5 device gets not authorized (0x87) in the PUBACK, and an MQTT 3.1.1 device, which has no reason codes, is disconnected without a PUBACK for that message (the acknowledgments of the messages before it are sent first). It is never acknowledged, because an acknowledgment tells the device its data landed: the device sends the message again when it reconnects, so fix its topic. The value is not delivered anywhere. The same rule applies to a Last Will: on the built-in broker a CONNECT whose will topic is outside the device's own inbox is refused as not authorized (MQTT 5 CONNACK 0x87, MQTT 3.1.1 return code 5) and listed in the reject feed. A will inside the inbox is delivered to MQTT subscribers of that topic, never retained, and not stored as a value. The retain flag is cleared on inbox publishes, for MQTT 3.1.1 and MQTT 5 alike: an inbox message is a request, not data, and retained it would replay to every later inbox subscriber (MaestroHub's own ingest included, after a restart) as a fresh publish. The publish is otherwise handled and acknowledged as usual (the device is not told on the wire), and nothing is retained on the inbox. Whether a topic keeps its last value for new subscribers is the topic's own retain setting in MaestroHub, not the device's flag. On the built-in broker each such publish is counted in uns_broker_inbox_retain_cleared_total (by workspace), and the broker log says so once per device connection, with its client ID.

Subscribing to an inbox. A client allowed to read an inbox topic can subscribe to it and receives the device's raw publish, as it was sent, next to the admitted value on the data-node topic. These are two topics: a filter that matches both (for example mHv1.0/acme/#) receives both, one copy of each. On a clustered built-in broker an inbox subscription works the same on every replica: a subscriber connected to another replica than the device receives the raw publish, and a persistent session receives the inbox publishes sent while it was away, in order, whichever replica it reconnects to. While such a session exists, the device's acknowledgment also waits until the publish is kept for it. An inbox nobody subscribes to costs nothing extra.

Payloads are raw values or a JSON envelope, detected by shape:

raw:      23.5   ·   true   ·   RUNNING
envelope: {"value": 23.5,
"timestamp": "2026-08-22T10:00:00Z", // optional, RFC3339
"quality": 0, // optional: 0 Good · 1 Uncertain · 2 Bad
"requestId": "req-42"} // optional, echoed in the reply

A JSON object without a value key is itself the published value, so structured payloads are legal. Payloads over 1 MiB are refused before parsing (the limit is ingress.maxPayloadBytes, see Operating the endpoint).

The QoS subscribers get​

The admitted value is published on its data-node topic at the QoS the device published it at, and at least at liveMirror.qos (1 by default). Each subscriber gets the lower of that and the QoS it subscribed at:

Device publishes atSubscriber at QoS 2 getsat QoS 1at QoS 0
QoS 2QoS 2, exactly onceQoS 1QoS 0
QoS 1QoS 1QoS 1QoS 0
QoS 0liveMirror.qos (1 by default)liveMirror.qosQoS 0

Exactly once end to end means: a value a device published at QoS 2, from the moment the device holds its PUBREC, reaches a subscriber that subscribed at QoS 2 once: live, or after a persistent session reconnects, on a single node or on any replica of a cluster, also when a replica dies in the middle of either QoS 2 handshake. If a replica dies after the value was stored but before the device got its PUBREC, the device sends it again and a subscriber may get it twice; it is never lost. Send a timestamp so such a resend is stored once too. The full list of what is and is not covered is in What exactly once covers.

Timestamps and resends​

Send a timestamp when every reading must be stored exactly once. A device resends a QoS 1 publish whose PUBACK it did not get, and the broker resends what MaestroHub had not acknowledged when a connection dropped. A reading that carries its own timestamp is stored once however often it arrives: a copy with the same time and value is recognised as the same reading (stored once, no second pipeline run, live publish or alert). A copy that comes back later keeps the stored reading as it was, whatever MaestroHub would judge it now (stale for arriving late, frozen, out of service): only the device declaring a worse quality level, or another value, is a correction (a resend that only changes the reason at the same level, Bad for one cause instead of another, keeps the stored one). The same time with a different value is a correction and replaces the stored reading.

A reading without a timestamp is dated when it is received, and its provenance says so in time_source:

time_sourceDated when
received_by_brokerthe built-in broker received the publish
received_by_maestrohubOnly on readings stored by an earlier version that used an external broker: MaestroHub received it from EMQX. No new reading gets it.
publishedit was published to MaestroHub without a time (REST, a pipeline)

Such a reading keeps its date through every retry of the delivery, and is never judged stale for arriving late. It never overwrites another reading: two received in the same microsecond on one signal are both kept, the second one microsecond later. A resend of it (MQTT DUP) whose first copy's acknowledgment was never written is matched to that copy by the replica that received it, within two minutes, and stored once: the device gave up waiting or lost its connection while the value was being stored, or the built-in broker ended a connection whose write hung (the values behind it too). A resend that cannot be matched (its PUBACK was written and lost on the way, another replica received the first copy, or MaestroHub restarted) is dated anew and can be stored as a second reading. Each one is counted, and while any occur the Overview shows a note.

When MaestroHub stops, it first lets the values being stored finish and acknowledges them (for up to 5 seconds), so a restart does not make devices resend values that are already stored.

Identity and permissions​

The data steward's existing topic grants govern every write, and there is nothing MQTT-specific to configure on topics. The write is attributed to a principal, and that principal needs topic:publish on the canonical path (never on _ingress paths, which are transport, not data).

The device authenticates at CONNECT as its own API client, with the client ID as MQTT username and the client secret as password, or an OAuth2 access token from that client as password. The write runs as that client: principal, with its own grants.

Auto-creating topics through the endpoint additionally requires topic:create; a publish-only device cannot invent topics.

Replies: how a device learns its verdict​

MQTT has no error responses, so the endpoint answers asynchronously. Every rejected publish is published as a JSON verdict to the device's own error topic:

mHv1.0/<workspace-slug>/_errors/<your-client-id>

{"status": "rejected", "topic": "…", "reason": "publish_denied",
"remediation": "ask your administrator to grant topic:publish on …",
"requestId": "req-42"}

Subscribe to your own _errors topic to hear rejects (works on MQTT 3.1.1). MQTT 5 clients can additionally set a Response Topic on the publish, and accepted and rejected verdicts are delivered there, provided it points inside your own _errors subtree (any other destination is ignored). MQTT 5 clients also receive every refusal as the PUBACK's reason code (for example not authorized 0x87, payload format invalid 0x99, quota exceeded 0x97; see acknowledged once decided), so a PUBACK of 0x00 means the value was stored.

Reject reasons are stable strings: publish_denied, autocreate_denied, schema_type_violation, foreign_workspace_topic, malformed_payload, rate_limited, retained_request, unknown_workspace, session_ended (the device's session ended, because its credential was revoked or its session expired, before the message was handled; reconnect and publish again), invalid_value (the value itself: wrong type for the topic, NaN or infinity, or text the historian cannot store such as a NUL character or bytes that are not UTF-8; resending it unchanged is refused again).

Operating the endpoint​

  • The reject feed: Alerts → Ingress rejects lists every refusal, coalesced per device + topic + reason with a running count. GET /api/v1/uns/ingress/rejects serves the same rows. Requires the uns_ingress_rejects:read permission.
  • The attention card: a sustained reject rate (≥ 1/min per workspace) raises a warning on the overview, linking to the feed.
  • Metrics: uns_ingress_accepted_total and uns_ingress_rejected_total{reason} in the monitoring stack; uns_broker_inbox_retain_cleared_total counts inbox publishes whose retain flag was cleared. For resends: uns_publish_unchanged_total (values that arrived with a time and value already stored, including a polled tag repeating its last sample), uns_publish_shifted_total (readings dated on arrival that were stored one microsecond later) and uns_ingest_redelivered_total{outcome} (restamped: matched to the first copy and stored once; unmatched: dated anew, possibly stored twice).
  • The resend note: unmatched resends in the last 15 minutes raise a note on the Overview with the count. It never changes the system status.
  • Payload size: ingress.maxPayloadBytes in config.yaml (default 1 MiB, from 1 KiB to 256 MiB). A larger payload is refused as malformed_payload. The broker uses the same limit, plus room for the topic and MQTT properties: it announces the limit to MQTT 5 clients when they connect (the CONNACK's Maximum Packet Size), and refuses a larger packet from the length in its first bytes, before reading the rest, and disconnects the client. The log names the client and this setting.
  • Rate limits: ingress.perClientRatePerSec (default 2,000 per second) and ingress.perWorkspaceRatePerSec (default 5,000 per second per vCPU, at least 20,000) in config.yaml; 0 disables a limit. A device over either limit is slowed down rather than refused: the broker waits to read its next message until the limit has room, so the device sends no faster than its limit and every value it is acknowledged is judged on its merits (uns_ingress_paced_total counts the messages that waited). A value that would have to wait more than 5 seconds for room is refused at once as rate_limited: at QoS 1 or 2 an MQTT 5 device hears quota exceeded (0x97), and an MQTT 3.1.1 device is disconnected without the acknowledgment and sends the value again. Publishes the broker refuses outside a device's inbox count against the same per-client limit: past it they are still counted in uns_ingress_rejected_total, but not listed in the reject feed or answered on _errors. They use up that budget: a device that keeps publishing outside its inbox can see its own valid values refused as rate_limited. The per-client limit is sized for a gateway: one MQTT client carrying about 1,000 tags at 1 Hz stays well under it, while a device stuck in a publish loop is still stopped. The per-workspace limit is about two thirds of what each vCPU stores without loss, so it grows with the machine: a normal plant never reaches it, but one workspace cannot take a whole shared server. A limit you set in config.yaml is kept; only unset limits take these defaults.

Which broker​

The write endpoint is served by MaestroHub's built-in broker, the only MQTT broker MaestroHub uses. Earlier versions could also use an external EMQX broker, with authentication and authorization delegated to MaestroHub; that option is gone, and ingress.substrateWatch, which watched such a broker for publishes that bypassed the inbox, is ignored. Point devices that published through EMQX at MaestroHub's MQTT address. See Upgrading from an external broker.