Crossling
On this page

Developers · Weather station push

Weather station push: send your logger's readings

Send readings from your own weather station to Crossling with its token: the JSON and CSV it takes, units, time zones, limits, and a script to run from cron.

Updated

A program can add its own weather station at a research station in Crossling. The logger, or a small script beside it, sends readings to one address with the station's token. Crossling turns them into local days for the trial sites there, ahead of NASA POWER's, and Today shows what it measured in the last two hours. Only the program's members see the readings.

POST https://crossling.app/api/v1/weather/stations/push/

Rather not write code? Upload a file on the station's page instead. It reads the same formats.

Get a token#

  1. In Crossling, open Map and pick the research station.
  2. Under Weather stations, tap Open (or Weather station if there isn't one yet), and add the station: its name and time zone. A program's admins and breeders can add one.
  3. Copy the token it shows, cwx_…. Crossling keeps only a hash, so it's shown this once.

The token can add readings to that one station, and do nothing else. If it's lost or leaks, open the station, tap Send readings automatically, and make a new one: the old one stops working at once. Revoke the token there leaves the station with none.

Send a reading#

Put your token in an environment variable first, so it stays out of your scripts:

Shell
export STATION_TOKEN='cwx_EXAMPLE_00000000000000000000000000000000000'

Then send a reading:

curl
curl -X POST https://crossling.app/api/v1/weather/stations/push/ \
  -H "Authorization: Bearer $STATION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"readings": [{"time": "2026-07-01T14:00:00-06:00",
                     "temp": 24.6, "rh": 41, "rain": 0, "wind": 14, "gust": 27, "dir": 290}]}'
Python
import json
import os
import urllib.request

URL = "https://crossling.app/api/v1/weather/stations/push/"
readings = [{"time": "2026-07-01T14:00:00-06:00", "temp": 24.6, "rh": 41,
             "rain": 0, "wind": 14, "gust": 27, "dir": 290}]

req = urllib.request.Request(URL, data=json.dumps({"readings": readings}).encode(), method="POST", headers={
    "Authorization": f"Bearer {os.environ['STATION_TOKEN']}",
    "Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=60) as r:
    print(json.load(r))
Response
{
  "ok": true,
  "station": "Lacombe North",
  "saved": 1,
  "new": 1,
  "replaced": 0,
  "skipped": 0,
  "problems": [],
  "last_reading": "2026-07-01T20:00:00Z"
}

The same time sent again replaces the reading that was there, so a script can safely resend its last hour or day each time it runs.

Readings in JSON#

Send {"readings": [...]}, or just the list. Every value but the time is optional: send what the logger measures.

FieldWhat it is
timeWhen the reading was taken: ISO 8601 (2026-07-01T14:00:00-06:00, 2026-07-01T20:00:00Z), or seconds since 1970. dateTime works too.
tempAir temperature, °C
rhRelative humidity, %
rainRain since the reading before, mm
rain_totalOr a running rain total that resets, mm (Rain totals)
windWind speed, km/h
gustHighest gust, km/h
dirDegrees the wind comes from, 0 to 360

A number can be sent as a number or as text ("24.6"). null, an empty value, NaN or a logger's -999 or lower all mean "no value".

Other units#

Add units to send °F, inches, m/s, mph or knots:

JSON
{
  "units": {"temp": "F", "rain": "in", "wind": "mph"},
  "readings": [{"time": "2026-07-01T14:00:00-06:00", "temp": 76.3, "rain": 0.02, "wind": 9}]
}
KeyChoicesDefault
tempC, FC
rainmm, inmm
windkmh, ms, mph, knkmh

Crossling keeps everything in °C, mm and km/h.

What's left out#

A value outside what's possible is left out, and the rest of the reading is kept:

ValueKept between
temp-60 and 60 °C
rh0 and 100 % (up to 105 is read as 100)
rain0 and 300 mm
rain_total0 and 20,000 mm
wind0 and 300 km/h
gust0 and 400 km/h
dir0 and 360 (360 is read as 0)

A reading is skipped when its time can't be read, when it's more than 12 hours in the future, or when it has no values at all. Two readings with the same time: the later one is kept. Each of these is counted in the answer's skipped or listed in problems.

Times and time zones#

  • A time with a zone (Z, +00:00, -06:00) is read as it says.
  • A time without one (2026-07-01 14:00) is read in the station's time zone, set when the station was added and changed under its Settings.
  • A number is seconds since 1970, which is always UTC. Milliseconds work too.

Many loggers keep standard time all year. If yours does, send its times with their offset, so daylight saving doesn't shift them by an hour.

Readings are kept in UTC. Crossling builds each local day in the station's time zone: high, low, mean and rain. A reading stamped exactly at midnight closes the day before, as loggers stamp the end of each interval. A day counts, and stands in for NASA POWER's that day, when its temperatures cover at least 20 of its hours.

CSV and logger files#

Send CSV with a header row instead of JSON, with Content-Type: text/csv:

readings.csv
time,temp,rh,rain,wind,gust,dir
2026-07-01 14:00,24.6,41,0,14,27,290
2026-07-01 14:10,24.9,40,0,16,29,285
curl
curl -X POST https://crossling.app/api/v1/weather/stations/push/ \
  -H "Authorization: Bearer $STATION_TOKEN" \
  -H "Content-Type: text/csv" \
  --data-binary @readings.csv

The push reads files the same way as the upload on the station's page:

  • Logger exports: Campbell Scientific TOA5 (LoggerNet's .dat), Onset HOBO, Davis WeatherLink (the desktop export and weatherlink.com's), and Ecowitt.
  • Plain CSV: columns named like time, temp, rh, rain, wind, gust, dir, or Temperature (°F), Wind speed (m/s) and the like. The first row names the columns.
  • Days: date, tmax, tmin, rain (and tmean), one row per day.

Units come from the header where it says them (°F, m/s, in). Otherwise temperatures over 50 are read as °F, and then rain as inches and wind as mph. Otherwise it's °C, mm and km/h. Times are in the station's time zone unless the header says otherwise (HOBO's GMT-06:00, a UTC column) or each time carries its own zone. Write dates as 2026-07-01: a date like 07/01/26 has to be guessed.

Use --data-binary, not -d, to send a file: -d drops the line breaks.

Rain totals#

Rain comes two ways:

  • rain: the amount since the reading before.
  • rain_total: a running total, like a daily rain counter or a tipping bucket's count in mm.

Crossling works out each reading's rain from the totals, across pushes too. A total that drops means the counter was reset, so the rain since then is the new total itself. After a gap of more than 6 hours, the rain in between is unknown, rather than all put on one reading.

In a CSV, a rain column named like Daily Rain or Rain total, or one that climbs and only drops when it resets, is read as a running total.

Days instead of readings#

If all you have is a day's high, low and rain, send days:

JSON
{"days": [{"date": "2026-07-01", "tmax": 27.4, "tmin": 11.2, "rain": 3.6}]}

tmean is optional. A day with both a high and a low counts for that date, ahead of NASA POWER's. A day sent again replaces it. Send readings or days in one push, not both: with both, only the readings are kept.

Answers#

StatusBodyWhen
200{"ok": true, …}Saved. saved is how many readings (or days), new and replaced split them, and skipped and problems say what was left out.
400{"ok": false, …}Rows came, but none could be saved: problems says why
400{"error": "…"}A body that can't be read (empty, not JSON or CSV, over 10,000 readings), a token in the address, or a station that's full
401{"error": "…"}No token, or one that's been replaced or revoked
413Over 1 MB. Send smaller batches.
429{"detail": "…"}Too many pushes. Wait the seconds in Retry-After.

problems holds up to 10 lines like 1 humidity values left out: outside what's possible (line 2: 140). last_reading is the newest reading the station has, in UTC.

The station's page shows its last push, with the same problems, so you can see why readings aren't arriving without opening the logger.

Limits#

WhatLimit
One push1 MB and 10,000 readings
Pushes360 an hour per token, and 1,200 an hour per address
One station1,500,000 readings (about 14 years at 5 minutes)
The demo's station50,000 readings, until the nightly reset

Pushing every minute or every few minutes is well inside these. A station that's full says so, and nothing is added.

Run it from cron#

This script sends the newest rows of a file your logger software keeps writing (a CSV, or a Campbell TOA5 .dat). It uses only Python's standard library, so it runs on a Raspberry Pi or the PC beside the logger as it is.

push_readings.py
#!/usr/bin/env python3
"""Send the newest rows of a logger's file to a Crossling weather station.

    STATION_TOKEN=cwx_... python3 push_readings.py /path/to/table.dat
"""
import os
import sys
import urllib.error
import urllib.request

URL = "https://crossling.app/api/v1/weather/stations/push/"
ROWS = 300  # the newest rows; ones sent before just replace themselves

with open(sys.argv[1], "rb") as f:
    lines = f.read().splitlines()
# keep the header (a Campbell TOA5 file has four rows of it), then the newest rows
header = 4 if lines and lines[0].lstrip(b'"').startswith(b"TOA5") else 1
body = b"\n".join(lines[:header] + lines[header:][-ROWS:]) + b"\n"

req = urllib.request.Request(URL, data=body, method="POST", headers={
    "Authorization": f"Bearer {os.environ['STATION_TOKEN']}",
    "Content-Type": "text/csv",
})
try:
    with urllib.request.urlopen(req, timeout=60) as r:
        print(r.read().decode())
except urllib.error.HTTPError as e:
    print(e.code, e.read().decode(), file=sys.stderr)
    sys.exit(1)

Then run it every 10 minutes with cron (crontab -e):

crontab
*/10 * * * * STATION_TOKEN=cwx_EXAMPLE_00000000000000000000000000000000000 /usr/bin/python3 /home/pi/push_readings.py /home/pi/weather/readings.csv >> /home/pi/push.log 2>&1

At 10-minute readings, 300 rows is about two days, so a push that fails while the internet is down is made up by the next one that gets through.

From LoggerNet#

LoggerNet writes each table to a TOA5 .dat file on the PC. Have LoggerNet's Task Master run the script after each scheduled collection, with the table's file:

python push_readings.py C:\Campbellsci\LoggerNet\CR1000X_Table10.dat

Set STATION_TOKEN for the account Task Master runs as. A CR1000X or CR6 with its own internet connection can post to the same address with HTTPPost() instead.

Other setups#

  • WeeWX: run a script like the one above from cron, reading the newest archive records (outTemp, outHumidity, rain, windSpeed, windGust, windDir). With US units, add "units".
  • Home Assistant: a rest_command with method POST, this address, the Authorization header and a payload built from your sensors, called by an automation every 5 or 10 minutes.
  • Node-RED: an http request node, method POST, this address, the Authorization header, and msg.payload set to the readings.

No code: upload a file#

On the station's page, Upload a file takes the same formats as the push, up to 8 MB and 200,000 rows at a time. It shows how it's reading the file first: which column is which, the units, the time zone, a few rows as they'll go in, and the days they make. Change anything that's wrong, then import. A program's admins, breeders and technicians can upload.

It's the easy way to bring in last season's records, or a logger that's never online.