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.
Base URL
Section titled “Base URL”https://data.co2targettracking.co.uk/functions/v1All endpoints are Supabase Edge Functions. Append the function name to the base URL.
Authentication
Section titled “Authentication”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.
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}'Getting an API key
Section titled “Getting an API key”- Open the Developer Portal in the admin app (Estate Settings > Developer Portal)
- Click Create API Key
- Name the key (e.g. “Sizewell water sensors”) and select scopes
- Copy the key — it is shown once and never stored
Scopes
Section titled “Scopes”| Scope | Allows |
|---|---|
ingest | POST sensor readings |
provision | Create and update sensors |
read | List sensors and query readings |
A key can have multiple scopes. The default is ingest only.
Key lifecycle
Section titled “Key lifecycle”- 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
Endpoints
Section titled “Endpoints”POST /ingest — Push sensor readings
Section titled “POST /ingest — Push sensor readings”Send one or more sensor readings. This is the primary endpoint for devices and pollers.
Required scope: ingest
Single reading
Section titled “Single reading”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 } }'Batch readings (up to 100)
Section titled “Batch readings (up to 100)”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"} ] }'Request fields
Section titled “Request fields”| Field | Type | Required | Description |
|---|---|---|---|
sensor_id | UUID string | Yes | The sensor to record against |
value | number | Yes | The reading value (finite number) |
recorded_at | ISO 8601 string | No | When the device took the reading. Defaults to server time |
metadata | object | No | Arbitrary key-value pairs for provenance (signal strength, battery, calibration) |
Success response (200)
Section titled “Success response (200)”{ "status": "ok", "inserted": 3, "readings": [ {"sensor_id": "...", "reading_id": "uuid-1"}, {"sensor_id": "...", "reading_id": "uuid-2"}, {"sensor_id": "...", "reading_id": "uuid-3"} ]}Idempotency
Section titled “Idempotency”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.
POST /sensors — Register a sensor
Section titled “POST /sensors — Register a sensor”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
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 } }'Response (201 created)
Section titled “Response (201 created)”{ "status": "created", "sensor_id": "550e8400-e29b-41d4-a716-446655440000"}Response (200 already exists)
Section titled “Response (200 already exists)”{ "status": "exists", "sensor_id": "550e8400-e29b-41d4-a716-446655440000", "message": "Sensor already exists with this asset_id, type, and name"}PATCH /sensors/:id — Update a sensor
Section titled “PATCH /sensors/:id — Update a sensor”Update a sensor’s name, unit, active status, or metadata.
Required scope: provision
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}'GET /sensors — List sensors
Section titled “GET /sensors — List sensors”List all sensors for your estate. Supports filtering.
Required scope: read
# All sensorscurl -H "X-API-Key: sk_live_abc123..." \ https://data.co2targettracking.co.uk/functions/v1/sensors
# Filter by assetcurl -H "X-API-Key: sk_live_abc123..." \ "https://data.co2targettracking.co.uk/functions/v1/sensors?asset_id=uuid&sensor_type=water_level"Query parameters
Section titled “Query parameters”| Param | Description |
|---|---|
asset_id | Filter by asset |
sensor_type | Filter by type (e.g. water_level) |
is_active | true or false |
limit | Max results (default 100, max 500) |
GET /sensors/:id — Get a single sensor
Section titled “GET /sensors/:id — Get a single sensor”Required scope: read
curl -H "X-API-Key: sk_live_abc123..." \ https://data.co2targettracking.co.uk/functions/v1/sensors/550e8400-...Sensor types
Section titled “Sensor types”Water monitoring
Section titled “Water monitoring”| Type | Unit | Description |
|---|---|---|
water_flow | l/min | Flow rate |
water_level | litres | Tank/reservoir level |
water_temperature | °C | Water temperature |
Generator monitoring
Section titled “Generator monitoring”| Type | Unit | Description |
|---|---|---|
generator_fuel_level | % | Fuel remaining |
generator_power_output | kW | Current output |
generator_runtime_hours | hours | Cumulative runtime |
generator_temperature | °C | Engine temperature |
Weather monitoring
Section titled “Weather monitoring”| Type | Unit | Description |
|---|---|---|
weather_temperature | °C | Air temperature |
weather_humidity | % | Relative humidity |
weather_wind_speed | km/h | Wind speed |
weather_precipitation | mm | Precipitation |
Custom sensor types can be created by using any string as sensor_type when provisioning.
Provenance model
Section titled “Provenance model”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:
| Key | Used on | Description |
|---|---|---|
signal_strength | reading | RSSI or signal quality (0-100) |
battery_pct | reading | Device battery percentage |
calibration_date | sensor | Last calibration ISO date |
model | sensor | Device model number |
serial | sensor | Device serial number |
latitude | sensor | GPS latitude (for weather polling) |
longitude | sensor | GPS longitude (for weather polling) |
Rate limiting
Section titled “Rate limiting”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.
Error responses
Section titled “Error responses”| Status | Meaning |
|---|---|
| 400 | Invalid JSON body |
| 401 | Missing or invalid API key |
| 403 | Key doesn’t have required scope, or sensor belongs to a different estate |
| 404 | Sensor not found |
| 405 | HTTP method not allowed |
| 422 | Validation error (missing fields, invalid values, batch too large, unknown sensor) |
| 429 | Rate limit exceeded |
| 500 | Server error |
All error responses include an error field with a human-readable message:
{"error": "Unknown sensor IDs", "sensor_ids": ["bad-uuid"]}Example: Python ingestion script
Section titled “Example: Python ingestion script”import requestsfrom 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 readingresult = push_reading("550e8400-e29b-41d4-a716-446655440000", 42.5)print(f"Inserted: {result['inserted']} readings")Example: Node.js ingestion script
Section titled “Example: Node.js ingestion script”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 readingsconst 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:
- Query sensors by type to find what needs polling
- Group by estate to minimise external API calls
- Fetch external data and map to sensor readings
- Upsert readings (idempotent — safe to re-run)
- Handle per-estate errors independently (one failure doesn’t block others)
Troubleshooting
Section titled “Troubleshooting””Invalid or expired API key”
Section titled “”Invalid or expired API key””- 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-Keyheader, 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
”Unknown sensor IDs”
Section titled “”Unknown sensor IDs””- The sensor UUID doesn’t exist in the database
- Register it first via POST /sensors (requires
provisionscope)
“Rate limit exceeded”
Section titled ““Rate limit exceeded””- Wait 60 seconds and retry
- Switch to batch mode to send more readings per request
- Request a higher limit in the Developer Portal
Duplicate readings
Section titled “Duplicate readings”- By design, sending the same
(sensor_id, recorded_at)pair twice updates the existing reading - This is intentional — retries are always safe