LoRaWAN Payload Decoder for Temperature Sensors: How It Works, With a Worked Example
Short Answer
A LoRaWAN payload decoder is a small function, usually JavaScript, that runs on your network server and converts the raw bytes a sensor transmits into readable values such as temperature: 24.35 and humidity: 58.2. Sensors send compact integers to save airtime and battery, so the decoder has to reverse four things the sensor did:
- Scaling: the sensor sent 2435, meaning 24.35 °C ×100.
- Signed values: negative temperatures are sent in two’s complement, so 0xFF38 means −2.00 °C, not 65,336.
- Byte order: most sensors send the high byte first (big-endian), but not all.
- Ports and message types: a heartbeat, an alarm and a boot message may use different ports and layouts.
Get any one of these wrong and the dashboard shows nonsense. The most common symptom, a freezer reading +653 °C, comes from treating a signed value as unsigned.
If you use a Macnman sensor, you don’t have to write a decoder: every product has a published one in our LoRa Module SDK.
Why LoRaWAN Sensors Don’t Just Send “24.35”
LoRaWAN payloads are tiny, so sensors send numbers as packed integers rather than text or floating point.
A temperature reading written as text ("24.35") takes five bytes. The same reading packed as an integer ×100 takes two. Across a payload with temperature, humidity, battery and a timestamp, packing keeps the message to around 10 bytes instead of 30 or more. On LoRaWAN, where every byte costs airtime, battery and shared network capacity, that matters.
The trade-off is that the bytes mean nothing until something unpacks them. That is the decoder’s job, and the sensor’s manufacturer is the only one who knows the exact layout, which is why a decoder is always specific to a device and its firmware version.
Where the Decoder Runs
The decoder runs on the network or application server after the payload has been decrypted, never on the gateway.
The sensor encrypts its payload with the application session key. The gateway just forwards the encrypted bytes. The network server decrypts them, then applies the decoder and delivers the resulting JSON to your application over MQTT or HTTP.
| Platform | Where the decoder lives | Function it calls |
|---|---|---|
| ChirpStack v4 | Device profile → Codec tab | decodeUplink(input) |
| ChirpStack v3 | Device profile (Application Server) | Decode(fPort, bytes) |
| The Things Stack (TTN) | Application or device → Payload formatters → Uplink | decodeUplink(input) |
| ThingsBoard | Integration → Uplink data converter | Decoder(payload, metadata) style converter |
| Platforms using the LoRaWAN device repository | Imported with the device profile | decodeUplink(input) |
ChirpStack v4 and The Things Stack use the same decodeUplink function signature, so one decoder usually works on both. For the ChirpStack steps, see How to Connect a LoRaWAN Sensor to ChirpStack.
Reading a Payload Specification
Every decoder starts from the manufacturer’s payload table, which lists what each byte means. Here’s the heartbeat message from the Macnman MacSync-L temperature and humidity sensor, sent on port 2 as 10 bytes:
| Byte | Field | Type | Scaling | Notes |
|---|---|---|---|---|
| 0 | Status flag | uint8 | — | 0 = reading OK, 1 = sensor read error |
| 1–2 | Temperature | int16, big-endian | ×100 | Signed: supports negative temperatures |
| 3–4 | Humidity | int16, big-endian | ×100 | %RH |
| 5 | Battery | uint8 | — | Percentage |
| 6–9 | Timestamp | uint32, big-endian | — | UTC seconds since 1970 |
A real packet looks like this:
00 09 83 16 C4 61 68 F3 2A 10Decoded by hand:
- Byte 0 =
0x00→ status OK - Bytes 1–2 =
0x0983= 2435 → 24.35 °C - Bytes 3–4 =
0x16C4= 5828 → 58.28 %RH - Byte 5 =
0x61= 97 → battery 97% - Bytes 6–9 =
0x68F32A10= 1760897552 → a UTC timestamp
Now a cold-room packet:
00 FF 38 1D 4C 5F 68 F3 2A 10- Bytes 1–2 =
0xFF38. Read as unsigned that’s 65,336, which would show as 653.36 °C. Read as signed int16 it’s −200 → −2.00 °C, the correct reading.
That single difference is the most common decoder bug in the field.
The Four Things a Decoder Must Handle
1. Scaling
Divide by the factor in the specification. ×10 gives one decimal place, ×100 gives two. Some sensors use offsets instead, for example “value − 40” to avoid negatives, so read the table rather than guessing.
2. Signed Values
A 16-bit field can hold 0 to 65,535 as unsigned, or −32,768 to +32,767 as signed. If the top bit is set, a signed value is negative. In JavaScript:
function s16(hi, lo) {
var v = (hi << 8) | lo;
return (v & 0x8000) ? v - 0x10000 : v;
}Any temperature field that can go below zero must be decoded this way.
3. Byte Order
Big-endian (high byte first) is the most common in LoRaWAN sensors, and is what the MacSync-L uses. Some sensors use little-endian (low byte first). If your readings look random rather than merely scaled wrongly, byte order is the first thing to check. For a value of 2435 (0x0983):
- Big-endian on the wire:
09 83 - Little-endian on the wire:
83 09
4. Ports and Message Types
Sensors often send different message types on different FPorts. The MacSync-L, for example, uses port 5 for a boot message with firmware version, port 2 for periodic readings, port 3 for multi-sample batches and port 4 for alarm and clear events. A decoder should branch on input.fPort and return a warning for anything it doesn’t recognise, rather than mis-decoding it.
A Worked Decoder: ChirpStack v4 and The Things Stack
This decoder handles the MacSync-L heartbeat above and returns clean JSON. The same code works in ChirpStack v4’s codec tab and in The Things Stack’s uplink payload formatter.
function decodeUplink(input) {
var b = input.bytes;
var port = input.fPort;
var data = {};
var warnings = [];
// Signed 16-bit, big-endian
function s16(hi, lo) {
var v = (hi << 8) | lo;
return (v & 0x8000) ? v - 0x10000 : v;
}
// Unsigned 32-bit, big-endian
function u32(a, c, d, e) {
return ((a << 24) | (c << 16) | (d << 8) | e) >>> 0;
}
// Port 2: periodic heartbeat, 10 bytes
if (port === 2 && b.length === 10) {
data.type = "heartbeat";
if (b[0] === 1) {
warnings.push("Sensor read error reported by device");
} else {
data.temperature = s16(b[1], b[2]) / 100; // °C, 2 decimals
data.humidity = s16(b[3], b[4]) / 100; // %RH
}
data.battery = b[5]; // %
data.timestamp = u32(b[6], b[7], b[8], b[9]); // UTC seconds
return { data: data, warnings: warnings };
}
warnings.push("Unhandled port " + port + " or length " + b.length);
return { data: data, warnings: warnings };
}Running it on the two packets above gives:
{ "type": "heartbeat", "temperature": 24.35, "humidity": 58.28, "battery": 97, "timestamp": 1760897552 }
{ "type": "heartbeat", "temperature": -2, "humidity": 75, "battery": 95, "timestamp": 1760897552 }Notes on good practice:
- Return numbers, not strings.
24.35, not"24.35". Dashboards and alert rules need numeric values to compare and graph. - Use warnings and errors. Both ChirpStack v4 and The Things Stack accept
warningsanderrorsarrays, which show up in the console and save hours of debugging. - Check lengths before reading bytes. A truncated packet should produce a warning, not a crash.
- Keep the full manufacturer decoder in production. The trimmed example above handles one port; the complete MacSync-L codec also decodes boot, alarm and batch messages and encodes downlink configuration.
The Same Decoder on ChirpStack v3 and ThingsBoard
Older ChirpStack and ThingsBoard use different function wrappers around the same logic.
ChirpStack v3 calls Decode(fPort, bytes) and expects the decoded object back directly. Wrap the function above:
function Decode(fPort, bytes) {
return decodeUplink({ fPort: fPort, bytes: bytes }).data;
}ThingsBoard receives data through an integration, and an uplink data converter turns it into telemetry. The converter is JavaScript too, but its input is the whole message from the network server (often base64-encoded) plus metadata. A typical pattern decodes the base64 payload into bytes, runs the same byte logic, and returns deviceName, deviceType and a telemetry object. If ChirpStack already applies the codec, ThingsBoard can simply read the decoded object from the ChirpStack message instead of decoding bytes again. We’ll cover the full ThingsBoard setup in a separate guide.
Cayenne LPP vs a Custom Payload
Cayenne LPP is a self-describing format that needs no custom decoder; custom payloads are smaller and carry exactly what the product needs.
| - | Cayenne LPP | Custom payload |
|---|---|---|
| How it works | Each value is prefixed with a channel and a type byte (e.g. 0x67 = temperature) | Fixed layout defined by the manufacturer |
| Decoder needed | No; platforms decode it natively | Yes, from the manufacturer |
| Payload size | Larger: 2 extra bytes per value | Smallest possible |
| Precision | Fixed per type (temperature ×10, one decimal) | Whatever the product needs (×100, timestamps, flags) |
| Best for | Prototypes, mixed hobby devices | Production sensors, battery-critical devices |
Cayenne LPP’s temperature type sends values ×10, so a reading of 23.4 °C arrives as 00EA after its channel and type bytes. That’s convenient for prototypes, but industrial sensors generally use custom formats for the extra precision and the ability to include battery, timestamps and alarm flags without overhead.
One warning from the field: a device must be set to the format its decoder expects. In one widely shared case, a sensor’s readings came out completely wrong, not just offset, because the device was sending Cayenne LPP while the platform applied the vendor’s custom decoder.
Troubleshooting: What the Wrong Values Tell You
| What you see | Likely cause | Fix |
|---|---|---|
| Temperature around 600 °C or more, only in cold rooms | Signed value decoded as unsigned | Use a signed 16-bit conversion |
| Values 10× or 100× too big or too small | Wrong scaling factor | Check the payload table: ×10 vs ×100 |
| Values look random, change wildly between packets | Wrong byte order, or fields read from the wrong offsets | Check endianness and the byte table; confirm firmware version |
| Values from a different sensor type (e.g. humidity in the temperature field) | Decoder for a different model or firmware | Load the decoder matching the exact model and firmware |
| Decoded object is empty | Decoder not applied, or on the wrong device profile | See the ChirpStack guide; check the profile the device uses |
| Readings are strings in quotes | Decoder returns strings (e.g. uses .toFixed()) | Return numbers |
| Some packets decode, others don’t | Several message types on different ports; decoder handles only one | Branch on fPort and length; add warnings for unhandled types |
| Dashboard shows a value but it’s stale | Decoder fine; sensor not reporting | Check join status and signal; see our RF testing guide |
Testing a Decoder Before You Deploy
Test the decoder with known bytes before trusting it with live data.
- Take sample packets from the manufacturer’s documentation, including one with a negative temperature if the sensor can go below zero.
- Use the platform’s test tool. The Things Stack’s payload formatter page has a test box where you paste bytes and a port and see the output. ChirpStack shows the decoded
objectin the device’s Events tab for each real uplink. - Check the edge cases: 0 °C, a negative reading, 100 %RH, and a low battery.
- Compare against the device. Macnman sensors show live readings in the Maya app over Bluetooth, so you can compare the dashboard value with what the device itself reports.
- Keep the decoder with the firmware version. When firmware changes the payload, the decoder must change with it. Record both in your device register.
Decoders Published for Every Macnman Sensor
Macnman designs and manufactures LoRaWAN sensors in India, and publishes a complete codec for each one, covering uplink decoding for every port and downlink encoding for configuration, in our LoRa Module SDK on GitHub. The codecs follow the decodeUplink convention, so they work in ChirpStack v4 and The Things Stack as published.
- Temperature and humidity: the MacSync LTS X1 (outdoor) and Narmada TSX1 (indoor)
- Probe temperatures: the LoRaWAN RTD sensor for freezers and process lines
- Existing instruments: the RS485 to LoRaWAN converter, with decoders for Modbus register data
- The rest of the range: LoRaWAN sensors and gateways
Stuck on a decoder? Send us the raw bytes, the port and the model, and a product engineer will check the decoding within one working day: contact us. To start a new deployment with the decoders already in place, order a pilot kit.
Frequently Asked Questions
What is a LoRaWAN payload decoder?
A payload decoder is a function, usually JavaScript, that runs on the network or application server and converts the raw bytes a LoRaWAN sensor sends into named, readable values such as temperature, humidity and battery. It reverses the scaling and packing the sensor applied to keep its messages small.
How do I write a LoRaWAN decoder for a temperature sensor?
Start from the manufacturer’s payload table. For each field, read the bytes at the stated offset in the stated byte order, convert signed fields with two’s complement, divide by the scaling factor, and return the result as a number in a JSON object. Branch on the FPort if the sensor sends different message types.
Why does my LoRaWAN temperature show 655 °C or a similar huge value?
The decoder is reading a signed 16-bit value as unsigned. A reading of −2.00 °C is sent as 0xFF38, which is 65,336 unsigned but −200 signed. Convert the field with a signed 16-bit function.
What is the difference between decodeUplink and Decode?decodeUplink(input) is the function used by ChirpStack v4 and The Things Stack; it receives input.bytes and input.fPort and returns { data, warnings, errors }. Decode(fPort, bytes) is the older ChirpStack v3 function that returns the decoded object directly. Many vendor decoder files include both.
Can one decoder work on ChirpStack and The Things Network?
Yes. Both call decodeUplink(input) with the same input fields, so a decoder written for one usually runs unchanged on the other. ThingsBoard uses a different converter wrapper around the same byte logic.
Should I use Cayenne LPP or a custom payload?
Cayenne LPP needs no custom decoder and suits prototypes, but adds two bytes per value and fixes precision (temperature to one decimal). Custom payloads are smaller and carry exactly what the product needs, which is why most industrial sensors use them and ship a decoder.
Where do I get the decoder for my sensor?
From the manufacturer. Reputable vendors publish decoders on GitHub or in their documentation, and some are included in the LoRaWAN device repository that ChirpStack and The Things Stack can import. Macnman publishes a full codec for every sensor in its GitHub SDK.
Does the decoder run on the gateway?
No. The gateway only forwards encrypted bytes. Decoding happens on the network or application server after decryption, which is why the decoder is configured in ChirpStack, The Things Stack or ThingsBoard rather than on the gateway.
What happens if the firmware changes the payload format?
The decoder must be updated to match. Keep decoders versioned alongside firmware, and check the manufacturer’s SDK for the decoder matching the firmware on your devices.
Can a decoder handle alarms and other message types?
Yes. Sensors often use different FPorts for different messages, such as periodic readings, alarm events and boot information. A complete decoder branches on the port and payload length, decodes each type, and returns a warning for anything unexpected.
