How to Encode a Satellite IoT Payload When Every Byte Counts

Packing sensor data into tiny satellite messages where every byte costs money

Your existing telemetry message is 240 bytes of tidy JSON. The satellite link gives you 13. That’s not a compression problem, it’s a redesign problem, and gzip won’t save you (entropy coders need volume to work, and 13 bytes is noise).

Here’s the playbook. It works for Hubble, Swarm, Astrocast, Myriota, Iridium SBD, anything with a tight MAC-layer ceiling. The numbers change, the technique doesn’t.

The 13-Byte Reality

Physics caps the link. Transmit power, antenna gain, free-space path loss, and receiver sensitivity together decide how many bits a low-power device can push uplink in a single pass. The result, across providers:

  • Hubble Network: ~13 bytes of arbitrary payload (BLE-from-space, the tightest mainstream example)
  • Myriota: ~20 bytes per uplink
  • Astrocast: ~160 bytes
  • Swarm: 192 bytes (with strict duty cycles)
  • Iridium SBD: 340 bytes (and cost-per-byte still rewards compactness)

JSON is hopeless. Protobuf’s varints and field tags eat 2-3 bytes per field before you’ve encoded anything. CBOR is better but still carries type tags you can’t afford. You’re going to define a fixed binary schema and bit-pack it yourself. It’s less work than it sounds.

Step 1: Define Your Byte Budget

First, confirm the real ceiling. Networks add framing, headers, and sometimes encryption overhead beneath your “payload.” Read the spec. For Hubble, the advertising packet structure tells you exactly how many bytes you actually own.

Convert to bits and reserve overhead first, before you think about a single sensor reading:

+------------------- 104 bits total --------------------+
| ver | type | seq |        payload (89 bits)           |
|  4  |  3   |  8  |                                    |
+-------------------------------------------------------+
  • Schema version (4 bits): 16 versions. Non-negotiable. Firmware will evolve.
  • Message type (3 bits): 8 layouts. Position fix, heartbeat, alert, config-ack, etc.
  • Sequence (8 bits): lets the backend detect drops and reorder. Optional but cheap.

You’ve burned 15 bits on overhead. Plan the remaining 89 carefully.

Step 2: Inventory and Rank Your Fields

Every field your firmware currently logs is over-specified. A 32-bit float for battery voltage is 26 bits of waste. Build a table:

Field        | Native | Range needed | Precision | Bits
-------------|--------|--------------|-----------|-----
Latitude     | f32    | -90..+90     | ~1 m      |  24
Longitude    | f32    | -180..+180   | ~1 m      |  25
Battery (V)  | f32    | 2.5..4.3     | 0.05 V    |   6
Temp (C)     | f32    | -40..+85     | 1 C       |   7
Status flags | u32    | 8 booleans   | exact     |   8
                                                  ----
                                                   70

70 bits of payload. Fits in 89 with 19 bits to spare for whatever you forgot. Ask what precision the use case needs, not what the sensor outputs. A pipeline pressure monitor doesn’t need 0.001 PSI resolution. An asset tracker doesn’t need GPS to a centimeter.

Step 3: Quantize Aggressively

This is where you recover the most space, faster than any algorithm can. Map a real-world range onto an integer:

q = (value - min) * (2^bits - 1) / (max - min)

Decode is the inverse. A few worked examples:

Battery voltage, 2.5 V to 4.3 V at 0.05 V steps. That’s (4.3 - 2.5) / 0.05 = 36 distinct values. ceil(log2(36)) = 6 bits. You just turned a 32-bit float into 6 bits with zero useful precision lost.

GPS latitude. Using 24 bits, you get a resolution of roughly 1.07 m at the equator (90° / 2^23). Drop to 21 bits and you get ~17 m, plenty for tracking a shipping container or a herd. Longitude needs one extra bit for the wider range.

Temperature, -40 to +85 °C at 1 °C: 125 steps, 7 bits. Or store it as int8_t and skip the math.

Timestamps: don’t send them. Let the gateway timestamp on receipt. If you genuinely need on-device time (the device buffered the reading for hours), send a delta in seconds or minutes from “now,” not absolute UTC. A 12-bit minute delta covers nearly 3 days.

Always clamp inputs to your declared range before quantizing. A battery reading of 4.5 V from a glitchy ADC will wrap around and look like 2.5 V on the other end. Clamp.

Step 4: Bit-Pack the Payload

A tiny bit writer, no dependencies, alignment-free, MSB-first:

typedef struct {
    uint8_t  buf[16];
    uint16_t bit_pos;
} bitbuf_t;

void bb_write(bitbuf_t *b, uint32_t val, uint8_t nbits) {
    while (nbits--) {
        uint8_t bit = (val >> nbits) & 1u;
        b->buf[b->bit_pos >> 3] |=
            (uint8_t)(bit << (7 - (b->bit_pos & 7)));
        b->bit_pos++;
    }
}

Encoding a position-fix message:

bitbuf_t b = {0};
bb_write(&b, SCHEMA_VER, 4);   // version
bb_write(&b, MSG_GPS,    3);   // type
bb_write(&b, seq++,      8);   // sequence
bb_write(&b, lat_q,     24);   // latitude
bb_write(&b, lon_q,     25);   // longitude
bb_write(&b, batt_q,     6);   // battery
bb_write(&b, temp_q,     7);   // temperature
bb_write(&b, flags,      8);   // status bits

// Total: 85 bits = 11 bytes. Fits in 13.

Two rules you can’t skip. First, pick an endianness and document it; MSB-first (network order) is the convention, and whatever you pick, your decoder must match. Second, unit-test the encoder against the decoder. Write a Python decoder for your backend and a C encoder for the device, then round-trip 10,000 randomized samples through both. Off-by-one bit errors are silent and catastrophic, especially when they shift every subsequent field.

If you’re targeting Hubble specifically, the device SDK reference applications show the surrounding glue (advertising, scheduling, encryption) so you can focus on payload design.

Step 5: Handle Multiple Message Types

Not every uplink is a GPS fix. A heartbeat doesn’t need 49 bits of coordinates. An alert wants room for an event code and context. Use the type field to switch layouts:

switch (msg_type) {
case MSG_GPS:       encode_gps_fix(&b, &state);   break;
case MSG_HEARTBEAT: encode_heartbeat(&b, &state); break;
case MSG_ALERT:     encode_alert(&b, &state);     break;
case MSG_CONFIG_ACK:encode_config_ack(&b, &state);break;
}

Keep the schema in one source-of-truth file (YAML works well) and code-generate both the firmware encoder and the backend decoder from it. Hand-written, drift-prone parallel implementations are how silent corruption ships to production.

Step 6: Plan for Schema Evolution

Your message format will change. Plan for it on day one.

  • Never reuse a version number. Increment on any breaking change.
  • Add new fields only at the end of an existing layout, or define a new message type.
  • Keep schemas/v1.yaml, v2.yaml, etc. in Git, forever.
  • The backend must decode every historical version indefinitely. Devices in the field on firmware from 3 years ago are still sending v2 packets.

Treat deployed schemas like deployed APIs. They’re a contract.

Five Ways to Blow Your Budget

  • Don’t ship JSON, CBOR, or Protobuf. Field tags and type bytes alone blow your budget.
  • Don’t run gzip or brotli on 13 bytes. Compression ratios go negative on payloads this small.
  • Don’t send absolute UTC timestamps if the gateway can stamp on receipt.
  • Always clamp inputs to your declared range before quantizing.
  • Keep schemas in a registry the backend reads, not buried in firmware comments.

Pre-Flight Checklist

Before you flash production firmware:

  • Confirmed the network’s true payload ceiling (after framing/headers)
  • Reserved bits for version, message type, and sequence
  • Inventoried every field with native size, required range, and required precision
  • Quantized each field to its minimum useful resolution
  • Clamped all inputs to declared ranges before encoding
  • Bit-packed encoder round-trip tested against the backend decoder
  • Schema committed to a versioned registry, not just code comments
  • Backend decoder can handle every historical schema version
  • Endianness documented and agreed between firmware and backend

Get those nine checked and you’ll fit a useful telemetry stream into 13 bytes.


Hubble Network gives BLE-class devices direct-to-satellite connectivity, so your bit-packed payloads reach the backend without gateways or cellular fallback. See how it works →