Assistant & API

API documentation

Read sensor data without a token, upload readings with one, and the OGC SensorThings and WaterML2 endpoints.

The Geronimo IoT API provides real-time and historical environmental sensor data. Query data by sensor, time range, and variables — and build on top of our open-source monitoring platform. A public sensor is readable with no credential; a private one — which is what a new sensor is — needs its read token or a signed-in session. Uploading always needs the sensor's own credential.

GET Endpoint

GET /api/creek-data/

Reading Data

Whether a GET needs credentials depends on the sensor. A new sensor is private until someone with Settings permission publishes it, so the common case for your own sensor is that it needs one of:

  • a read token — X-Read-Token: <token>, issued on the sensor's edit page. This is the one to use from a script or another system;
  • or a signed-in session, which is what your browser already has.

A sensor that has been made public is readable with no token and no header at all. A private sensor read without either answers 404 with "This sensor is private; a valid read token is required." — that message means the sensor exists and you have not presented a credential, not that the name is wrong.

Required Parameters (GET)

  • site — the sensor's storage key, shown on its page (e.g. North Fork O). It is not a table name, and it is not the display name you can rename: the storage key is fixed when the sensor is created and never changes. It may contain spaces — URL-encode them (North%20Fork%20O).
  • start — start time (ISO 8601 format)
  • end — end time (ISO 8601 format)
  • vars — comma-separated list of columns to return (e.g. vars=Meter_Hydros21_Cond,Meter_Hydros21_Temp)

Notes on vars

  • DateTimeUTC (the reading's UTC timestamp) is always included in the response — you don't need to list it in vars.
  • Names in vars that the sensor does not record are ignored. If none of them match, the response is an empty list.

Example GET Request

curl "https://app-iot.geronimoengineering.com/night-heron-multi/api/creek-data/?site=North%20Fork%20O&start=2025-04-01T00:00:00Z&end=2025-04-07T00:00:00Z&vars=Meter_Hydros21_Cond" \
-H "X-Read-Token: <read token>"

Drop the X-Read-Token header if the sensor is public. Substitute your own storage key for North Fork O — it is shown on the sensor's page, and a key that is not yours answers 404.

Example GET Response

[
  {
    "DateTimeUTC": "2025-04-06 01:45:00",
    "Meter_Hydros21_Cond": 419.7
  },
  {
    "DateTimeUTC": "2025-04-06 02:00:00",
    "Meter_Hydros21_Cond": 419.7
  }
]

Upload Sensor Data

Send new readings for one sensor. The credential names the sensor, so there is no site to choose.

Endpoint

POST /api/upload-sensor-data/

Authentication (POST)

Each sensor has its own credential, sk_<sensor_id>_<secret>. It identifies the sensor as well as authenticating it, so an upload can only ever write that sensor's readings — there is no target to name in the payload. Each sensor is set to one of two modes on its edit page.

The credential may contain spaces. The <sensor_id> is the sensor's storage key, and storage keys are allowed spaces, so a real credential can look like sk_Creek One_A1b2…. That is valid and the server accepts it: it splits the Authorization header on the first space only. Client code often does not — auth.split()[1] and split(' ') truncate it to sk_Creek, which fails with 403 {"error": "Forbidden: invalid API token"}, the same error a wrong secret gives. Send the credential verbatim, everything after Authorization: Token .

Signed requests (default)

The secret never leaves the device. Send the key id — the part before the secret — and sign each request:

Authorization: Token sk_<sensor_id>
X-NH-Timestamp: 1753130000
X-NH-Nonce: 6f1c9e0d4b2a
X-NH-Signature: <hex hmac>

The signature is HMAC-SHA256(signing_key, canonical), hex-encoded, where the canonical string is these six lines joined with \n:

NH1
POST
/api/upload-sensor-data/
<timestamp>
<nonce>
<sha256 hex of the exact request body>
  • timestamp is whole seconds since the epoch and must be within 5 minutes of server time — keep the device clock synced.
  • nonce must be different on every request; a repeat is rejected as a replay. Random hex is fine.
  • The body hash is over the bytes you actually send, so sign after serialising, not before.
  • The path is the request path exactly as sent, including any deployment prefix — sign /night-heron-multi/api/upload-sensor-data/ if that is what your URL contains.

The signing key is shown once, with the token, when the credential is issued or rotated.

Legacy bearer token

For firmware that cannot sign. Send the whole credential and nothing else:

Authorization: Token sk_<sensor_id>_<secret>

The secret is then on the wire in every request, and a captured request can be replayed. Prefer signed mode wherever the device can manage it.

Required JSON Fields

  • timestamp — ISO datetime (e.g. 2025-05-01T13:00:00Z). Optional for a single reading, where it defaults to the time the upload arrives; required for every reading in a batch.
  • Send a UTC offset. A timestamp with no offset — 2025-05-01T13:00:00 — is read as UTC, not as the sensor's configured timezone. A logger reporting local time without an offset therefore stores every reading shifted by the site's offset, and nothing about the stored data will look wrong. Write 2025-05-01T13:00:00Z or 2025-05-01T06:00:00-07:00.

There is no site field: the token identifies the sensor, so an upload can only ever write to that one sensor's series. A site key in the payload is treated as an ordinary reading, not a target.

Sensor Parameters

Any additional JSON key (other than timestamp) is stored as a sensor reading — the API accepts an open vocabulary, so new sensor types need no code change. Numeric values are stored as numbers and non-numeric values as text.

A parameter name must be a valid identifier: it must start with a letter or underscore, contain only letters, numbers, and underscores, and be at most 64 characters. Keys that don't match (or whose values are nested objects, arrays, or booleans) are ignored. Common examples:

  • Meter_Hydros21_Cond
  • Meter_Hydros21_Depth
  • Meter_Hydros21_Temp
  • EnviroDIY_Mayfly_Batt
  • Sensirion_SHT40_Temperature
  • …or any parameter your sensor reports

Example POST Request

Legacy bearer mode only. A sensor is created in signed mode, and a signed sensor rejects a request that carries no X-NH-Timestamp / X-NH-Nonce / X-NH-Signature — so pasting this against a new sensor answers Forbidden until you switch that sensor to legacy bearer mode on its edit page. The headers a signed request sends are above; everything else about the body is identical.

curl -X POST https://app-iot.geronimoengineering.com/night-heron-multi/api/upload-sensor-data/ \
-H "Authorization: Token sk_<sensor_id>_<secret>" \
-H "Content-Type: application/json" \
-d '{
  "timestamp": "2025-05-01T13:00:00Z",
  "Meter_Hydros21_Cond": 123.45,
  "EnviroDIY_Mayfly_Batt": 3.9
}'

Uploading a Batch

Batch by default, not only when you were offline. Measured on 2026-09-17 on an isolated copy of this deployment: one reading per request moved 40 readings a second and pinned the database, while requests of 100 readings moved 1,088 readings a second at a quarter of the database's effort. The cost of an upload is the request, not the readings inside it, so a device that posts every sample the moment it is taken spends the whole fleet's headroom on itself. The shape that keeps a fleet of a thousand devices comfortable: sample as often as you like, post every 5 minutes with everything you have (up to 500 readings), and keep the buffer until the server answers 200. That also stays well inside the per-token rate limit below.

A device that buffered readings while offline replays them the same way, in a single request. Send a JSON array of the same objects you would have posted one at a time — either bare, or under a readings key:

The same note applies as above: this is the legacy bearer form, and a signed sensor (the default) needs the three X-NH-* headers instead of the bare token.

curl -X POST https://app-iot.geronimoengineering.com/night-heron-multi/api/upload-sensor-data/ \
-H "Authorization: Token sk_<sensor_id>_<secret>" \
-H "Content-Type: application/json" \
-d '{"readings": [
  {"timestamp": "2025-05-01T13:00:00Z", "Meter_Hydros21_Cond": 123.45},
  {"timestamp": "2025-05-01T13:15:00Z", "Meter_Hydros21_Cond": 124.10}
]}'
  • Up to 500 readings per request (413 above that).
  • Every batched reading must carry its own timestamp.
  • A batch is all-or-nothing: if any reading is malformed nothing is stored, and the error names the offending index.
  • Re-sending a reading you already sent overwrites it in place rather than duplicating it, so replaying an overlapping buffer is safe.

Rate Limits

Uploads are limited per token (600 requests/hour by default), so one device can't crowd out the rest of the fleet. Reading data back out is limited per IP for anonymous callers only. Over the limit returns 429; batching is the way to stay under it.

Success Response

{
  "message": "Data inserted successfully.",
  "readings_stored": 2
}

OGC SensorThings API

The same data is also published through the OGC SensorThings API v1.1, a standard read interface that off-the-shelf clients, GIS tools and data catalogues already speak. Use it instead of /api/creek-data/ if you are harvesting into another system; use /api/creek-data/ if you just want a table of numbers.

GET /api/v1.1/

That service document lists every entity set. The mapping to this system's own terms:

  • Things — monitoring stations (sensors)
  • Locations / FeaturesOfInterest — where each station is, as GeoJSON
  • Datastreams — one variable at one station, with its UCUM unit
  • ObservedProperties — what is measured, as an ODM2/CUAHSI or CF vocabulary URI
  • Sensors — how the readings are acquired (the procedure)
  • Observations — the readings themselves, with QARTOD quality flags

Examples

GET /api/v1.1/Things
GET /api/v1.1/Things('south_fork_0')/Datastreams
GET /api/v1.1/Datastreams('south_fork_0:Meter_Hydros21_Temp')/Observations?$top=10
GET /api/v1.1/Observations?$filter=phenomenonTime ge 2026-07-01T00:00:00Z&$orderby=phenomenonTime asc
GET /api/v1.1/Observations?$resultFormat=dataArray&$top=1000

Supported query options

This is a read-only façade over the existing store, and it implements a documented subset. Anything outside the subset is rejected with a 400 naming what is supported — a query option is never silently ignored, because a dropped $filter would hand you unfiltered data that looks like an answer.

  • $top (max 1000), $skip, $count, $select, $orderby — fully supported; @iot.nextLink pages the rest
  • $expand — one level, no nested options
  • $filter — and/or/not, parentheses, and eq ne gt ge lt le against the fields listed under serverSettings in the service document
  • $resultFormat=dataArray — the compact bulk form, for Observations

Data quality

An Observation whose reading has been quality-controlled carries a resultQuality object with its QARTOD flag, the tests that produced it, and whether a human reviewed it. Where a correction has been applied, result is the corrected value and the original is kept alongside it as rawResult.

Access

Public stations are readable with no credential. A private station is absent from every collection and returns 404 by id, unless you present its read token in an X-Read-Token request header, or are signed in with access to it. The token is read from that header only — never from the query string, where it would be written into server access logs and request traces and outlive the request. Only variables that have been given an observed property and a unit on the Settings page appear as Datastreams — an unclassified variable is left out rather than published under a name only this deployment understands.

WaterML2 export

For depositing data rather than querying it, series can be exported as OGC WaterML 2.0 Part 1 documents — the format HydroShare, CUAHSI and state water agencies accept. SensorThings above is the interface to read from; this is the file to hand over.

GET /api/waterml2/
GET /api/waterml2/timeseries/south_fork_0:Meter_Hydros21_Temp
GET /api/waterml2/timeseries/south_fork_0:Meter_Hydros21_Temp?start=2026-07-01T00:00:00Z&end=2026-07-08T00:00:00Z
GET /api/waterml2/collection?series=south_fork_0:stage&series=south_fork_0:flow_rate

The index at /api/waterml2/ lists every exportable series with its identifier, observed property and UCUM unit. Series identifiers are the same <station>:<variable> pairs SensorThings uses for Datastreams, so moving between the two needs no translation.

Data quality

Each point carries its QARTOD flag as a wml2:qualifier holding an swe:Category — the schema reserves wml2:quality for OGC's own code list and directs other code systems to qualifier. Corrected readings publish the corrected value, with the original stated in the point's comment.

Limits

A request for more than 50,000 points is refused with a 400 naming the count and the limit, rather than being truncated: a partial export is indistinguishable from a complete one, so narrowing the window has to be your decision and not ours. The same applies to an unknown or unpublishable series and to an unparseable start/end — both are errors, never a quietly smaller document. Up to 25 series may share one collection.

Access

Identical to SensorThings, and enforced by the same code: public stations are open, a read token or a signed-in session unlocks a private one, and a variable with no observed property and unit is not exported at all.

Limits and behaviours a device integrator meets first

Every ceiling below is a refusal with a message, except the /api/creek-data/ row cap, which returns what fits and says so in response headers (see Reading). The numbers are the defaults; an operator can retune them per deployment.

Uploading

  • Credential header. Send the upload token as Authorization: Token <token> (or the bare token). Authorization: Bearer <token> is not accepted on upload and answers 403 — Bearer is the read API's header for a read token, a different credential.
  • 10,000 readings per request (a reading is one variable at one instant, so a batch of 100 items × 20 variables is 2,000). Over that: 413. A batch may hold at most 500 items.
  • 64 variables per upload. A request naming more distinct variables than that is refused with 400, because a buggy device inventing column names would otherwise create variables forever.
  • Clock window. A timestamp more than 60 minutes in the future, or more than 50 years in the past, is refused with 400 naming the acceptable window — a logger whose clock runs an hour fast is rejected, not stored as "the latest reading". Naive timestamps are read as UTC.
  • Signed sensors. A sensor whose uploads must be signed answers 401 (not 403) when X-NH-Timestamp, X-NH-Nonce or X-NH-Signature is missing.

Reading

  • A private sensor's read token goes in X-Read-Token or Authorization: Bearer <token>; a signed-in session also works.
  • The window may span at most 548 days and a request at most 50 variables; latest=N returns the newest N readings instead of a window, and qc=1 adds the QC flag and the corrected value to each row.
  • Row cap on /api/creek-data/. One request reads at most max(20,000, 200,000 ÷ number of variables) readings (one reading = one variable at one instant), oldest first — 200,000 for one variable, 40,000 for five. A window with more is not refused: the response is 200 with the oldest readings that fit, ending on a complete instant, and carries X-NH-Truncated: 1 and X-NH-Row-Cap: <cap>. Always check for X-NH-Truncated; to get the rest, request again with start just after the last DateTimeUTC you received, or use /api/creek-stats/ for a bucketed summary of a wide window.
  • WaterML 2.0 documents are capped at 50,000 points and 25 series; SensorThings pages its results.

Error Responses

The read and upload APIs return errors as a single JSON object, {"error": "<message>"}, with the status below. WaterML2 uses this shape too — its successful responses are XML, but its errors are this same JSON object, so one parser handles every failure the read APIs produce. (The SensorThings service uses its own spec-defined error object instead, {"code": …, "type": "error", "message": "…"} — the same shape for every failure it returns, 404s included.)

  • 400 — Missing or invalid parameters (for a batch, the response includes the index of the offending reading)
  • 401 — Signature missing, wrong, stale, or a reused nonce
  • 403 — Missing or invalid upload credential
  • 404 — A private sensor read without a valid read token. An unknown site gets the same 404, deliberately: the answer must not reveal whether a private sensor exists
  • 413 — A batch of more than 500 items, or a request that would store more than 10,000 readings (see Limits)
  • 429 — Rate limit exceeded; retry after the window
  • 500 — Internal server error
  • 503 — Replay protection temporarily unavailable; retry