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
POSTrequests. See Prototype with a webhook site if you do not have one yet. - A working Python3 environment
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 Management → Message Routing Service → Add 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:
type | Meaning |
|---|---|
device.messages | Real device traffic. This is what you ingest. |
system.verification | Two-step verification token (Step 2). |
test | A test message triggered from the portal. |
Inside messages, every entry carries deviceId, messageId, and
receivedAt. What comes next depends on one thing:
topicis present — themessageobject is already decoded JSON. This is the normalized form, and it covers device messages and shadow updates.coapRequestUrlis present — themessageobject 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.
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:
appId | data | Source |
|---|---|---|
TEMP | Number, °C | BME680 |
AIR_PRESS | Number, kPa | BME680 |
HUMID | Number, % relative humidity | BME680 |
BATTERY | Number, % charge | Fuel gauge |
GNSS | Object: lat, lng, acc, and optionally spd, hdg, alt | GNSS 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:
datais 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,datamay be a single value (such as temperature), or a dictionary of values (such as latitude, longitude, etc.) depending on the context.appIdcan 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:
coapRequestUrl | Body format after base64 decode |
|---|---|
FETCH /loc/ground-fix | CBOR, integer keys |
PATCH /state/reported | JSON |
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.
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()inapp/src/modules/cloud/cloud_location.c). The response body then carries a CBORground_fix_respwith keys1: lat,2: lon,3: uncertainty, and4: 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_messagesso you are not billed for deliveries you discard. - Skip unknown shapes without failing. New
appIdvalues and new message types will appear over time.
Further References
- nRF Cloud CoAP endpoints — the full list
of resources a CoAP device can call, and therefore the
coapRequestUrlvalues you may see. - application-protocols — JSON schemas for device messages and shadow documents.
Questions or Feedback?
Reach out to us on the DevZone community forum.