Skip to main content

Message Routing Service

This quickstart guide walks through how to take advantage of the Message Routing Service (MRS) to forward data arriving to nRF Cloud to any destination you want.

The documentation assumes you are using the nRF Cloud CoAP transport and a template application such as Asset Tracker Template with the nRF Cloud library enabled.

Prerequisites

Before you begin, to follow this guide, you must have:

  • An nRF Cloud account with Admin permissions for the organization.
  • At least one onboarded device reporting to nRF Cloud.
  • A destination URL that can receive HTTP POST requests. See Prototype with a webhook site if you do not have one yet.
  • A working Python3 environment
note

The Message Routing Service currently lives in the legacy nRF Cloud experience but is in the process of being refreshed and included in the new experience.

Step 1: Prototype with a request bin

Before writing any backend code, you can quickly explore the Message Routing Service using a free webhook receiver such as webhook.site

Step 2: Create and verify the destination

To receive traffic, you will need to verify your destination. To do this, add the destination in the nRF Cloud portal under Device ManagementMessage Routing ServiceAdd Destination, as described in Using the Message Routing Service.

Your destination receives nothing until it is verified.

{
"type": "system.verification",
"messages": [{ "verificationToken": "171507" }],
"timestamp": "2026-07-30T15:51:58.222304705Z"
}

Copy the verificationToken value into the portal to finish verification. See Verification for the full flow.

Step 3: Read the envelope

Every request your destination receives has the same message structure: a type, a batch timestamp, and a messages array. Messages are batched, so always iterate over messages rather than reading messages[0].

Dispatch on type first:

typeMeaning
device.messagesReal device traffic. This is what you ingest.
system.verificationTwo-step verification token (Step 2).
testA test message triggered from the portal.

Inside messages, every entry carries deviceId, messageId, and receivedAt. What comes next depends on one thing:

  • topic is present — the message object is already decoded JSON. This is the normalized form, and it covers device messages and shadow updates.
  • coapRequestUrl is present — the message object is a {"request": …, "response": …} pair holding the raw CoAP exchange, with base64-encoded bodies. This is the form used for direct calls to nRF Cloud services such as ground fix.
info

Message order is not guaranteed. Order by the receivedAt timestamp within each message.

Step 4: How to Handle JSON Messages

A subset of messages arrive JSON decoded ready to use. In the following sections we'll walk through a few more details.

Device messages (.../d2c)

Sensor and GNSS data. message is a deviceToCloud message where appId names the data, data carries it, and ts is a Unix timestamp in milliseconds.

{
"deviceId": "50343959-3733-4607-80c9-1029aee309b3",
"topic": "prod/<teamId>/m/d/<deviceId>/d2c",
"message": {
"messageType": "DATA",
"appId": "TEMP",
"data": 25.26,
"ts": 1785427067372
},
"receivedAt": "2026-07-30T15:57:47.526Z"
}

The Asset Tracker Template, e.g, emits these appId values:

appIddataSource
TEMPNumber, °CBME680
AIR_PRESSNumber, kPaBME680
HUMIDNumber, % relative humidityBME680
BATTERYNumber, % chargeFuel gauge
GNSSObject: lat, lng, acc, and optionally spd, hdg, altGNSS fix

Here is an example of a GNSS payload with location data:

{
"messageType": "DATA",
"appId": "GNSS",
"data": {
"lat": 42.87026187867386,
"lng": -79.01603486048212,
"acc": 6.791696548461914,
"spd": 0.09337744861841202,
"alt": 177.94920349121094
},
"ts": 1785427645000
}

Two things to note when decoding these messages:

  • data is untyped in the protocol. It may be a number or a numeric string depending on the sending firmware, and the unit is determined by the firmware. The units above are what ATT sends but can be customized by your application. Additionally, data may be a single value (such as temperature), or a dictionary of values (such as latitude, longitude, etc.) depending on the context.
  • appId can be anything. is defined by your application and can be any string. Treat unrecognized values as data to skip, not as an error.

Shadow updates (.../shadow/update/documents and /delta)

documents messages carry previous and current full shadow documents; delta messages carry only what changed. Use documents when you want to mirror device state, and note that state.reported.device is where the useful fleet metadata lives:

{
"current": {
"state": {
"reported": {
"device": {
"deviceInfo": {
"appVersion": "1.5.4",
"modemFirmware": "mfw_nrf91x1_2.0.4",
"imei": "359404230232317",
"board": "thingy91x"
},
"networkInfo": { "currentBand": 12, "mccmnc": "302610" },
"connectionInfo": { "protocol": "CoAP", "method": "LTE" }
},
"config": { "sample_interval": 600, "storage_threshold": 1 }
}
}
}
}

config holds the ATT application settings, so this is also how you observe a configuration change taking effect on the device.

Step 5: Decoding CBOR encoded messages

For optimized device-to-cloud bandwidth, a number of frequently occurring messages are CBOR encoded.

When coapRequestUrl is present, both message.request.body and message.response.body are base64. You will first need to base64 decode the payload. Base64 does not mean CBOR — after decoding the base64 you get one of two formats depending on the endpoint:

coapRequestUrlBody format after base64 decode
FETCH /loc/ground-fixCBOR, integer keys
PATCH /state/reportedJSON

A reliable way to tell them apart in code is to look at the first byte: { (0x7b) is JSON, anything else is CBOR.

For example, here is a python script to handle the decode:

import base64
import json
import sys

import cbor2

# Key names from the SDK's nrf_cloud_coap_ground_fix.cddl
# https://github.com/nrfconnect/sdk-nrf/blob/v3.4.0/subsys/net/lib/nrf_cloud/coap/cddl/nrf_cloud_coap_ground_fix.cddl
GROUND_FIX = {
1: "earfcn", 2: "pci", 3: "rsrp", 4: "rsrq", 5: "timeDiff",
6: "mcc", 7: "mnc", 8: "eci", 9: "tac", 10: "adv", 11: "nmr",
12: "macAddress", 13: "age", 14: "signalStrength", 15: "channel",
16: "frequency", 17: "ssid", 18: "accessPoints", 19: "lte", 20: "wifi",
}


def name_keys(obj):
if isinstance(obj, dict):
return {GROUND_FIX.get(k, k): name_keys(v) for k, v in obj.items()}
if isinstance(obj, list):
return [name_keys(v) for v in obj]
if isinstance(obj, bytes):
return obj.hex()
return obj


body = base64.b64decode(sys.argv[1])
# Shadow bodies are JSON; location-service bodies are CBOR.
decoded = json.loads(body) if body[:1] == b"{" else name_keys(cbor2.loads(body))
print(json.dumps(decoded, indent=2))

Ground fix

The integer keys come from nrf_cloud_coap_ground_fix.cddl in the nRF Connect SDK. That file is the authoritative mapping for both the request and the response. Running the script on a ground fix request body gives:

{
"lte": [
{
"mcc": 302,
"mnc": 610,
"eci": 142736141,
"tac": 55516,
"earfcn": 5145,
"rsrp": -95,
"rsrq": -11.0,
"nmr": [
{
"earfcn": 2325,
"pci": 109,
"rsrp": -111,
"rsrq": -15.5,
"timeDiff": 24
}
]
}
],
"wifi": {
"accessPoints": [{ "macAddress": "c04a008cb39f", "signalStrength": -54 }]
}
}

macAddress is a byte string, shown here hex-encoded. Up to five cells and twenty access points can appear in one request.

The contents of this request determine how accurate the resulting fix is. See Best Practices for High Accuracy Location for what makes a good scan.

The coordinates are probably not in this message

Ground fix is a two-step exchange: the device uploads the scan above, and nRF Cloud resolves it to coordinates. Whether those coordinates come back on the wire depends on the doReply query parameter you can see in coapRequestUrl.

The Asset Tracker Template sets doReply=false, so the forwarded response is empty — nRF Cloud stores the location but does not return it. If you need resolved coordinates in your own cloud, you have two options:

  • Build firmware with do_reply = true (in ATT, handle_cloud_location_request() in app/src/modules/cloud/cloud_location.c). The response body then carries a CBOR ground_fix_resp with keys 1: lat, 2: lon, 3: uncertainty, and 4: fulfilledWith. Confirm the forwarded shape against your own capture.
  • Resolve it yourself: pass the decoded cells and access points to nRF Cloud Location Services.

Note that the response uses lon, while the GNSS device message uses lng.

Shadow PATCH

PATCH /state/reported bodies are JSON, so no CBOR is involved:

{
"device": {
"networkInfo": {
"currentBand": 12,
"areaCode": 55516,
"mccmnc": "302610",
"cellID": 142736141,
"networkMode": "LTE-M GPS"
}
}
}

This is the device writing its own state. The resulting merged document arrives separately as a documents message (Step 4), which is usually the one you persist.

Step 6: Hardening for production

We recommend the following for production use cases

  • Authenticate requests. Authenticate each payload received with the HMAC hex digest of the payload. See Authentication.
  • Return 2XX quickly. Acknowledge first and process asynchronously. Failed deliveries are retried for 24 hours and then removed.
  • Deduplicate on messageId. Retries mean you can see the same message twice.
  • Filter at the source. If you only care about some traffic, use message filtering categories — location, shadow, fota, device_messages, cloud_messages so you are not billed for deliveries you discard.
  • Skip unknown shapes without failing. New appId values and new message types will appear over time.

Further References

Questions or Feedback?

Reach out to us on the DevZone community forum.