This document describes the HTTP APIs exposed by the HiveHive services. The system has four services and three callable APIs:
| Service | Host port | Container port | Description |
|---|---|---|---|
| Homepage | 5173 |
5173 |
React + Vite frontend |
| Backend | 3002 |
3002 |
Express API consumed by the homepage |
| Image Service | 8000 |
4444 |
ESP upload + telemetry sidecar |
| DuckDB Service | 8002 |
8000 |
Persistent storage API |
The homepage itself is at http://localhost:5173. The canonical wire shapes
shared between backend and homepage live in contracts/src/index.ts.
The three APIs documented below are:
- Backend API (
http://localhost:3002) — auth-gated, consumed by the homepage. - Image Service API (
http://localhost:8000) — image ingestion + telemetry. - DuckDB Service API (
http://localhost:8002) — persistent storage.
Base URL: http://localhost:3002
The backend (backend/src/app.ts) is an Express + TypeScript service that
the homepage talks to. It refreshes an in-memory cache from the DuckDB
service and shapes the response for the frontend.
Reshaped by #142 / ADR-019. The homepage bundle carries no secret.
Reads are public. GET /api/health, GET /api/modules,
GET /api/modules/:id, GET /api/images (+ GET /api/images/:filename),
GET /api/modules/:id/activity, .../measurements, and
GET /api/user-location require no credential.
Admin / write actions require a session (backend/src/session.ts):
POST /api/admin/loginwith{ "password": "<HIGHFIVE_API_KEY>" }sets anHttpOnlyhf_admin_sessioncookie (rate-limited; constant-time check). The browser sends it automatically (credentials: 'include').- Or send header
X-Admin-Key: <HIGHFIVE_API_KEY>— the server-side machine credential for scripts / CI, never shipped to the browser.
requireAdmin gates DELETE /api/modules/:id,
DELETE /api/images/:filename, PATCH /api/modules/:id/name,
POST /api/modules/:id/measurements, POST /api/admin/weather/backfill,
GET /api/modules/:id/logs, and GET /api/admin/logs; it returns 401 when neither credential is
valid. Companion routes: POST /api/admin/logout (clears the cookie) and
GET /api/admin/session → { "authenticated": boolean }.
The dev default key is hf_dev_key_2026; override via HIGHFIVE_API_KEY in
production. (The legacy X-API-Key / Authorization: Bearer / ?api_key=
transports and the blanket read gate were removed in #142.)
GET /api/health
Public, no auth. Liveness probe.
{
"status": "ok",
"timestamp": "2026-04-25T12:34:56.000Z"
}GET /api/modules
Public — no auth (#142). Returns an array of Module objects shaped for the dashboard:
[
{
"id": "aabbccddeeff",
"name": "fierce-apricot-specht",
"displayName": "Klostergarten",
"location": { "lat": 47.81, "lng": 9.64 },
"status": "online",
"lastApiCall": "2026-04-25T12:34:56.000Z",
"batteryLevel": 85,
"firstOnline": "2023-04-15T00:00:00.000Z",
"totalHatches": 450,
"imageCount": 142
}
]location.lat/lng are generalized to ~1 km (2 decimal places) for every
caller, admin included — a privacy control for wild-bee nest sites, not a
precision bug. The exact fix is never served and (after duckdb round-on-write)
never persisted. See
ADR-020 /
#145.
name is the firmware-reported value (mutable on every UPSERT; same-batch
collisions auto-suffixed by duckdb-service add_module). displayName
is an optional admin-settable override; null when the operator has not
renamed the module. Frontend surfaces resolve the operator-visible label
via the shared helper
homepage/src/lib/displayLabel.ts,
which trims displayName and falls back to name on null / empty /
whitespace-only. The leading 4 hex chars of id ride along as a
visual subtitle (the trailing octets are shared by same-batch hardware
— see ADR-011 for the rationale). See
ADR-011.
status is one of 'online' | 'offline' | 'unknown' and is computed
in backend/src/database.ts's fetchAndAssemble. A module is 'online'
when any liveness signal (last image upload, registration timestamp, or
heartbeat) is fresher than 2 h. A module that would otherwise have been
classified as 'offline' is reported as 'unknown' (gray) instead
when the duckdb /heartbeats_summary fetch failed — we can't rule out
that a heartbeat from the last few minutes would have flipped it to
'online', so we admit uncertainty rather than misleading the
on-call. See #31.
The header X-Highfive-Data-Incomplete: heartbeats is set on the
listing route whenever the heartbeats fetch failed (irrespective of
whether any module's status actually flipped — the header surfaces the
data quality, not a per-module flag) so the dashboard can render a
"data incomplete" banner. The detail route (/api/modules/:id)
deliberately omits the header — its consumer always lands there from
the listing and has already seen the degradation signal. Old clients
that don't read the header still see a structurally valid response;
only the per-module status value may differ.
Caching / freshness. Both GET /api/modules and
GET /api/modules/:id are served from a shared in-process snapshot in
backend/src/database.ts's ModuleReadModel that is at most 5 s old
(ASSEMBLE_CACHE_TTL_MS). The detail route reuses the listing's
snapshot rather than re-running the four-endpoint duckdb fan-out, so the
common "open the dashboard, click a module" path costs one upstream
round-trip, not two. Consequence: a freshly registered or renamed module
— or a brand-new heartbeat — can lag by up to one TTL. The dashboard
does not poll, so this is only ever observed across deliberate
re-navigations, where 5 s is imperceptible. A degraded fan-out (any
upstream fetch failed) is returned to the caller but not cached, so a
transient duckdb outage cannot pin partial state past recovery.
GET /api/modules/:id
Public — no auth (#142). Same shape as above, plus a nests array of NestData. Each nest
carries dailyProgress[] with progress_id, nest_id, date,
empty, sealed, hatched. 404 if the module is unknown.
PATCH /api/modules/:id/name
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Body: { "display_name": "Garden Bee" } # or null to clear
Sets or clears the operator-settable display_name override (ADR-011).
Backend proxies to duckdb-service PATCH /modules/<id>/display_name,
which enforces a UNIQUE constraint at the DB layer. Frontend surfaces
resolve the label via
homepage/src/lib/displayLabel.ts,
so this is the endpoint to call when an operator wants to rename a
module without re-flashing it.
Status codes:
- 200 — success. Body echoes
{ id, display_name, message }with the newly-stored value (ornullif cleared). - 400 — body missing the
display_namekey, or value is not a string ornull, or exceeds 100 chars. - 401 — no valid admin session cookie or
X-Admin-Key. - 404 — module id is well-formed but not registered.
- 409 — another module already holds this
display_name. Body:{ error, display_name, conflicting_module_id }. The homepage'sRenameModuleModalsurfaces the conflicting module's leading 4 hex inline so the operator can pick a different name. - 502 —
duckdb-serviceunreachable.
Passing an empty/whitespace string is treated as null (clears the
override), matching the modal's "leave empty to clear" UX.
GET /api/modules/:id/logs?limit=10
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Proxies image-service /modules/<mac>/logs and returns telemetry
sidecar entries newest-first. Returns 401 if no valid admin session
cookie or X-Admin-Key is supplied, 502 if the image-service is
unreachable.
[
{
"mac": "aabbccddeeff",
"received_at": "2026-05-07T12:00:00",
"image": "esp_capture_20260507_120000.jpg",
"payload": {
"fw": "1.0.0",
"uptime_s": 72145,
"last_reset_reason": "TASK_WDT",
"last_stage_before_reboot": "setup:getGeolocation",
"free_heap": 124352,
"min_free_heap": 98211,
"rssi": -67,
"wifi_reconnects": 2,
"last_http_codes": [200, 200, 500, 200, 200],
"log": "[BOOT] fw=1.0.0 ..."
}
}
]The shape is the typed envelope dumped from image-service/services/sidecar.py's
LogSidecarEnvelope: service-injected metadata at the top level (mac,
received_at, image), the raw ESP telemetry nested under payload.
Pre-envelope sidecars on disk are read-compat and reshape into the same
envelope on the way out. The TypeScript contract is TelemetryEntry in
contracts/src/index.ts.
Inside payload, last_stage_before_reboot is optional. The firmware
emits it only when the previous boot's RTC_NOINIT breadcrumb survived
(i.e. the previous boot ended in a software reset — TASK_WDT, panic,
ESP.restart — rather than a clean exit or a power-on). Sidecars produced
by firmware that pre-dates the field continue to validate; admin UI
consumers should treat the field as missing when absent, not error.
Diagnostic mechanism for issue #42 — see
06-runtime-view/esp-reliability.md "Stage breadcrumb".
The telemetry section in the dashboard is hidden unless the URL has
?admin=1; see 06-runtime-view/esp-reliability.md for the
end-to-end admin flow.
GET /api/admin/logs?service=backend|duckdb-service|image-service&lines=N
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Tails a service's own recent log entries — distinct from §1.5 (per-module
ESP telemetry). Each service keeps a ring of structured { ts, level, msg }
entries (a stdout/stderr tee plus a structured logger); the backend serves its
own ring and proxies to the two Flask services' internal /logs, forwarding the
machine credential. Every handled request adds one access entry
(method path status ms, level by status: ≥500 error, ≥400 warn, else info)
— logged path-only, never headers, body, or query string, so no secret
reaches the ring. service must be one of the three names (others, incl.
nginx, return 400). lines defaults to 200 and is clamped to [1, 1000].
Returns 401 without a valid admin credential, 502 if a proxied service is
unreachable or returns a drifted envelope. Design + caveats:
ADR-021.
{
"service": "duckdb-service",
"entries": [
{
"ts": "2026-06-18T20:42:55.123Z",
"level": "info",
"msg": "[heartbeat] mac=aabbccddeeff battery=None rssi=-67 …"
},
{ "ts": "2026-06-18T20:42:56.004Z", "level": "info", "msg": "POST /heartbeat 200 3ms" }
],
"truncated": false
}entries is chronological (oldest→newest, like tail); each carries an ISO 8601
ts, a level (info | warn | error), and the msg. truncated is true
when the ring held more than were returned. The TypeScript contract is
LogEntry / ServerLogsResponse in contracts/src/index.ts.
GET /api/admin/logs/stream?service=backend|duckdb-service|image-service
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Accept: text/event-stream
Server-Sent Events live tail (#178 / ADR-023). After the REST GET /api/admin/logs
backfill, the panel opens this for "tail -f": each new log entry arrives as one
data: event whose payload is a single LogEntry JSON ({ ts, level, msg });
: ping comments keep the connection alive. service validation, the admin gate,
and the cross-service X-Admin-Key proxy match the REST endpoint (backend
streams its own ring; the Flask services are piped from their internal
/logs/stream). The response sets X-Accel-Buffering: no; the host-nginx vhost
must also set proxy_buffering off for this location (see
production-deployment.md).
: connected
data: {"ts":"2026-06-18T20:42:56.004Z","level":"info","msg":"POST /heartbeat 200 3ms"}
: ping
The admin Server Logs panel consumes both: a live indicator reflects the SSE
connection, follow-mode auto-scrolls (pausing when you scroll up), and the loaded
entries can be searched, filtered by level, and exported to a plain .log —
all client-side, no extra endpoints.
GET /api/modules/:id/heartbeat-gaps?limit=50
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Proxies duckdb-service GET /heartbeats/<id>/gaps and returns the
silent windows the device itself cannot report (#172 option 3). The
hourly heartbeat fires ~1×/h; a failed/timed-out one never reaches the
server, so HeartbeatSnapshot.lastHbFailCount only covers streaks the
device lived through and recovered from. This endpoint derives the gaps
the server didn't hear about from the module_heartbeats.received_at
timeline (a LAG window function), returning intervals wider than ~90 min,
newest first. Read-only: no table, no writer (see
ADR-025).
The admin gate runs first, so an unauthenticated request returns 401 even with
a malformed id; an authenticated request with a malformed module id returns
400; 502 if duckdb-service is unreachable or returns a malformed shape.
{
"gaps": [
{ "gapStart": "2026-06-01T02:00:00", "gapEnd": "2026-06-01T06:00:00", "gapSeconds": 14400 }
]
}The TypeScript contract is HeartbeatGap in
contracts/src/index.ts; the upstream
duckdb-service shape is snake_case (gap_start/gap_end/gap_seconds),
camelCased by the backend proxy.
GET /api/user-location
Public — no auth (#142).
Returns a coarse, IP-based location guess used by the dashboard to
centre the map near the visitor on first load (issue #14). Accuracy is
~10–50 km — city-level, not GPS-precise. Precise location still comes
from the in-map "show my location" button which calls
navigator.geolocation.getCurrentPosition() in the browser.
The visitor's IP is resolved from req.ip, honouring X-Forwarded-For
only when the immediate hop comes from a trusted private network range
(Express trust proxy = 'loopback, linklocal, uniquelocal').
On success:
{ "lat": 52.52, "lng": 13.405 }Accuracy is implicitly city-level (~10–50 km — the documented IP-geo band). The wire shape deliberately does not include a precision field: ipapi.co does not publish a per-IP accuracy number, and no consumer currently renders one. If a future view needs to surface an explicit "± N km" annotation, add a field then; don't pre-allocate constant-shaped metadata.
Non-success status codes are part of the contract; the homepage treats both as "no hint" and falls back to the default centre:
- 204 No Content — the visitor's IP resolved to a loopback, RFC-1918, or IPv6 ULA address. Common in dev. No upstream call was made.
- 503 Service Unavailable — the upstream IP-geolocation provider (ipapi.co, free tier) returned a non-2xx or a 200-with-error-flag rate-limit response. The endpoint deliberately does NOT swallow the failure to a 200-with-null body; see ADR-012.
Successful lookups are cached in-process per IP for 1 hour. The cache is per-replica — multi-replica deployments amortise to one upstream call per replica per visitor per hour.
Why a backend proxy rather than reusing GEO_API_KEY directly:
ADR-012.
GET /api/modules/:id/activity?interval=hourly&days=7
Public — no auth (#142).
Bucketed image-upload counts for a single module, used by the dashboard
ActivityWeatherChart to overlay activity against Open-Meteo weather
at the module's lat/lng. Maps to the duckdb-service
/modules/<id>/activity_timeseries route (see §3.10) and rewrites the
snake_case wire to the camelCase ActivityTimeSeries shape pinned in
contracts/src/index.ts.
Query parameters (both optional, with sensible defaults):
interval—hourly(default) ordaily. Buckets coarser than hourly skip the weather overlay client-side (Open-Meteo only publishes hourly observations).days— look-back window. Default7, range[1, 90].
Empty buckets are filled server-side with count: 0. The chart
renders a continuous timeline rather than stitching across silent
hours, which would visually misrepresent a quiet hive as a spike on
either side of the gap.
{
"moduleId": "aabbccddeeff",
"interval": "hourly",
"start": "2026-05-13T00:00:00",
"end": "2026-05-20T00:00:00",
"buckets": [
{ "timestamp": "2026-05-13T00:00:00", "count": 0 },
{ "timestamp": "2026-05-13T01:00:00", "count": 3 }
]
}Timestamps are UTC ISO 8601, bucket-start. The homepage formats them to
the visitor's browser locale at render time (see
08-crosscutting-concepts/api-contracts.md
for the timezone reasoning).
Error responses bubble verbatim from duckdb-service:
400— invalid module id, unknowninterval, ordaysoutside[1, 90].404— module unknown.502— duckdb-service unreachable.
GET /api/modules/:id/snips
GET /api/snips/:filename
Public — no auth (#165, ADR-026). Snips are cropped to the hole only, so they carry no garden/house background and need no credential (the privacy mechanism of #154).
GET /api/modules/:id/snips proxies duckdb-service GET /detections (§3.15)
and maps the snake_case rows to the camelCase NestSnip shape pinned in
contracts/src/index.ts. One entry per nest hole —
the latest detection per (beeType, nestIndex). Malformed rows (unknown bee
type / state) are dropped rather than forwarded.
{
"snips": [
{
"beeType": "leafcutter",
"nestIndex": 1,
"state": "sealed",
"confidence": 0.91,
"snipFilename": "esp_cap_123-leafcutter-1.jpg",
"bbox": [0.41, 0.18, 0.18, 0.27],
"sourceFilename": "esp_cap_123.jpg",
"detectedAt": "2026-06-23 12:00:05"
}
]
}GET /api/snips/:filename proxies the snip JPEG bytes from image-service
GET /snips/:filename (§2.5). Resolve a snipFilename to its URL with
api.getSnipUrl(...) on the homepage, mirroring getImageUrl.
Errors: 400 invalid module id; 502 duckdb-service / image-service
unreachable; 404 snip not found (bytes route).
GET /api/modules/:id/snips/history
Public, like the grid read. Where /snips returns one row per nest from the
module's latest capture, this returns every nest of every capture, oldest
first, so the dashboard can scrub the whole block across days with one slider
under NestSnipGrid — dragging it swaps all holes at once to the chosen
capture's crops. Proxies duckdb-service GET /detections/history (§3.15) and
maps to the same NestSnip element shape as /snips; the homepage buckets the
flat list by sourceFilename into per-capture frames
(NestSnipHistoryResponse in contracts/src/index.ts).
A re-uploaded capture (network retry) is deduped to one row per
(filename, bee_type, nest_index).
{
"snips": [
{
"beeType": "leafcutter",
"nestIndex": 1,
"state": "empty",
"snipFilename": "...-2026-06-01.jpg",
"detectedAt": "2026-06-01 12:00:00",
"confidence": 0.92,
"bbox": [0.42, 0.3, 0.16, 0.16],
"sourceFilename": "..."
},
{
"beeType": "leafcutter",
"nestIndex": 1,
"state": "sealed",
"snipFilename": "...-2026-06-26.jpg",
"detectedAt": "2026-06-26 12:00:00",
"confidence": 0.92,
"bbox": [0.42, 0.3, 0.16, 0.16],
"sourceFilename": "..."
}
]
}Errors: 400 invalid module id (rejected before hitting upstream); 502
duckdb-service unreachable.
GET /api/modules/:id/measurements?metric=battery_pct&interval=hourly&days=7
POST /api/modules/:id/measurements
Headers: (GET) none — public read (#142)
(POST) Cookie: hf_admin_session=… or X-Admin-Key: <HIGHFIVE_API_KEY>
Per-module bucketed time-series read against the canonical
measurements store (issue #110). Maps to the duckdb-service
/modules/<id>/measurements and /measurements routes (see §3.11
and §3.12) and rewrites the snake_case wire to the camelCase
MeasurementTimeSeries shape pinned in
contracts/src/index.ts.
Query parameters:
metric— required. One of the metric strings the producers emit (battery_pcttoday; future:temperature_c,activity_score,rssi_dbm, …). See the glossary for the canonical list.interval—hourly(default) ordaily.days— look-back window. Default7, range[1, 90].
Empty buckets carry value: null and sampleCount: 0 — NOT value: 0. A missing sensor reading is unknown, not zero; the homepage chart
renders null as a break in the line so a silent device doesn't
read as a flat-line discharge.
{
"moduleId": "aabbccddeeff",
"metric": "battery_pct",
"interval": "hourly",
"start": "2026-05-13T00:00:00",
"end": "2026-05-20T00:00:00",
"buckets": [
{ "timestamp": "2026-05-13T00:00:00", "value": null, "sampleCount": 0 },
{ "timestamp": "2026-05-13T01:00:00", "value": 87.5, "sampleCount": 2 }
]
}Bucket value is AVG(measurements.value) across all rows landing in
the bucket; sampleCount is the row count behind the average.
Errors:
400— invalid module id, missingmetric, unknowninterval,daysoutside[1, 90].404— module unknown.502— duckdb-service unreachable.
Body shape — single:
{
"ts": "2026-05-20T12:00:00Z",
"metric": "temperature_c",
"value": 18.4,
"source": "weather-api"
}Body shape — batched (≤ 1000 rows):
{
"measurements": [
{"ts": "...", "metric": "...", "value": 1.0, "source": "..."},
...
]
}The backend forces module_mac to match the path; a body-supplied
module_mac is ignored. Returns {"inserted": N} on success.
Errors:
400— invalid body, batch > 1000 rows, missing/oversized field, non-finitevalue, malformedts.401— no valid admin session cookie orX-Admin-Key.502— duckdb-service unreachable.
Intended producers: weather worker (#111), classifier (#112).
Heartbeat-side battery does NOT go through this proxy — it
dual-writes directly from duckdb-service/routes/heartbeats.py.
See ADR-016
for the rationale.
POST /api/admin/weather/backfill?days=N
Headers: Cookie: hf_admin_session=… # or X-Admin-Key: <HIGHFIVE_API_KEY>
Trigger a one-shot historical weather backfill for every module with
a plausible lat/lng. Operator command, expected to be run once
per deployment after a new module's geolocation lands or after a
fresh dev volume is seeded. Implementation:
duckdb-service/services/weather_worker.py's run_weather_backfill.
Query parameters:
days— optional integer, range[1, 36500]. When omitted, each module's window starts at itsmodule_configs.first_onlineso the full history is covered. Withdays=N, all modules start atnow - N days. The upper bound is alwaysnow - 5 days(the Open-Meteo Archive API is ERA5-backed and trails real time by ~5 days; hours more recent than that are filled by the live hourly worker).
Response (200 OK):
{
"modules_touched": 5,
"rows_written": 87600,
"errors": []
}errors is a list of {module_mac, error} objects when one module
fails — partial success is the explicit contract for an admin
endpoint, so a single module's API failure does not invalidate the
rows already written for the others. A request that completes with
non-empty errors still returns 200; the caller inspects the array.
Concurrent invocations: a second POST arriving while the first is
still running returns 200 immediately with a single sentinel error
{"module_mac": null, "error": "backfill already in progress"} and
no rows written. Two parallel runs would each read the same
existing ts dedup set and silently double-write chunks (the
measurements table has no UNIQUE constraint per
ADR-016),
so the worker fails-fast rather than racing.
The endpoint remains reachable even when WEATHER_WORKER_ENABLED
is false — the env var controls the scheduled hourly tick only;
the operator-initiated admin path is always available so a stack
with the live worker intentionally off can still trigger a one-shot
historical import.
Status codes:
- 200 — request completed (possibly partially; check
errors). Also 200 for the "backfill already in progress" sentinel — the request was accepted but no work was done. - 400 —
daysquery param is non-integer or out of range. - 401 — no valid admin session cookie or
X-Admin-Key. - 502 — duckdb-service unreachable.
The endpoint runs synchronously and may take seconds to minutes
depending on days and the number of modules — Open-Meteo's
Archive endpoint serves the data fast, but each module is a separate
HTTP call. Triggering it from a script is fine; do not put it behind
a single page-load click without a spinner. See
ADR-017
and
weather-worker-flow.md
for the rationale and the live-worker counterpart.
Base URL: http://localhost:8000 (container port 4444).
GET /health
{ "ok": true, "service": "image-service" }Liveness only — does not verify DuckDB connectivity. Use the
duckdb-service /health for that.
POST /upload
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
image |
File | Yes | Captured JPEG |
mac |
Text | Yes | Module identifier |
battery |
Text | Yes | Integer 0–100 |
logs |
Text | No | JSON telemetry payload (see esp-reliability) |
If logs is present and parseable, it is saved to {image_path}.log.json
in LogSidecarEnvelope format: {mac, received_at, image, payload: {…}}.
Unparseable payloads are still saved as { "raw": ..., "parse_error": true, ... }.
Response:
{
"message": "Image hive_image.jpg uploaded successfully",
"mac": "esp-9081726354",
"battery": 67,
"classification": {
"black_masked_bee": { "1": 1, "2": 0, "3": 1, "4": 0 },
"leafcutter_bee": { "1": 1, "2": 1, "3": 0, "4": 1 },
"orchard_bee": { "1": 0, "2": 1, "3": 1, "4": 0 },
"resin_bee": { "1": 1, "2": 1, "3": 1, "4": 0 }
}
}The classifier is currently a stub returning random 0/1 values.
GET /modules/<mac>/logs?limit=N
Reads *.log.json sidecars on disk, filters by mac (envelope field),
sorts by mtime descending, and returns the newest N (default 10, max 100).
Used by the backend admin proxy in section 1.4.
GET /images?module_id=<mac>&limit=N&offset=M
Proxies duckdb-service GET /image_uploads (§3.14) verbatim. The
backend exposes this unchanged at GET /api/images, which the admin
image gallery (homepage/src/pages/AdminPage.tsx) calls — it loads the
newest PAGE_SIZE (5) and reveals the rest via "Load more".
Query parameters, all optional:
module_id— canonical or colon-/dash-separated MAC; filters to one module (canonicalised server-side in duckdb).limit— page size, clamped to[1, 500]. Omit to return all rows (back-compat); a malformed value degrades to the 500 cap, never to unbounded.offset— rows to skip (≥0), for "Load more" pagination.
Response is the { images, total } envelope (newest-first):
{
"images": [
{
"module_id": "aabbccddeeff",
"filename": "esp_capture_…jpg",
"uploaded_at": "2026-06-03 10:00:06"
}
],
"total": 15352
}total is the full count matching module_id, ignoring limit/offset
— the UI compares images.length < total to decide whether to keep the
"Load more" button. Ordering is uploaded_at DESC, id DESC (deterministic;
see §3.14). Proxied at a 15s read timeout — never proxy an un-paginated
list across a short timeout (chapter 11 "failed to load images").
The TypeScript wire type is ImageUploadsPage in
contracts/src/index.ts; see
api-contracts.md for the
backend↔homepage contract.
GET /snips/<filename>
Serves a cropped per-nest snip JPEG from the snip folder
(IMAGE_STORE_PATH/snips/). Public, like GET /images/<filename> — the crop
removes all background (#154). The backend re-exposes this at
GET /api/snips/:filename (§1.6b). Snips are produced on /upload by the
learned HoleDetector (YOLO26n-seg via ONNX, ADR-027) and recorded via duckdb
POST /record_detections (§3.15). 404 when the snip file is absent.
Base URL: http://localhost:8002 (container port 8000).
GET /health
{ "ok": true, "db": "/data/app.duckdb" }POST /new_module
Content-Type: application/json
{
"esp_id": "b0696ef23a08",
"module_name": "Garden-Hive",
"latitude": 48.52137,
"longitude": 9.05891,
"battery_level": 72
}esp_id is the canonical 12-char lowercase-hex form of the eFuse MAC. Legacy colon-separated and uppercase-hex inputs (e.g. AA:BB:CC:DD:EE:FF) are accepted and canonicalised; raw uint64 decimal stringification (~15 digits) is rejected with HTTP 400 — see issue #39.
Returns:
{ "id": "b0696ef23a08", "name": "Garden-Hive", "message": "Module added successfully" }The response echoes the actually-stored name. If another module
already holds the requested module_name, the server auto-suffixes
(Garden-Hive-2, Garden-Hive-3, …, capped at -99) — the echoed
value is the disambiguated form so the firmware can observe it.
A module with the same identifier is replaced.
Validation errors (HTTP 400):
module_namelonger than 100 chars — bounded by the Pydantic entry- point model so a non-colliding 200-char name cannot reach the DB (DuckDB does not enforceVARCHAR(N)lengths on its own).esp_idnot 12 lowercase hex chars after canonicalisation — see issue #39.battery_leveloutside[0, 100].
GET /modules
Returns the raw DB rows under modules:
{
"modules": [
{
"battery_level": 72,
"first_online": "Wed, 11 Mar 2026 00:00:00 GMT",
"id": "esp-9081726354",
"lat": "48.52137",
"lng": "9.05891",
"name": "Garden-Hive"
}
]
}GET /nests
{
"nests": [{ "nest_id": "nest-001", "module_id": "hive-001", "beeType": "blackmasked" }]
}GET /progress
{
"progress": [
{
"progress_id": "prog-001",
"nest_id": "nest-001",
"date": "Sat, 01 Jun 2024 00:00:00 GMT",
"empty": 5,
"sealed": 45,
"hatched": 15
}
]
}progress_id and hatched are spelled correctly (a recent fix
corrected legacy progess_id / hateched).
POST /add_progress_for_module
Content-Type: application/json
{
"module_id": "aabbccddeeff",
"classification": {
"black_masked_bee": { "1": 1, "2": 1, "3": 0 },
"orchard_bee": { "1": 0, "2": 1, "3": 1 }
}
}Returns { "success": true }. Missing nests are auto-created. Progress
rows are inserted with the current date. The legacy typo modul_id is
still accepted via AliasChoices on
duckdb-service/models/progress.py's ClassificationOutput as a
deprecation window — see
08-crosscutting-concepts/api-contracts.md.
POST /heartbeat
Content-Type: application/x-www-form-urlencoded
Form fields:
| Field | Type | Notes |
|---|---|---|
mac |
string | accepted in canonical 12-hex form, colon-separated, or dash-separated; canonicalised on the server (or esp_id alias) |
battery |
int | optional |
rssi |
int | optional, dBm |
uptime_ms |
int | optional, since last boot |
free_heap |
int | optional, bytes |
fw_version |
string | optional, ≤40 chars (a bee-name from ESP32-CAM/VERSION; see ADR-006) |
reset_reason |
string | optional, ≤16 chars (#148) — the device's resetReasonStr(esp_reset_reason()): POWERON, BROWNOUT, TASK_WDT, PANIC, SW, … Lifted onto the heartbeat (it was previously only in the per-upload telemetry sidecar) so a crash-looping or hung module — which never reaches the daily image upload — still reports why it reset on its very next hourly heartbeat. |
min_free_heap |
int | optional, bytes (#148) — ESP.getMinFreeHeap(), the heap low-water mark since boot. A steadily-falling value across boots is the heap-leak signature. |
boot_count |
int | optional (#148) — the NVS-backed monotonic reboot counter (getBootCount()). Climbing while uptime_ms stays seconds-low across heartbeats is the boot-loop / hang signature. |
last_hb_fail_code |
int | optional (#172) — the return value of the most recent failed heartbeat: -2 = connect/WiFi-down, -4 = unparseable status line, otherwise the raw non-2xx HTTP code. 0 when there is no current streak. Sent on every heartbeat by #172+ firmware; omitted (→ NULL) by older firmware. |
last_hb_fail_count |
int | optional (#172) — consecutive heartbeat failures since the last 2xx (0 when healthy). The hourly heartbeats fail between boots and never reach the server (no 2xx), so this streak is carried forward and reported on the next 2xx heartbeat (typically the boot heartbeat after a livenessReboot). A non-zero value on an otherwise-online module is the #170 reboot-loop signature made remotely visible. Sent densely (0 when no streak, not omitted) so the /heartbeats_summary ARG_MAX fold — which ignores NULL rows — reflects the latest heartbeat instead of latching a stale streak after recovery. Persisted across software resets in RTC memory (ESP32-CAM/lib/hb_failure). |
last_stage_before_reboot |
string | optional, ≤64 chars (#172 option 2) — the RTC breadcrumb recovered at boot naming which long-running stage was active when the previous run died (loop:livenessReboot, setup:getGeolocation, …). Previously rode only the per-upload telemetry sidecar (the noon image), so after a watchdog reboot it could be up to 24 h late; carrying it on the boot heartbeat surfaces it immediately. Sent densely ("" when no breadcrumb survived) like reset_reason; omitted (→ NULL) by firmware predating option 2. |
latitude |
float | optional — geolocation-recovery field; only sent by firmware when its boot-time getGeolocation failed and the deferred retry has since succeeded (PR II / issue #89). Must be in [-90, 90] to be accepted. |
longitude |
float | optional, paired with latitude. Must be in [-180, 180]. |
accuracy |
float | optional, paired with latitude/longitude. Must be > 0 (Google's "no fix" response is accuracy: 0, which the server treats as not-a-fix). |
The mac field is canonicalised to lowercase 12-hex via
ModuleId.model_validate(...) before the INSERT, mirroring the
/upload seam in image-service/app.py. Two clients sending
AA:BB:CC:DD:EE:FF and aabbccddeeff therefore land on the same
module_id PK rather than silently creating parallel rows.
Returns { "ok": true }, 200. Missing mac returns
{ "error": "missing mac" }, 400. A mac value that does not
reduce to [0-9a-f]{12} returns { "error": "invalid mac format" },
400.
Side effects: a single INSERT into module_heartbeats. The
handler also UPDATEs module_configs.lat/lng (PR II / issue
#89) — but ONLY when ALL of the following are true:
- The heartbeat carries plausible
latitude/longitude/accuracy(the_is_plausible_fixrule: not(0,0), not NaN, not out of range,accuracy > 0). - The existing
module_configsrow sits at the(0,0)sentinel. A deliberately-placed module is never clobbered — the rule is "only patch from (0,0)".
The handler does not touch module_configs.updated_at (that
column has dual semantics — see chapter-11 "updated_at semantic
overload" / issue #97). Implementation in the heartbeat route of
duckdb-service/routes/heartbeats.py.
This is the telemetry heartbeat fired hourly by firmware's
sendHeartbeat in ESP32-CAM/client.cpp. It is distinct from the post-upload
aggregate at POST /modules/<id>/heartbeat below — same word, different
endpoint, different body, different table. See
../12-glossary/README.md "Heartbeat (telemetry)"
vs "Heartbeat (post-upload aggregate)".
POST /modules/<module_id>/heartbeat
Content-Type: application/json
{ "battery": 87 }| Field | Type | Notes |
|---|---|---|
battery |
int | required, 0-100 |
Returns { "ok": true }, 200. Missing/invalid battery returns
{ "error": "battery must be an int in [0, 100]" }, 400. Unknown
module returns { "error": "Module not found" }, 404.
Side effect (single UPDATE on module_configs):
battery_level← supplied valueimage_count←image_count + 1first_online←COALESCE(first_online, today)— only filled on the first call after a NULL; in practice the column is written byadd_moduleat registration and the heartbeat leaves it alone (issue #75)
Does not insert into module_heartbeats. Called by image-service
after every accepted upload (image-service/services/duckdb.py's
heartbeat). Implementation: duckdb-service/routes/modules.py's
heartbeat.
POST /record_image
Content-Type: application/json
{ "module_id": "aabbccddeeff", "filename": "esp_capture_20260511_143022.jpg" }| Field | Type | Notes |
|---|---|---|
module_id |
string | canonicalised on the server via ModuleId.model_validate(...); colon- and dash-separated MACs both accepted |
filename |
string | filename of the persisted image on the shared duckdb_data volume (image-service writes the bytes; this writes the row) |
Returns { "message": "Image recorded" }, 200. Missing either field
returns { "error": "module_id and filename required" }, 400. An
invalid module_id (does not reduce to [0-9a-f]{12} — e.g. raw
uint64 decimal stringification per the §3.2 rule) returns
{ "error": "invalid module id" }, 400.
Side effect: a single INSERT into image_uploads with module_id,
filename, and a server-stamped uploaded_at. The admin /api/images
listing and the dashboard's last_image_at column on /api/modules
both join on this table.
Called by image-service after every successful _persist_image step
(image-service/services/upload_pipeline.py's
_record_image_upload). The image bytes themselves are written
locally; this endpoint is what makes the upload visible to the rest of
the stack. Implementation: duckdb-service/routes/modules.py's
record_image.
GET /modules/<module_id>/activity_timeseries?interval=hourly&days=7
Bucketed image-upload counts for the dashboard ActivityWeatherChart.
The backend's /api/modules/:id/activity (§1.6) proxies this route and
renames the top-level module_id field to moduleId on the way out;
nested fields are camelCase already.
Query parameters:
interval—hourly(default) ordaily. Any other value returns400.days— integer in[1, 90], default7. Out-of-range or non-integer returns400.
Empty buckets are filled server-side with count: 0 so consumers
render a continuous timeline. Bucket-start timestamps are UTC ISO 8601.
{
"module_id": "aabbccddeeff",
"interval": "hourly",
"start": "2026-05-13T00:00:00",
"end": "2026-05-20T00:00:00",
"buckets": [
{ "timestamp": "2026-05-13T00:00:00", "count": 0 },
{ "timestamp": "2026-05-13T01:00:00", "count": 3 }
]
}Error responses:
400— invalid module id, invalidinterval, ordaysout of range.404— module unknown.
Implementation: duckdb-service/routes/modules.py's
activity_timeseries. Source table is image_uploads, filtered by
module_id and aggregated via date_trunc('hour' | 'day', uploaded_at). Adding a third granularity means a matching entry in
INTERVAL_STEP (in routes/_bucketing.py) and a new branch in the
date_trunc positional argument — both wired by the same interval
query param.
GET /modules/<module_id>/measurements?metric=battery_pct&interval=hourly&days=7
Bucketed read against the per-module measurements table (issue
#110). The backend's /api/modules/:id/measurements (§1.7) proxies
this route and rewrites module_id → moduleId, sample_count →
sampleCount.
Query parameters:
metric— required.interval—hourly(default) ordaily.days—[1, 90], default7.
Empty buckets emit value: null and sample_count: 0 (NOT value: 0). Bucket value is AVG(value).
{
"module_id": "aabbccddeeff",
"metric": "battery_pct",
"interval": "hourly",
"start": "2026-05-13T00:00:00",
"end": "2026-05-20T00:00:00",
"buckets": [
{ "timestamp": "2026-05-13T00:00:00", "value": null, "sample_count": 0 },
{ "timestamp": "2026-05-13T01:00:00", "value": 87.5, "sample_count": 2 }
]
}Implementation: duckdb-service/routes/measurements.py's
get_measurements. Shares bucketing helpers with §3.10 via
routes/_bucketing.py. Uses the same ::TIMESTAMP cast on the
date_trunc result — see the chapter 11 entry "date_trunc('day', ts) returns DATE not TIMESTAMP" for the incident.
POST /measurements
Append one or a batch of measurement rows. No service-level auth —
network-internal only (the backend proxy is the public boundary, and
gates with X-Admin-Key).
Body — single:
{
"module_mac": "aabbccddeeff",
"ts": "2026-05-20T12:00:00Z",
"metric": "temperature_c",
"value": 18.4,
"source": "weather-api"
}Body — batched (≤ 1000):
{
"measurements": [
{"module_mac": "...", "ts": "...", "metric": "...", "value": 1.0, "source": "..."},
...
]
}Response (200 OK):
{ "inserted": 2 }Validation rejects the entire batch on any item failure (400 with
the failing item's index in the response body). Implementation:
duckdb-service/routes/measurements.py's post_measurements.
POST /admin/weather/backfill?days=N
No service-level auth — internal only, the backend's
POST /api/admin/weather/backfill (§1.8) is the public boundary and
gates with X-Admin-Key. Calls
duckdb-service/services/weather_worker.py's run_weather_backfill
synchronously and returns counts.
Do not expose duckdb-service's port 8002 publicly. The dev
docker-compose.yml binds 0.0.0.0:8002 for development
convenience; in production the host firewall must restrict the port
to the backend's reverse-proxy origin. Without that boundary, any
host-network caller could trigger arbitrary outbound Open-Meteo
fetches and large writes by hitting this route directly.
Query parameter:
days— optional integer in[1, 36500]. Omitted → sincemodule_configs.first_onlineper module. Present → uniformnow - daysstart for all modules.
Response shape (200 OK), matching §1.8:
{ "modules_touched": 5, "rows_written": 87600, "errors": [] }Errors:
400—daysnon-integer or out of range.
A partial failure (some modules' Open-Meteo calls fail mid-run) is
reported in the errors array, NOT a non-2xx status. The endpoint
distinguishes "the request itself was bad" (400) from "the work was
attempted but some modules failed" (200 with errors).
GET /image_uploads?module_id=<mac>&limit=N&offset=M
Newest-first list of image_uploads rows, paginated. Proxied by
image-service GET /images (§2.4) and backend GET /api/images; backs
the admin gallery.
Query parameters, all optional:
module_id— filter to one module; canonicalised via_canonicalize_or_400(a non-canonicalisable value →400).limit— page size, clamped to[1, 500]. Omit → all rows (back-compat). A malformed value degrades to the500cap, never to the unbounded query.offset— rows to skip (≥0).
{
"images": [
{
"module_id": "aabbccddeeff",
"filename": "esp_capture_…jpg",
"uploaded_at": "2026-06-03 10:00:06"
}
],
"total": 15352
}total is the count matching module_id, ignoring limit/offset.
Ordering is ORDER BY uploaded_at DESC, id DESC — newest capture first,
with the monotonic id (insertion sequence) as a stable tiebreaker so
two rows sharing a second-resolution uploaded_at cannot duplicate or
skip across pages. Implementation: duckdb-service/routes/modules.py's
list_image_uploads. The unbounded variant (omitted limit) is slow on
a large table — see chapter 11 "failed to load images"; callers should
always paginate.
POST /record_detections
GET /detections?module_id=<mac>
GET /detections/history?module_id=<mac>
Per-nest hole-detection rows + snips (#165, ADR-026). duckdb-service is the
sole writer (ADR-001), so image-service POSTs here after cropping snips on
/upload; the read backs the public snip grid via backend GET /api/modules/:id/snips (§1.6b).
POST /record_detections body:
{
"module_id": "aabbccddeeff",
"filename": "esp_cap_123.jpg",
"detections": [
{
"bee_type": "leafcutter",
"nest_index": 1,
"bbox": [0.41, 0.18, 0.18, 0.27],
"state": "sealed",
"confidence": 0.91,
"snip_filename": "esp_cap_123-leafcutter-1.jpg"
}
]
}Rows with an invalid state (not empty/sealed/undetermined) or missing
snip_filename are skipped, not fatal — one bad item can't reject the capture.
The learned detector emits undetermined (it localizes but defers empty/sealed,
ADR-027). Returns
{"message": "...", "inserted": N}. bee_type is the canonical DB form
(blackmasked/resin/leafcutter/orchard), matching nest_data.beeType.
GET /detections?module_id=<mac> returns the latest detection per
(bee_type, nest_index) (ROW_NUMBER() … ORDER BY detected_at DESC, id DESC) —
full history is retained in nest_detections but the read folds to current
state for the dashboard. bbox is reassembled to [x, y, w, h] (normalized).
400 on missing/invalid module_id; empty {"detections": []} for a module
with no detections yet. Implementation: duckdb-service/routes/detections.py.
GET /detections/history?module_id= is the inverse fold for the #166 global
time-lapse: every nest of every capture, oldest first
(ORDER BY detected_at ASC, filename, bee_type, nest_index), deduped to one row
per (filename, bee_type, nest_index) (a re-uploaded capture via
ROW_NUMBER() … PARTITION BY filename, bee_type, nest_index ORDER BY id DESC).
Same {"detections": [...]} row shape as the grid read; the homepage groups by
filename into per-capture frames. 400 on missing or invalid module_id;
empty list when the module has no captures.
Served directly from homepage/public/ by the homepage's static
asset path (Vite dev server in dev, host-nginx in prod). No
authentication — same exposure as any other homepage static.
Consumed by the web installer (merged bin) and by ESP32-CAM modules
running OTA-capable firmware (app-only bin + manifest). All three
artifacts are regenerated by bash ESP32-CAM/build.sh from the
current ESP32-CAM/VERSION value.
GET /firmware.json
Response (200 OK):
{
"version": "carpenter",
"md5": "1234567890abcdef1234567890abcdef",
"built_at": "2026-05-13T10:00:00+00:00",
"app_md5": "abcdef1234567890abcdef1234567890",
"app_size": 1048576
}version— bee-species name per ADR-006. Used by the OTA fetch path to decide whether to download.md5— MD5 of the mergedfirmware.bin(bootloader + partitions + boot_app0 + app). Used by the web installer for integrity check before flashing.built_at— ISO-8601 build timestamp.app_md5— MD5 of the app-onlyfirmware.app.bin. Used by the OTA fetch path;Update.setMD5()verifies this against the rolling MD5 computed during flash write. A mismatch leaves the inactive slot unbootable, no rollback needed.app_size— byte length offirmware.app.bin. The firmware rejects anapp_sizelarger than 1.9 MB at parse time as a defence against a malformed manifest.
GET /firmware.bin
Bootloader + partitions + boot_app0 + app, merged into one image.
Used by the web installer (/web-installer) to flash a blank ESP32-
CAM via the Chrome Web Serial API. Includes the partition table, so
this is the artifact that performs the first-time OTA migration
(default → min_spiffs layout) per
ADR-008.
GET /firmware.app.bin
Application image alone (no bootloader, no partitions). Used by the
firmware's boot-time OTA fetch path
(ESP32-CAM/ota.cpp's httpOtaCheckAndApply)
via Update.write(). Not flashable by the web installer — the web
installer needs the merged firmware.bin because a blank module has
no bootloader yet.
- (Once) Seed the DB — set
SEED_DATA=trueon the duckdb-service. - Field module boots and calls
POST /new_moduleagainstduckdb-service. - Module starts uploading via
POST /uploadtoimage-service(withlogs). image-servicewrites the image + sidecar, classifies (stub), and forwards toduckdb-service /add_progress_for_module.- Frontend reads
GET /api/modules+/api/modules/:idfrom the backend, which reads fromduckdb-service. - Operators inspect telemetry via
?admin=1on the dashboard, which callsGET /api/modules/:id/logswithX-Admin-Key.