Skip to content

IoT Ingestion API

The IoT Ingestion API allows external devices, pollers, and third-party integrations to push sensor readings into the CO2 platform. Data flows into Supabase time-series tables and appears in the app alongside asset information.

https://data.co2targettracking.co.uk/functions/v1

All endpoints are Supabase Edge Functions. Append the function name to the base URL.


Every request must include an API key in the X-API-Key header. Keys are estate-scoped — a key for Estate A cannot push data to sensors in Estate B.

Terminal window
curl -X POST https://data.co2targettracking.co.uk/functions/v1/ingest \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_your_key_here" \
-d '{"sensor_id": "...", "value": 42.5}'
  1. Open the Developer Portal in the admin app (Estate Settings > Developer Portal)
  2. Click Create API Key
  3. Name the key (e.g. “Sizewell water sensors”) and select scopes
  4. Copy the key — it is shown once and never stored
ScopeAllows
ingestPOST sensor readings
provisionCreate and update sensors
readList sensors and query readings

A key can have multiple scopes. The default is ingest only.

  • Keys can be revoked immediately from the Developer Portal
  • Keys can have an expiry date set at creation
  • Last used timestamp is tracked automatically
  • To rotate a key: create a new one, update your devices, then revoke the old one

Send one or more sensor readings. This is the primary endpoint for devices and pollers.

Required scope: ingest

Terminal window
curl -X POST https://data.co2targettracking.co.uk/functions/v1/ingest \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_abc123..." \
-d '{
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"value": 42.5,
"recorded_at": "2026-03-18T12:00:00Z",
"metadata": {
"signal_strength": 85,
"battery_pct": 72
}
}'
Terminal window
curl -X POST https://data.co2targettracking.co.uk/functions/v1/ingest \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_abc123..." \
-d '{
"readings": [
{"sensor_id": "...", "value": 42.5, "recorded_at": "2026-03-18T12:00:00Z"},
{"sensor_id": "...", "value": 43.1, "recorded_at": "2026-03-18T12:15:00Z"},
{"sensor_id": "...", "value": 41.9, "recorded_at": "2026-03-18T12:30:00Z"}
]
}'
FieldTypeRequiredDescription
sensor_idUUID stringYesThe sensor to record against
valuenumberYesThe reading value (finite number)
recorded_atISO 8601 stringNoWhen the device took the reading. Defaults to server time
metadataobjectNoArbitrary key-value pairs for provenance (signal strength, battery, calibration)
{
"status": "ok",
"inserted": 3,
"readings": [
{"sensor_id": "...", "reading_id": "uuid-1"},
{"sensor_id": "...", "reading_id": "uuid-2"},
{"sensor_id": "...", "reading_id": "uuid-3"}
]
}

Duplicate readings (same sensor_id + recorded_at) are upserted — the value and metadata are updated, not duplicated. This means retrying a failed request is always safe.


Register a new sensor for an asset. If a sensor with the same asset, type, and name already exists, the existing ID is returned.

Required scope: provision

Terminal window
curl -X POST https://data.co2targettracking.co.uk/functions/v1/sensors \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_abc123..." \
-d '{
"asset_id": "asset-uuid",
"site_id": "site-uuid",
"sensor_type": "water_level",
"name": "Tank A Level Sensor",
"unit": "litres",
"metadata": {
"model": "Endress+Hauser FMU30",
"serial": "SN-12345",
"latitude": 52.2053,
"longitude": 1.6284
}
}'
{
"status": "created",
"sensor_id": "550e8400-e29b-41d4-a716-446655440000"
}
{
"status": "exists",
"sensor_id": "550e8400-e29b-41d4-a716-446655440000",
"message": "Sensor already exists with this asset_id, type, and name"
}

Update a sensor’s name, unit, active status, or metadata.

Required scope: provision

Terminal window
curl -X PATCH https://data.co2targettracking.co.uk/functions/v1/sensors/550e8400-... \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_live_abc123..." \
-d '{"is_active": false}'

List all sensors for your estate. Supports filtering.

Required scope: read

Terminal window
# All sensors
curl -H "X-API-Key: sk_live_abc123..." \
https://data.co2targettracking.co.uk/functions/v1/sensors
# Filter by asset
curl -H "X-API-Key: sk_live_abc123..." \
"https://data.co2targettracking.co.uk/functions/v1/sensors?asset_id=uuid&sensor_type=water_level"
ParamDescription
asset_idFilter by asset
sensor_typeFilter by type (e.g. water_level)
is_activetrue or false
limitMax results (default 100, max 500)

Required scope: read

Terminal window
curl -H "X-API-Key: sk_live_abc123..." \
https://data.co2targettracking.co.uk/functions/v1/sensors/550e8400-...

TypeUnitDescription
water_flowl/minFlow rate
water_levellitresTank/reservoir level
water_temperature°CWater temperature
TypeUnitDescription
generator_fuel_level%Fuel remaining
generator_power_outputkWCurrent output
generator_runtime_hourshoursCumulative runtime
generator_temperature°CEngine temperature
TypeUnitDescription
weather_temperature°CAir temperature
weather_humidity%Relative humidity
weather_wind_speedkm/hWind speed
weather_precipitationmmPrecipitation

Custom sensor types can be created by using any string as sensor_type when provisioning.


Every reading stores two timestamps:

  • recorded_at — When the sensor device took the reading (set by the caller)
  • received_at — When the server ingested the reading (set automatically)

The gap between these two tells you about transmission delay. If a device was offline and batches its readings later, recorded_at will be in the past while received_at is current.

The metadata field on both sensors and readings is a freeform JSON object. Common conventions:

KeyUsed onDescription
signal_strengthreadingRSSI or signal quality (0-100)
battery_pctreadingDevice battery percentage
calibration_datesensorLast calibration ISO date
modelsensorDevice model number
serialsensorDevice serial number
latitudesensorGPS latitude (for weather polling)
longitudesensorGPS longitude (for weather polling)

Each API key has a per-minute request limit (default: 600 requests/minute). When exceeded:

// HTTP 429
{
"error": "Rate limit exceeded",
"limit": 600,
"retry_after_seconds": 60
}

Use batch mode (up to 100 readings per request) to stay within limits for high-frequency sensors.


StatusMeaning
400Invalid JSON body
401Missing or invalid API key
403Key doesn’t have required scope, or sensor belongs to a different estate
404Sensor not found
405HTTP method not allowed
422Validation error (missing fields, invalid values, batch too large, unknown sensor)
429Rate limit exceeded
500Server error

All error responses include an error field with a human-readable message:

{"error": "Unknown sensor IDs", "sensor_ids": ["bad-uuid"]}

import requests
from datetime import datetime, timezone
API_URL = "https://data.co2targettracking.co.uk/functions/v1/ingest"
API_KEY = "sk_live_your_key_here"
def push_reading(sensor_id: str, value: float):
response = requests.post(
API_URL,
headers={
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
json={
"sensor_id": sensor_id,
"value": value,
"recorded_at": datetime.now(timezone.utc).isoformat(),
},
)
response.raise_for_status()
return response.json()
# Push a water level reading
result = push_reading("550e8400-e29b-41d4-a716-446655440000", 42.5)
print(f"Inserted: {result['inserted']} readings")

const API_URL = "https://data.co2targettracking.co.uk/functions/v1/ingest";
const API_KEY = "sk_live_your_key_here";
async function pushReadings(readings) {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
body: JSON.stringify({ readings }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Ingestion failed: ${error.error}`);
}
return response.json();
}
// Push a batch of generator readings
const result = await pushReadings([
{ sensor_id: "...", value: 85.2, recorded_at: new Date().toISOString() },
{ sensor_id: "...", value: 42.0, recorded_at: new Date().toISOString() },
]);
console.log(`Inserted ${result.inserted} readings`);

Example: Weather poller (reference integration)

Section titled “Example: Weather poller (reference integration)”

The platform includes a built-in weather poller as a reference implementation. It runs every 15 minutes, fetches current conditions from the Open-Meteo API, and inserts readings for temperature, humidity, wind speed, and precipitation.

See supabase/functions/weather-poller/index.ts for the full implementation. Key patterns:

  1. Query sensors by type to find what needs polling
  2. Group by estate to minimise external API calls
  3. Fetch external data and map to sensor readings
  4. Upsert readings (idempotent — safe to re-run)
  5. Handle per-estate errors independently (one failure doesn’t block others)

  • Check the key hasn’t been revoked in the Developer Portal
  • Check the key hasn’t expired (if an expiry was set)
  • Make sure you’re sending the full key in the X-API-Key header, not just the prefix

”Sensor does not belong to this API key’s estate”

Section titled “”Sensor does not belong to this API key’s estate””
  • The sensor was registered under a different estate than your API key
  • Check the sensor’s estate_id matches your key’s estate
  • The sensor UUID doesn’t exist in the database
  • Register it first via POST /sensors (requires provision scope)
  • Wait 60 seconds and retry
  • Switch to batch mode to send more readings per request
  • Request a higher limit in the Developer Portal
  • By design, sending the same (sensor_id, recorded_at) pair twice updates the existing reading
  • This is intentional — retries are always safe