Keep every LoRaWAN uplink from The Things Network in your own database
A guide for the The Things Network community. Every command and output below comes from a run against the service on 2026-10-04.
Your application on The Things Network already decodes every uplink with its payload formatter, and The Things Stack can POST each one to a URL of your choice: a webhook. Point a custom webhook at LakehouseBox and every decoded reading of every device in the application becomes a row in a table you own: device, time, metric name and value, plus the signal strength it arrived with. You query months or years of it with SQL (DuckDB, Python, Spark, anything that reads Apache Iceberg), export it, or share it. There is nothing to run yourself: no server, no Node-RED, no database to look after.
What you need
An application on The Things Network / The Things Stack whose devices have a payload formatter (the device repository's, or your own): the webhook carries the formatter's output as
decoded_payload, and that is what becomes rows. You need the rights to add a webhook to the application.A LakehouseBox account (sign up, free) and the CLI, one Python 3 file:
curl -fsSL https://lakehousebox.com/install.sh | sh lhbox login # approve once in the browser; the key is saved, never shownFor queries on your machine, DuckDB 1.5.5 or newer (the run used 1.5.6).
lhbox queryworks without it.
What the run on this page used. The Things Stack 3.36.2 (the open-source thethingsnetwork/lorawan-stack image, the same software as The Things Network's clusters), with an application garden-sensors of three devices, a custom webhook created exactly as below, and its Simulate uplink to send three uplinks carrying the decoded payloads of the device repository's examples for a Dragino LHT65, an Elsys ERS CO2 and an Elsys ERS. The stack posted them to LakehouseBox itself; every output below is from that run on 2026-10-04.
1. A catalog and a table
A catalog holds your tables. The table has the six columns the ttn preset fills: one row per reading.
lhbox catalog create lorawan
lhbox table create lorawan.ttn.uplinks \
--column event_time:timestamptz:required --column device_id:string:required \
--column dev_eui:string --column application_id:string \
--column metric_name:string:required --column metric_value:double
2. A sink with a device route and the ttn preset
A sink receives posts and commits them to the table every 5 minutes. --device gives it a URL that needs no header (the URL is the credential); --preset ttn turns each uplink into tidy rows.
lhbox sink create --catalog lorawan --table ttn.uplinks --name ttn_in \
--device --preset ttn --save ~/ttn-sink.json
The run's output:
Sink ttn_in created: table ttn.uplinks of catalog lorawan, every 300 s (or 64 MiB, or 1000 batches).
...
device route: POST http://in.lakehousebox.com/d/********
no header needed; form, JSON, NDJSON or CSV, up to 256 KiB
the same path also answers over HTTPS on https://in.lakehousebox.com
device token: saved to ttn-sink.json with the URL (not shown; the API cannot show it again)
mapping: ttn (2 rules), applied at each roll; try it: lhbox sink test ttn_in <file>
~/ttn-sink.json (mode 0600) holds the device URL. Lost it? lhbox sink device-token ttn_in --save ~/ttn-sink.json makes a new one, and the old one stops working at once. Use a new file name for each sink: --save will not overwrite a file that holds another sink.
3. Add a custom webhook
Print the HTTPS URL once (it is a secret: anyone who has it can post into this sink):
jq -r .device_url ~/ttn-sink.json # https://in.lakehousebox.com/d/<token>
In the Console: your application → Integrations → Webhooks → + Add webhook → Custom webhook.
| Field | Value |
|---|---|
| Webhook ID | anything, e.g. lakehousebox |
| Webhook format | JSON |
| Base URL | the URL above, https://in.lakehousebox.com/d/<token> |
| Downlink API key, Request authentication, additional headers | leave empty: the URL is the credential |
| Enabled event types | Uplink message ticked, its path empty (uplinks then go to the base URL itself) |
Leave the other event types unticked: a join accept or a downlink event has no decoded payload and would only use up the sink's rate. Save with Add webhook. The next uplink of any device in the application is posted.
What The Things Stack sent in the run: POST with Content-Type: application/json, User-Agent: TheThingsStack/3.36.2, X-Tts-Domain, and the uplink as one JSON object:
{"end_device_ids":{"device_id":"lht65-greenhouse","application_ids":{"application_id":"garden-sensors"},
"dev_eui":"A84041000181C5E2","join_eui":"A840410000000101"},
"correlation_ids":["as:up:01M420HRC33X90JYJ389TBC83V","rpc:/ttn.lorawan.v3.AppAs/SimulateUplink:01M420HRBQ5J8K4WNSH5AFSXGY"],
"received_at":"2026-10-03T23:10:53.314860381Z",
"uplink_message":{"f_port":2,"f_cnt":1412,"frm_payload":"y/YLDQN2AQrdf/8=",
"decoded_payload":{"BatV":3.062,"Bat_status":3,"Ext_sensor":"Temperature Sensor","Hum_SHT":88.6,"TempC_DS":27.81,"TempC_SHT":28.29},
"rx_metadata":[{"gateway_ids":{"gateway_id":"garden-gw-01","eui":"B827EBFFFE6C1A2B"},"rssi":-71,"channel_rssi":-71,"snr":9.5, …},
{"gateway_ids":{"gateway_id":"town-hall-gw","eui":"0016C001FF1E3A4F"},"rssi":-112,"snr":-6.25, …}],
"settings":{…}},
"simulated":true}
Optional: send less. Under Filter event data you can keep only what the preset reads. With these four paths the LHT65 uplink above went from 1,193 to 847 bytes, and an Elsys uplink sent through the filtered webhook gave the same seven rows as without it:
end_device_ids
received_at
up.uplink_message.decoded_payload
up.uplink_message.rx_metadata
Keep end_device_ids and received_at as written, without up.: with up.end_device_ids the stack sent no device ids at all, and every row would be rejected for its missing device_id.
4. Check that it arrives
Copy one uplink from the application's Live data (or save the body above to uplink.json) and dry-run it. Nothing is stored:
$ lhbox sink test ttn_in uplink.json
Dry run on sink ttn_in (application/json): 1 record, nothing stored.
mapping: ttn v1 -> 7 rows
device_id dev_eui application_id event_time metric_name metric_value
---------------- ---------------- -------------- ------------------------ ----------- ------------
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z BatV 3.062
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z Bat_status 3.0
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z Hum_SHT 88.6
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z TempC_DS 27.81
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z TempC_SHT 28.29
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z rx_rssi -71.0
lht65-greenhouse A84041000181C5E2 garden-sensors 2026-10-03T23:10:53.314Z rx_snr 9.5
After the uplinks have arrived, lhbox sink get shows what is waiting and, after the roll, what was committed. The run's four uplinks (three without a filter, one with it):
$ lhbox sink get ttn_in
received {"batches_total": 4, "bytes_total": 3743, "rows_total": 4, "rows_rejected_total": 0}
last_roll {"at": "2026-10-04T00:44:08.887Z", "batches": 4, "rows": 27, "rejected_rows": 0, ...}
device {"enabled": true, ..., "refused": {}}
What the rows look like
One row per number in decoded_payload, long format: event_time (the uplink's received_at, when the network received it), device_id, dev_eui, application_id, metric_name (the formatter's own field name, exactly as it spells it: TempC_SHT, co2, BatV), metric_value. Two more rows per uplink carry the radio: rx_rssi and rx_snr of the first gateway listed in rx_metadata.
- Text and true/false values of the payload are not stored (the LHT65's
"Ext_sensor": "Temperature Sensor"gave no row). Nested objects in a formatter's output are kept, with dotted names ({"air": {"temperature": 21}}becomesair.temperature). - An uplink with no
decoded_payload(no formatter, or one that failed) gives only the two radio rows. - Long format means a new device type, or a formatter that adds a field, never changes the table.
- Simulated uplinks are stored like real ones. The Console's Simulate uplink goes through the webhook too.
Query it
lhbox duckdb --catalog lorawan opens a DuckDB shell with the catalog attached as lorawan. The run's queries and their output (times are shown in the shell's time zone, here UTC+2):
-- the latest value of every metric, per device
SELECT device_id, metric_name, arg_max(metric_value, event_time) AS value, max(event_time) AS at
FROM lorawan.ttn.uplinks
GROUP BY device_id, metric_name ORDER BY device_id, metric_name;
┌──────────────────┬─────────────┬────────┬────────────────────────────┐
│ device_id │ metric_name │ value │ at │
├──────────────────┼─────────────┼────────┼────────────────────────────┤
│ ers-co2-office │ co2 │ 776.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ humidity │ 41.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ light │ 39.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ motion │ 6.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ rx_rssi │ -98.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ rx_snr │ 4.0 │ 2026-10-04 02:39:18.643+02 │
│ ers-co2-office │ temperature │ 22.6 │ 2026-10-04 02:39:18.643+02 │
│ ers-hallway │ humidity │ 41.0 │ 2026-10-04 02:39:08.819+02 │
│ ... │ │ │ │
│ lht65-greenhouse │ TempC_SHT │ 28.29 │ 2026-10-04 02:39:08.669+02 │
│ lht65-greenhouse │ rx_rssi │ -71.0 │ 2026-10-04 02:39:08.669+02 │
│ lht65-greenhouse │ rx_snr │ 9.5 │ 2026-10-04 02:39:08.669+02 │
└──────────────────┴─────────────┴────────┴────────────────────────────┘
20 rows
-- one row per uplink (wide), e.g. for a spreadsheet
PIVOT (SELECT event_time, device_id, metric_name, metric_value FROM lorawan.ttn.uplinks WHERE device_id LIKE 'ers%')
ON metric_name IN ('temperature', 'humidity', 'co2', 'rx_rssi') USING first(metric_value)
ORDER BY event_time;
┌────────────────────────────┬────────────────┬─────────────┬──────────┬────────┬─────────┐
│ event_time │ device_id │ temperature │ humidity │ co2 │ rx_rssi │
├────────────────────────────┼────────────────┼─────────────┼──────────┼────────┼─────────┤
│ 2026-10-04 02:39:08.744+02 │ ers-co2-office │ 22.6 │ 41.0 │ 776.0 │ -98.0 │
│ 2026-10-04 02:39:08.819+02 │ ers-hallway │ 22.6 │ 41.0 │ NULL │ -104.0 │
│ 2026-10-04 02:39:18.643+02 │ ers-co2-office │ 22.6 │ 41.0 │ 776.0 │ -98.0 │
└────────────────────────────┴────────────────┴─────────────┴──────────┴────────┴─────────┘
-- coverage: the weakest signal each device reached its first gateway with
SELECT device_id, min(metric_value) FILTER (WHERE metric_name = 'rx_rssi') AS worst_rssi,
min(metric_value) FILTER (WHERE metric_name = 'rx_snr') AS worst_snr, count(DISTINCT event_time) AS uplinks
FROM lorawan.ttn.uplinks GROUP BY device_id ORDER BY device_id;
┌──────────────────┬────────────┬───────────┬─────────┐
│ device_id │ worst_rssi │ worst_snr │ uplinks │
├──────────────────┼────────────┼───────────┼─────────┤
│ ers-co2-office │ -98.0 │ 4.0 │ 2 │
│ ers-hallway │ -104.0 │ 1.75 │ 1 │
│ lht65-greenhouse │ -71.0 │ 9.5 │ 1 │
└──────────────────┴────────────┴───────────┴─────────┘
No DuckDB on this machine? The same SQL runs on the service, up to 2,000 rows:
$ lhbox query "SELECT device_id, count(*) AS rows, count(DISTINCT metric_name) AS metrics FROM lorawan.ttn.uplinks GROUP BY device_id ORDER BY device_id"
device_id rows metrics
---------------- ---- -------
ers-co2-office 14 7
ers-hallway 6 6
lht65-greenhouse 7 7
3 rows · 150 ms
The table is Apache Iceberg with Parquet files, so pandas, Polars, Spark and the rest read it too: engines.
Limits and gotchas
- Rate: 6 posts a minute per sink sustained, bursts of 30, and one webhook post is one uplink. That is 360 uplinks an hour for the whole application: 30 devices every 5 minutes, 60 every 10, 120 every 20. Measured in the run: 40 uplinks simulated back to back, 30 accepted, the next 10 answered
429 rate_limitedwithRetry-After: 5. - A refused uplink is lost. The Things Stack tries a webhook once per uplink; it logs the failure (an
as.webhook.failevent in Live data, with our answer's text) and moves on. Its retry queue is a The Things Stack Cloud Plus feature, per its documentation; the run's stack did not retry any of the 10.lhbox sink get ttn_incounts refusals underdevice.refused.rate_limited: if that number moves, you are over the rate. - More devices than that: split them into several applications, each with its own webhook, sink and table (a sink feeds exactly one table; the free plan has 10 device sinks), and query them together with
UNION ALL. Or run your own relay that collects uplinks and posts them in batches to the sink's send route (a JSON array of up to 16 MiB per request, with the send key in~/ttn-sink.json; see ingest). - The URL is the credential. Everyone who can see the application's webhooks in the Console can read it, and the run's stack wrote it in its own log when a post failed. Anyone holding it can post into this one sink, nowhere else. If it leaks:
lhbox sink device-token ttn_in --save ~/ttn-sink.json, then paste the new URL into the webhook. - The send route does not fit a webhook. It wants a JSON array and a new
Idempotency-Keyper request; a webhook sends one object and fixed headers (the run's try:400 not_an_array). Use the device route as above. - Duplicates. A post repeated with the same bytes within 5 minutes is stored once. Two uplinks never have the same bytes (
received_atandcorrelation_idsdiffer), so every uplink is a row set of its own. - Commit delay. Rows appear at the sink's roll, every 5 minutes by default (
--roll-seconds, at least 60). - Your own columns. The preset is a mapping document;
lhbox sink mapping ttn_inshows it, and a mapping of your own can keep more (the frame counter, every gateway, the formatter's text fields as a wide table): ingest. - No history. The webhook delivers uplinks from now on; this guide does not cover loading the Storage Integration's stored messages.
Next
- Ingest: sinks, the device route and the mapping format, if you want your own columns.
- DuckDB: connecting, writing, persistent secrets.
- Public catalogs: to share a catalog's tables with anyone, read-only.