Skip to content
Stand with Ukraine flag

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.

All examples below use curl. Replace localhost:8083 with your server address if TBMQ is running remotely.

Obtain an access token by logging in:

Terminal window
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:

Terminal window
export ACCESS_TOKEN=PLACE_YOUR_TOKEN_HERE

Token lifetimes and the refresh flow are described under REST API.

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.

Terminal window
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"}
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.

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.

Terminal window
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 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.

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:

Terminal window
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}:

Terminal window
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.

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
Terminal window
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.

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.

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.

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.

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.

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.