MQTT message publishing
TBMQ can publish an MQTT message on behalf of the broker itself, over REST. A backend service that already authenticates against the broker’s REST API can push a message to subscribers without opening an MQTT connection.
The message travels the same path as a PUBLISH received from a connected client — the cluster-wide throughput quota, the retained message store, the publish queue, and delivery — so retained flags, MQTT 5 properties, and QoS behave exactly as they do for a normal publisher.
The management console wraps this call in a form: the
Publish messages page composes the
request, shows the broker’s answer, and copies the call as a ready-to-run curl command.
All examples below use curl. Replace localhost:8083 with your server address if TBMQ is running remotely.
Authentication
Section titled “Authentication”Obtain an access token by logging in:
curl --location --request POST 'http://localhost:8083/api/auth/login' \--header 'Content-Type: application/json' \--data-raw '{ "username":"sysadmin@thingsboard.org", "password":"sysadmin"}'The response contains a token field. Export it for use in subsequent requests:
export ACCESS_TOKEN=PLACE_YOUR_TOKEN_HEREToken lifetimes and the refresh flow are described under REST API.
Publishing is a write operation, so the account must hold the Administrator
role. A Viewer is rejected with
403 Forbidden.
Publish a message
Section titled “Publish a message”Subscribe to the topic first, so you can confirm the message arrives and see how the payload and properties reach a
client. The WebSocket client in the
management console is the quickest way. Publishing to a topic nobody is subscribed to succeeds too, but answers 202
rather than 200 — see Response codes.
curl --location --request POST 'http://localhost:8083/api/mqtt/publish' \--header "X-Authorization: Bearer $ACCESS_TOKEN" \--header 'Content-Type: application/json' \--data-raw '{ "topic": "devices/a/commands", "payload": "eyJjbWQiOiAicmVib290In0=", "qos": 1}'The payload is Base64 by default, so the request above publishes the bytes {"cmd": "reboot"}.
A successful call returns the PUBACK reason code the broker would have sent to an MQTT publisher:
{"reasonCode":0,"message":"Success"}Request fields
Section titled “Request fields”| Field | Required | Default | Description |
|---|---|---|---|
topic |
yes | — | Topic name to publish to. See Topic rules. |
payload |
yes | — | Message payload, in the form declared by payloadEncoding. |
payloadEncoding |
no | BASE64 |
How to read payload. See Payload encodings. |
qos |
no | 0 |
Quality of Service: 0, 1, or 2. |
retain |
no | false |
Store the message as the retained message for the topic. |
properties |
no | — | MQTT 5 PUBLISH properties. See MQTT 5 properties. |
A null payload is rejected.
Clear a retained message
Section titled “Clear a retained message”Publishing an empty payload with retain set removes the topic’s retained message, exactly as an empty retained
PUBLISH from an MQTT client does. “Empty” means zero bytes, so use an empty string under BASE64 or TEXT — under
JSON there is no value that encodes to nothing.
curl --location --request POST 'http://localhost:8083/api/mqtt/publish' \--header "X-Authorization: Bearer $ACCESS_TOKEN" \--header 'Content-Type: application/json' \--data-raw '{ "topic": "devices/a/commands", "payload": "", "payloadEncoding": "TEXT", "retain": true}'See Retained messages.
Topic rules
Section titled “Topic rules”topic is a topic name, not a topic filter. The broker rejects a request with 400 Bad Request when the topic:
- contains a wildcard character —
#or+; - starts with
$; - contains the null character,
U+0000; - is empty, or longer than 65535 bytes;
- exceeds the topic-segment limit, when one is configured.
These are the ordinary MQTT topic-name rules, described in full — with the segment limit and its default — under Topics.
A responseTopic is validated by the same rules when it is present and non-empty. An empty one is dropped rather than
rejected.
Payload encodings
Section titled “Payload encodings”payloadEncoding decides how the broker turns the JSON payload field into message bytes.
| Encoding | payload must be |
Published bytes |
|---|---|---|
BASE64 |
a string | the decoded bytes — use this for binary payloads |
TEXT |
a string | its UTF-8 bytes |
JSON |
any JSON value | its compact JSON text, UTF-8 encoded |
TEXT is the convenient choice for a plain string:
curl --location --request POST 'http://localhost:8083/api/mqtt/publish' \--header "X-Authorization: Bearer $ACCESS_TOKEN" \--header 'Content-Type: application/json' \--data-raw '{ "topic": "devices/a/commands", "payload": "reboot", "payloadEncoding": "TEXT"}'JSON lets you embed a JSON document directly instead of escaping it into a string. The broker publishes its compact
form, so the message below is delivered as {"cmd":"reboot","delay":5}:
curl --location --request POST 'http://localhost:8083/api/mqtt/publish' \--header "X-Authorization: Bearer $ACCESS_TOKEN" \--header 'Content-Type: application/json' \--data-raw '{ "topic": "devices/a/commands", "payload": {"cmd": "reboot", "delay": 5}, "payloadEncoding": "JSON"}'Under BASE64 and TEXT the payload has to be a JSON string — passing an object or an array is rejected with
400 Bad Request. Use JSON to publish a JSON value.
MQTT 5 properties
Section titled “MQTT 5 properties”Every field of the optional properties object maps to the MQTT 5 PUBLISH property of the same name, and every one of
them is optional.
| Property | Type | Description |
|---|---|---|
payloadFormatIndicator |
0 or 1 |
0 — unspecified bytes, 1 — UTF-8 character data |
messageExpiryInterval |
seconds | How long the message stays valid |
contentType |
string | MIME type of the payload; at most 65535 bytes of UTF-8 |
responseTopic |
string | Response topic for request/response messaging; must be a valid topic name |
correlationData |
Base64 string | Correlation data, decoded before it is attached |
userProperties |
object | Free-form key-value pairs. A null value is rejected with 400 |
curl --location --request POST 'http://localhost:8083/api/mqtt/publish' \--header "X-Authorization: Bearer $ACCESS_TOKEN" \--header 'Content-Type: application/json' \--data-raw '{ "topic": "devices/a/commands", "payload": {"cmd": "reboot"}, "payloadEncoding": "JSON", "qos": 1, "retain": false, "properties": { "payloadFormatIndicator": 1, "messageExpiryInterval": 3600, "contentType": "application/json", "responseTopic": "devices/a/replies", "correlationData": "cmVxLTQy", "userProperties": {"source": "backend"} }}'These properties reach MQTT 5 subscribers as sent. MQTT 3.1.1 has no properties in its PUBLISH packet, so a subscriber on that version receives the payload without them.
Response codes
Section titled “Response codes”| Code | Meaning |
|---|---|
200 OK |
Queued, and at least one subscription matched the topic. Body carries reason code 0. |
202 Accepted |
Queued, but no subscription matched. Body carries reason code 16. A retained message is still stored. |
400 Bad Request |
Invalid topic, payload, encoding, or MQTT property. |
401 Unauthorized |
The X-Authorization token is missing, expired, or invalid. |
403 Forbidden |
The token is valid but the account may not publish. |
411 Length Required |
The request carried no Content-Length header. Chunked bodies are not accepted. |
413 Payload Too Large |
The request body exceeds the size guard. See Configuration. |
415 Unsupported Media Type |
Content-Type is not application/json. |
429 Too Many Requests |
Refused by the cluster-wide throughput quota. |
503 Service Unavailable |
The publish queue rejected the message, or did not acknowledge it in time. |
202 is not an error. It is the REST equivalent of the NO_MATCHING_SUBSCRIBERS PUBACK reason code, and it is the
normal answer when you publish to a topic nobody is subscribed to.
403 is specific to PE: a Viewer may not publish, because publishing is a write operation. See
Role-based access control.
Error responses
Section titled “Error responses”Only 200 and 202 return the {reasonCode, message} body. Every failure returns the standard REST API error
envelope instead, so a client has to branch on the status code rather than parse one shape:
{ "status": 400, "message": "Topic name cannot contain wildcard characters!", "errorCode": 31, "timestamp": "2026-09-10T12:34:56.789+00:00"}message carries the broker’s own explanation and is the field worth logging. errorCode is TBMQ’s internal code,
finer-grained than the HTTP status — 31 above is a bad request parameter.
Configuration
Section titled “Configuration”| Environment variable | Default | Description |
|---|---|---|
REST_PUBLISH_MAX_PAYLOAD_SIZE |
65536 |
Largest decoded message payload accepted, in bytes |
REST_PUBLISH_TIMEOUT_MS |
30000 |
How long to wait for the publish queue to acknowledge before answering 503 |
Two size checks apply. The request body is measured against its Content-Length before it is read, and is refused
with 413 above six times REST_PUBLISH_MAX_PAYLOAD_SIZE plus 16 KB — room for the JSON envelope and for the worst
case of a \uXXXX-escaped string, which is six bytes on the wire per payload byte. The bound is taken on the raw body,
so whitespace in a pretty-printed JSON payload counts against it even though the broker publishes the compact form.
The decoded payload is then measured against REST_PUBLISH_MAX_PAYLOAD_SIZE itself, and a payload over the limit is
rejected with 400.
Keep REST_PUBLISH_MAX_PAYLOAD_SIZE below the Kafka producer’s max.request.size, or an oversized message is
accepted by the API and then fails on its way to the queue.
Publisher client ID
Section titled “Publisher client ID”Messages published through this API are stamped with the client ID tbmq_rest_api, and TBMQ treats them as coming
from that publisher: it is the sender in the broker logs, and the MQTT 5 noLocal subscription option is evaluated
against it.
Nothing stops an MQTT client from connecting under the same ID, so treat it as reserved by convention. A client that
takes it will filter out REST publishes on any noLocal subscription it holds, and its log lines will interleave with
theirs.
Monitoring
Section titled “Monitoring”Calls that reach the publish path increment the restPublishMsgs_total counter, tagged accepted, quotaExceeded,
or failed. A request rejected earlier, by authorization or by request validation, does not reach it.
Prometheus metrics describes what each tag value
counts.
Dropped messages
Section titled Dropped messagesA REST publish that is lost is recorded on the
Dropped messages page like any other
reception-side loss, under the client ID tbmq_rest_api:
- Rate limits — the throughput quota refused the message, and the call returned
429. - Internal error — the publish queue rejected the message, and the call returned
503. - No subscribers — the message was queued and processed, but no subscriber topic filter matched it. The call
normally returned
202. It can disagree: the response is decided from the subscription trie before the message is queued, and the drop from a second lookup after it, so a subscription that disappears in between produces a200and a No subscribers entry.
Audit logs
Section titled Audit logsTBMQ can record every REST publish as an audit log entry, attributing it to the user who made the call. Because that writes one record per request, it is disabled by default. Turn it on with:
AUDIT_LOG_MASK_MQTT_MESSAGE=WEntries are recorded with the Published action type and succeed or fail with the call. The audit log page lists what each entry stores.
Was this helpful?