← Back to blog

Build a DIY ESP32 Garden Sensor with Garden.gg's IoT API

Garden.gg Team ·

Commercial weather stations are great — but half of gardening is knowing whether this raised bed is thirsty, and that’s what cheap sensors are perfect for. An ESP32, a capacitive soil probe, and a DHT22 cost about $15 and fit in a weatherproof junction box. Add an ESP32-CAM and you have a timelapse camera too.

This post walks through connecting one of those builds to Garden.gg so the readings appear on your plot’s Environment dashboard and the photos automatically become a timelapse playlist. No new endpoints to learn — the IoT API Ecowitt gateways use already does everything you need.

What you’ll build

Two tracks, same device family. Pick one or do both:

  • Track A — Soil + temp telemetry. Capacitive soil moisture probe + DHT22 on an ESP32 WROOM. Posts once a minute to Garden.gg using the Ecowitt-compatible ingest path. Per-plot charts for temperature, humidity, and soil moisture fill in automatically.
  • Track B — Timelapse camera. ESP32-CAM module takes a JPEG every 15 minutes and uploads to Garden.gg’s photo endpoint. Garden.gg’s Plot Photo Playlist feature plays them back as a variable-speed timelapse.

Both tracks run on a single ESP32 if you’re willing to solder a bit.

Shopping list

PartApprox. cost
ESP32 WROOM dev board (or ESP32-CAM)$8
Capacitive soil moisture probe (v1.2)$3
DHT22 (AM2302) temp + humidity$4
Weatherproof junction box$5
3-wire USB power or 18650 battery pack$5

Nothing here needs a logic-level shifter or a buck converter. The ESP32’s 3.3V rail drives the soil probe and DHT22 directly.

Track A — soil + temp telemetry

1. Generate an API key + a device token

You need both:

  • API key (from Profile → API Keys → New Key) — lets your build script discover which plots exist.
  • Device token (from Profile → Sensor Devices → Connect Device) — lets the sensor POST readings.

When you create the device, map ch1 to the plot your ESP32 is sitting in. Leave the other channels unmapped. Copy the /i/e/ggd_… path.

2. Pick the right plot (optional but nice)

From a laptop, list your plots with the API key — handy if you forget which plot IDs you have:

curl -s https://garden.gg/api/v1/iot/plots \
  -H "X-API-Key: gg_live_YOUR_KEY" | jq

Response is a small JSON array:

[
  { "id": "c7e8…", "name": "Backyard Raised Bed 1", "plot_type": "raised_bed", "soil_type": "potting_mix", "indoor": false },
  { "id": "2d31…", "name": "Tomato Row",          "plot_type": "in_ground", "soil_type": "loam",         "indoor": false }
]

You usually don’t need the plot ID on the device itself — the channel map handles routing. The API key path is there for scripts that want to configure devices programmatically, or for integrations that have a different routing model.

3. Wiring

  • DHT22 data → GPIO 4 (add the 10kΩ pull-up between data and VCC)
  • Soil probe analog out → GPIO 34 (ADC1)
  • Both sensors to 3.3V + GND

4. Sketch

The ESP32 sends form-encoded data that looks like what an Ecowitt gateway would emit — tempf1=…&humidity1=…&soilmoisture1=…. Garden.gg’s ingest pipeline classifies each field by channel (the trailing 1 → ch1) and writes one environment reading per channel.

#include <WiFi.h>
#include <HTTPClient.h>
#include <DHT.h>

#define DHT_PIN 4
#define DHT_TYPE DHT22
#define SOIL_PIN 34                      // ADC1, no WiFi conflict

const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-password";

// Paste the /i/e/… path from Sensor Devices. Nothing else is required —
// no API key, no headers. The token IS the auth.
const char* INGEST_URL = "https://garden.gg/i/e/ggd_XXXXXXXXXXXX";

DHT dht(DHT_PIN, DHT_TYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
  pinMode(SOIL_PIN, INPUT);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(500); }
}

// Map the capacitive probe's raw ADC reading to 0..100% moisture.
// Your probe will vary — dip it in dry air (0%) and water (100%) to calibrate.
int soilMoisturePct() {
  int raw = analogRead(SOIL_PIN);
  int dry = 3000; // air
  int wet = 1200; // submerged
  int pct = map(raw, dry, wet, 0, 100);
  return constrain(pct, 0, 100);
}

void postReading() {
  float tempC = dht.readTemperature();
  float humidity = dht.readHumidity();
  if (isnan(tempC) || isnan(humidity)) return;
  float tempF = tempC * 1.8 + 32.0;
  int soil = soilMoisturePct();

  HTTPClient http;
  http.begin(INGEST_URL);
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");

  String body = "tempf1=" + String(tempF, 1)
              + "&humidity1=" + String(humidity, 0)
              + "&soilmoisture1=" + String(soil);

  int code = http.POST(body);
  Serial.printf("POST %s -> %d\n", INGEST_URL, code);
  http.end();
}

void loop() {
  postReading();
  delay(60000); // Garden.gg rate-limits device tokens to 1/min
}

Flash it, plug in the ESP32, and within two minutes your plot’s environment dashboard will light up.

Expanding to multiple channels

Add more sensors? Give each its own channel number. An ESP32 with three soil probes for three raised beds would post:

soilmoisture1=42&soilmoisture2=38&soilmoisture3=55

Map ch1, ch2, ch3 to three different plots on the same device, and one POST updates all three plots at once.

Track B — timelapse camera

1. Wiring

Use an ESP32-CAM board (AI-Thinker variant). Nothing to wire externally — the camera is on-board. Power over USB or a 2×AA case with a boost converter.

2. Sketch

Photos go to /api/v1/iot/photo — multipart upload, API-key auth, 10 MB max, rate-limited to 1 per 30 seconds. When you POST against a plot ID, the photo shows up in that plot’s gallery and is automatically pulled into the playlist so you get a timelapse.

#include <WiFi.h>
#include <HTTPClient.h>
#include "esp_camera.h"

const char* WIFI_SSID  = "your-ssid";
const char* WIFI_PASS  = "your-password";
const char* API_KEY    = "gg_live_YOUR_KEY";
const char* PLOT_ID    = "c7e8…";        // from GET /api/v1/iot/plots
const char* UPLOAD_URL = "https://garden.gg/api/v1/iot/photo";

void captureAndUpload() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return;

  HTTPClient http;
  http.begin(UPLOAD_URL);
  http.addHeader("X-API-Key", API_KEY);

  // Minimal multipart body
  String boundary = "----gardengg" + String(millis());
  http.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary);

  String head =
    "--" + boundary + "\r\n" +
    "Content-Disposition: form-data; name=\"plot_id\"\r\n\r\n" +
    String(PLOT_ID) + "\r\n" +
    "--" + boundary + "\r\n" +
    "Content-Disposition: form-data; name=\"file\"; filename=\"cam.jpg\"\r\n" +
    "Content-Type: image/jpeg\r\n\r\n";
  String tail = "\r\n--" + boundary + "--\r\n";

  size_t total = head.length() + fb->len + tail.length();

  uint8_t *payload = (uint8_t*) malloc(total);
  memcpy(payload, head.c_str(), head.length());
  memcpy(payload + head.length(), fb->buf, fb->len);
  memcpy(payload + head.length() + fb->len, tail.c_str(), tail.length());

  int code = http.POST(payload, total);
  Serial.printf("Photo -> %d\n", code);

  free(payload);
  http.end();
  esp_camera_fb_return(fb);
}

void loop() {
  captureAndUpload();
  delay(15UL * 60UL * 1000UL); // 15 minutes
}

(Full camera init boilerplate omitted for space — copy it from the standard ESP32-CAM examples that ship with the Arduino core.)

Open the plot, hit the playlist button, drag the speed slider to ~8× and watch three weeks of growth in 15 seconds.

Rate limits, batteries, and other gotchas

  • 1 POST per minute per device token. Below that, requests get 429. For battery-powered builds that’s actually good — deep-sleep between posts gets you weeks on a single charge.
  • Photo uploads: 1 per 30 seconds per API key. A single camera uploading every 15 minutes never hits this.
  • HTTPS: HTTPClient needs WiFi TLS. The ESP32 Arduino core does the cert handling; garden.gg’s cert is a standard Let’s Encrypt chain.
  • Timestamp: Garden.gg uses the server clock for the reading timestamp, not a value from your device. No NTP sync needed.

Extending

  • Track A’s body is just URL-encoded key/value pairs — add pm25_ch1=…, solarradiation=…, or any other Ecowitt field name and it gets preserved in the raw JSONB column (even if there’s no dedicated chart for it yet).
  • For per-plot cost tracking or custom events, the non-IoT REST API (/api/v1/events, /api/v1/expenses) works with the same API key.
  • Contribute your build — if you design something interesting, tag us on Reddit at r/gardengg and we’ll link it from the integrations page.

Next steps


Garden.gg’s IoT endpoints accept data from any device that can make an HTTPS POST. Free tier includes device tokens, soil moisture + rain charts, 5GB photo storage, and the Plot Photo Playlist (variable-speed timelapse).