Overview
The IoT surface lets sensors, gateways, and third-party scripts feed data into a user’s plots. There are three auth shapes in play:
- JWT — used for device management (create / edit / revoke a device). This is the same auth you use for the rest of the API when logged in as a human.
- API keys — long-lived secrets generated in settings. Used for the API-key scoped IoT endpoints (
/api/v1/iot/*). - Device tokens — short opaque strings (
ggd_+ 12 base62 chars) baked into the URL path. Used only forPOST /i/e/{token}. The token itself is the credential; there is no header or query string.
Device tokens are scoped to a single device’s channel map — they can push readings to exactly the plots configured on that device, and nothing else. Revoking a device invalidates its token without affecting any other credentials.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/devices | JWT | List the user’s active IoT devices |
POST | /api/v1/devices | JWT | Create a device + issue a token |
PATCH | /api/v1/devices/:id | JWT | Update a device (name and/or channel map) |
DELETE | /api/v1/devices/:id | JWT | Revoke a device (idempotent) |
POST | /i/e/:token | Device token | Ecowitt-format ingest (form-encoded) |
GET | /api/v1/iot/plots | API key | List the user’s plots (minimal projection) |
POST | /api/v1/iot/photo | API key | Upload a photo to a plot (multipart) |
Channel keys
Device channel maps route sensor readings to plots. Valid keys are:
| Key | Ecowitt fields that end up here |
|---|---|
outdoor | tempf, humidity, rainratein, wind, solar, UV |
indoor | tempinf, humidityin, baromrelin, baromabsin |
ch1..ch8 | tempf<N>, humidity<N>, soilmoisture<N>, co2_ch<N>, pm25_ch<N>, etc. |
Unmapped channels are silently dropped on ingest. An empty channel map is accepted but the device will not produce any readings.
Devices (JWT auth)
List devices
GET /api/v1/devices
Authorization: Bearer <jwt>
Response 200
[
{
"id": "c1a8…",
"token": "ggd_AbCdEf123456",
"kind": "ecowitt",
"name": "Backyard Ecowitt",
"channel_map": {
"outdoor": "plot-uuid-back",
"ch1": "plot-uuid-raised-bed-1"
},
"created_at": "2026-04-18T12:34:00Z",
"last_seen_at": "2026-04-18T17:02:11Z"
}
]
Create a device
POST /api/v1/devices
Authorization: Bearer <jwt>
Content-Type: application/json
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | User-facing label (e.g. “Backyard Ecowitt”) |
kind | string | No | Currently only "ecowitt" (default) |
channel_map | object<string, uuid> | No | Map of channel key → plot UUID (see keys above) |
Response 201 — returns the created device (same shape as the list). The token is only shown here on initial create but is also included in every subsequent GET.
403 INVALID_CHANNEL_MAP when a plot in channel_map isn’t owned by the caller or the channel key is outside outdoor | indoor | ch1..ch8.
Update a device
PATCH /api/v1/devices/:id
Authorization: Bearer <jwt>
Content-Type: application/json
Partial update. Either field can be omitted.
{
"name": "Renamed",
"channel_map": { "outdoor": "…", "ch3": "…" }
}
404 NOT_FOUND if the device ID doesn’t belong to the caller. 403 INVALID_CHANNEL_MAP if the new map references unknown channels or foreign plots.
Revoke a device
DELETE /api/v1/devices/:id
Authorization: Bearer <jwt>
Idempotent. Returns 200 for both a first-time revoke and a second DELETE of an already-revoked device. Returns 404 only if the device ID has never existed for this user.
Ecowitt ingest (device token)
POST /i/e/:token
Content-Type: application/x-www-form-urlencoded
No Authorization header — the path token authenticates the request. Any Ecowitt-format payload works:
PASSKEY=…&stationtype=EasyWeatherV1&tempf=68.1&humidity=55&rainratein=0.02&tempf1=72.2&humidity1=62&soilmoisture1=37
Hostnames: HTTP and HTTPS both supported
| URL | Transport | When to use |
|---|---|---|
http://iot.garden.gg/i/e/{token} | HTTP on port 80 | Ecowitt gateway firmware and any other device that can’t do TLS. |
https://garden.gg/i/e/{token} | HTTPS on port 443 | Anything that can do TLS (custom scripts, ESP32, newer GW2000 firmware). |
Both hostnames route to the same Go handler. iot.garden.gg exists purely to accept plain-HTTP POSTs at the CDN edge and forward them upstream over HTTPS — the on-the-wire ingest looks identical once past Cloudflare.
Ecowitt gateways as of 2026 only POST over HTTP, so use the iot.garden.gg / port 80 form unless you’re sure your firmware supports HTTPS. If you can use HTTPS, prefer garden.gg / port 443.
Field → column mapping
Each classified field is bucketed by channel and may populate one of the dedicated columns on the reading that gets created for that channel’s plot:
| Dedicated column | Source fields |
|---|---|
temperature | tempf (outdoor), tempinf (indoor), tempf<N> (chN) |
humidity | humidity, humidityin, humidity<N> |
soil_moisture | soilmoisture<N> |
co2 | co2_ch<N> |
rain_rate_in | rainratein (outdoor only) |
raw (jsonb) | Every classified field from that channel’s payload |
Anything not listed above (wind, solar, UV, PM2.5, leaf wetness, rain totals, battery levels) still goes into raw so you don’t lose data when new charts land.
Response 200
{
"status": "ok",
"channels_written": 3,
"plots_written": ["plot-uuid-back", "plot-uuid-raised-bed-1", "plot-uuid-tomato"]
}
Errors
- 401 INVALID_TOKEN — token unknown, malformed, or revoked.
- 401 WRONG_KIND — the device wasn’t configured as
ecowitt. - 429 RATE_LIMITED — max 1 POST per minute per device. Raise the gateway’s upload interval.
API-key endpoints
List plots
GET /api/v1/iot/plots
X-API-Key: gg_live_…
Returns a narrow IoTPlot projection so integrations can discover plot IDs without pulling the full web-client plot payload.
Response 200
[
{
"id": "c7e8…",
"name": "Backyard Raised Bed 1",
"plot_type": "raised_bed",
"soil_type": "potting_mix",
"indoor": false
}
]
Upload a photo
POST /api/v1/iot/photo
X-API-Key: gg_live_…
Content-Type: multipart/form-data
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
plot_id | UUID | Yes | Plot the photo is attached to |
file | file | Yes | JPEG / PNG / WebP, ≤ 10 MB |
caption | string | No | Optional caption |
Response 200 returns the created photo’s id and url.
Errors
- 400 INVALID_CONTENT_TYPE — uploaded file isn’t one of
image/jpeg,image/png,image/webp. - 413 TOO_LARGE — file exceeds 10 MB.
- 429 RATE_LIMITED — max 1 upload per 30 seconds per API key.
Photos uploaded here appear in the plot’s gallery and are automatically pulled into the plot photo playlist (variable-speed timelapse playback on web, iOS, and Android).