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:

  1. 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.
  2. API keys — long-lived secrets generated in settings. Used for the API-key scoped IoT endpoints (/api/v1/iot/*).
  3. Device tokens — short opaque strings (ggd_ + 12 base62 chars) baked into the URL path. Used only for POST /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

MethodPathAuthDescription
GET/api/v1/devicesJWTList the user’s active IoT devices
POST/api/v1/devicesJWTCreate a device + issue a token
PATCH/api/v1/devices/:idJWTUpdate a device (name and/or channel map)
DELETE/api/v1/devices/:idJWTRevoke a device (idempotent)
POST/i/e/:tokenDevice tokenEcowitt-format ingest (form-encoded)
GET/api/v1/iot/plotsAPI keyList the user’s plots (minimal projection)
POST/api/v1/iot/photoAPI keyUpload a photo to a plot (multipart)

Channel keys

Device channel maps route sensor readings to plots. Valid keys are:

KeyEcowitt fields that end up here
outdoortempf, humidity, rainratein, wind, solar, UV
indoortempinf, humidityin, baromrelin, baromabsin
ch1..ch8tempf<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

FieldTypeRequiredDescription
namestringYesUser-facing label (e.g. “Backyard Ecowitt”)
kindstringNoCurrently only "ecowitt" (default)
channel_mapobject<string, uuid>NoMap 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

URLTransportWhen to use
http://iot.garden.gg/i/e/{token}HTTP on port 80Ecowitt gateway firmware and any other device that can’t do TLS.
https://garden.gg/i/e/{token}HTTPS on port 443Anything 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 columnSource fields
temperaturetempf (outdoor), tempinf (indoor), tempf<N> (chN)
humidityhumidity, humidityin, humidity<N>
soil_moisturesoilmoisture<N>
co2co2_ch<N>
rain_rate_inrainratein (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

FieldTypeRequiredDescription
plot_idUUIDYesPlot the photo is attached to
filefileYesJPEG / PNG / WebP, ≤ 10 MB
captionstringNoOptional 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).

See also