Ecowitt Cloud API v3 β Notes from Live Calls
Source: live calls against my GW3000B on 2026-05-10. Cross-checked against doc.ecowitt.net (SPA β not fetchable; learnings here come from the actual responses).
Auth
Every request takes three query params:
application_keyβ from User Center β Private Center β API Keysapi_keyβ same placemacβ device MAC, e.g.6C:C8:40:38:94:DB
No headers required. HTTPS only.
Base URL
https://api.ecowitt.net/api/v3
Response envelope
Every response is wrapped:
{ "code": 0, "msg": "success", "time": "1778435131", "data": { ... } }
code != 0 means error; msg carries the reason. time is server unix seconds (string).
Endpoints we use
GET /device/info
Sanity check + a real-time snapshot. No call_back needed. Returns device
metadata (name, mac, date_zone_id, lat/lon, stationtype, device_status)
and a last_update block whose shape matches the real-time payload below.
GET /device/real_time
Current snapshot. Required: auth + call_back. Real-time leaves have shape
{time: unix_str, unit: str, value: str} β different from history (see below).
GET /device/history
Historical series. Required params:
- auth (3 keys)
start_date,end_dateβYYYY-MM-DD HH:MM:SS. We send naive UTC; the API returns unix seconds that align with UTC, so no tz conversion needed despite the device tz beingAmerica/New_York.cycle_typeβ see belowcall_backβ comma-separated channel list.allis rejected for/device/history(40016 all is invalid) even though it works for/device/real_time. Pass explicit channels likeoutdoor,indoor,pressure,wind,solar_and_uvi,rainfall_piezo,rainfall,battery.
cycle_type values + storage tiers
Ecowitt retains data at three resolutions:
| cycle_type | resolution | retention |
|---|---|---|
5min |
5 minutes | last 90 days |
30min |
30 minutes | last 1 year |
4hour |
4 hours | last 2 years |
auto is also accepted and picks resolution based on the requested range.
Probed empirically β the docs suggest names like 240min, but those
return 40015 Invalid cycle_type. The four valid values are 5min, 30min,
4hour, and auto. Anything else is rejected.
History response shape
Different from real_time β values are stored in a list map keyed by unix seconds:
{
"data": {
"outdoor": {
"temperature": {
"unit": "ΒΊF",
"list": {
"1778277600": "57.1",
"1778279400": "56.8",
...
}
},
"humidity": { "unit": "%", "list": { ... } },
...
},
"pressure": { "relative": { ... }, "absolute": { ... } },
"wind": { "wind_speed": { ... }, ... },
"outdoor": { "temperature": { ... }, "humidity": { ... }, ... },
"indoor": { ... },
"solar_and_uvi": { "solar": { ... }, "uvi": { ... } },
"rainfall_piezo": { "rain_rate": { ... }, ... },
"battery": { "haptic_array_battery": { ... }, ... }
}
}
Every metric leaf is { unit: str, list: { unix_seconds_str: value_str } }. Both
the timestamp keys and the values are strings β cast on the way in.
The structure is consistently 2 levels deep: data.{channel}.{metric} for the
metrics we've seen on this station. pressure only has relative and
absolute; everything else has multiple metrics.
Running the SQLite sync
The archive lives at data/ecowitt.db (gitignored). The sync is idempotent:
re-running is safe, and only new rows are written.
# full update across all three cycle_types (5min, 30min, 4hour)
.venv/bin/python -m src.sync
# just one cycle_type
.venv/bin/python -m src.sync --cycle 5min
# different DB path
.venv/bin/python -m src.sync --db data/other.db
# bigger overlap when resuming (default is 24h)
.venv/bin/python -m src.sync --overlap-hours 48
How "incremental" works: for each cycle_type the sync queries
MAX(ts_unix) from the DB and starts the next fetch at max_ts β overlap
(default 1 day). Overlap re-fetches a small tail to catch late-arriving
points; dedup is handled by the primary key
(cycle_type, channel, metric, ts_unix) with INSERT OR REPLACE.
Suggested cadence:
- Hourly for
5min(most active tier). - Daily for
30minand4hourβ cheap and keeps the long-range archive current.
Pure-cron example (run hourly, only the active tier; do a full sweep nightly):
0 * * * * cd ~/repos/time-series-ai-weather-forecast && .venv/bin/python -m src.sync --cycle 5min >> data/sync.log 2>&1
30 3 * * * cd ~/repos/time-series-ai-weather-forecast && .venv/bin/python -m src.sync >> data/sync.log 2>&1
Inspect the archive directly with sqlite:
sqlite3 data/ecowitt.db "SELECT cycle_type, COUNT(*), MIN(datetime(ts_unix,'unixepoch')), MAX(datetime(ts_unix,'unixepoch')) FROM readings GROUP BY cycle_type"
sqlite3 data/ecowitt.db "SELECT * FROM fetch_log ORDER BY id DESC LIMIT 10"
If the run aborts on a rate-limit (code=-1), the script stops cleanly after
backing off twice β just re-run later and it picks up from where it left off.
Quirks
- Strings, not numbers. Timestamps and values both arrive as strings.
- Sparse data is possible. Different metrics may have slightly different sets of timestamps (sensors drop in and out).
- Per-call size. For
cycle_type=30minover 7 days with a few channels, responses are ~50 KB.5minfor the same window is ~10Γ larger. Chunk multi-month ranges to be safe. - Rate limit (observed). The API returns
code=-1,msg="The number of interface accesses reached the upper limit"after ~30 calls in quick succession. The exact threshold and reset window aren't documented; in practice a 60β120s sleep clears it. Sync handles this with exponential backoff (60s β 120s) and stops gracefully if still throttled so a re-run can resume from where it left off. - Channel availability is station-specific. GW3000B exposes
outdoor,indoor,solar_and_uvi,rainfall_piezo,wind,pressure,battery. Other stations differ. - Units may change. Returned in whatever unit the device is configured for
(mine is imperial). Persist
unitalongside the value so we don't lose it.