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 invars.- Names in
varsthat 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>
timestampis whole seconds since the epoch and must be within 5 minutes of server time — keep the device clock synced.noncemust 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. Write2025-05-01T13:00:00Zor2025-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_CondMeter_Hydros21_DepthMeter_Hydros21_TempEnviroDIY_Mayfly_BattSensirion_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 (
413above 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 GeoJSONDatastreams— one variable at one station, with its UCUM unitObservedProperties— what is measured, as an ODM2/CUAHSI or CF vocabulary URISensors— 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.nextLinkpages the rest$expand— one level, no nested options$filter—and/or/not, parentheses, andeq ne gt ge lt leagainst the fields listed underserverSettingsin 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 answers403—Beareris 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
400naming 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) whenX-NH-Timestamp,X-NH-NonceorX-NH-Signatureis missing.
Reading
- A private sensor's read token goes in
X-Read-TokenorAuthorization: Bearer <token>; a signed-in session also works. - The window may span at most 548 days and a request
at most 50 variables;
latest=Nreturns the newest N readings instead of a window, andqc=1adds the QC flag and the corrected value to each row. - Row cap on
/api/creek-data/. One request reads at mostmax(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 is200with the oldest readings that fit, ending on a complete instant, and carriesX-NH-Truncated: 1andX-NH-Row-Cap: <cap>. Always check forX-NH-Truncated; to get the rest, request again withstartjust after the lastDateTimeUTCyou 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 theindexof the offending reading)401— Signature missing, wrong, stale, or a reused nonce403— Missing or invalid upload credential404— A private sensor read without a valid read token. An unknownsitegets the same 404, deliberately: the answer must not reveal whether a private sensor exists413— 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 window500— Internal server error503— Replay protection temporarily unavailable; retry