LoRaWAN Fundamentals · Cold Chain & Storage

LoRaWAN Payload Decoder for Temperature Sensors: Explained

LoRaWAN
LoRaWAN Payload Decoder for Temperature Sensors: Explained

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:

  1. Scaling: the sensor sent 2435, meaning 24.35 °C ×100.
  2. Signed values: negative temperatures are sent in two’s complement, so 0xFF38 means −2.00 °C, not 65,336.
  3. Byte order: most sensors send the high byte first (big-endian), but not all.
  4. 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.

PlatformWhere the decoder livesFunction it calls
ChirpStack v4Device profile → Codec tabdecodeUplink(input)
ChirpStack v3Device profile (Application Server)Decode(fPort, bytes)
The Things Stack (TTN)Application or device → Payload formatters → UplinkdecodeUplink(input)
ThingsBoardIntegration → Uplink data converterDecoder(payload, metadata) style converter
Platforms using the LoRaWAN device repositoryImported with the device profiledecodeUplink(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:

ByteFieldTypeScalingNotes
0Status flaguint8—0 = reading OK, 1 = sensor read error
1–2Temperatureint16, big-endian×100Signed: supports negative temperatures
3–4Humidityint16, big-endian×100%RH
5Batteryuint8—Percentage
6–9Timestampuint32, big-endian—UTC seconds since 1970

A real packet looks like this:

00 09 83 16 C4 61 68 F3 2A 10

Decoded 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 warnings and errors arrays, 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 LPPCustom payload
How it worksEach value is prefixed with a channel and a type byte (e.g. 0x67 = temperature)Fixed layout defined by the manufacturer
Decoder neededNo; platforms decode it nativelyYes, from the manufacturer
Payload sizeLarger: 2 extra bytes per valueSmallest possible
PrecisionFixed per type (temperature ×10, one decimal)Whatever the product needs (×100, timestamps, flags)
Best forPrototypes, mixed hobby devicesProduction 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 seeLikely causeFix
Temperature around 600 °C or more, only in cold roomsSigned value decoded as unsignedUse a signed 16-bit conversion
Values 10× or 100× too big or too smallWrong scaling factorCheck the payload table: ×10 vs ×100
Values look random, change wildly between packetsWrong byte order, or fields read from the wrong offsetsCheck 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 firmwareLoad the decoder matching the exact model and firmware
Decoded object is emptyDecoder not applied, or on the wrong device profileSee the ChirpStack guide; check the profile the device uses
Readings are strings in quotesDecoder returns strings (e.g. uses .toFixed())Return numbers
Some packets decode, others don’tSeveral message types on different ports; decoder handles only oneBranch on fPort and length; add warnings for unhandled types
Dashboard shows a value but it’s staleDecoder fine; sensor not reportingCheck 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.

  1. Take sample packets from the manufacturer’s documentation, including one with a negative temperature if the sensor can go below zero.
  2. 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 object in the device’s Events tab for each real uplink.
  3. Check the edge cases: 0 °C, a negative reading, 100 %RH, and a low battery.
  4. 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.
  5. 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.

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.

On this page

WhatsAppLinkedInEmail
products image macnman
Got an IoT idea?

Let’s bring it to life!

Let‘s Make it Easy

Reach Out Now

Icon

Contact to Sales

Talk to our friendly team

chat@macnman.com
Icon

Call Us

24 X 7 Always On

+91 7972856163
Icon

Contact to Support

We are here to help

support@macnman.com
Icon

Visit Us

Visit our office HQ

View on Google Map

Socials

LoRaWAN

4G IoT

WiFi Ble IoT

Nb IoT

Customize

Maya

Documentation

Case Studies

Blogs