What the FarmSense observer sends, so a separate service or agent can receive the live sensor data.
The observer already POSTs every reading to the FarmSense web app. It can also mirror each reading to a second endpoint you control — a secondary destination set on the Flash Observer page (Secondary endpoint URL + API key). Both destinations get the identical payload in parallel, and each has its own on-device retry queue, so a slow or down secondary never affects the primary.
The primary FarmSense ingest (api/ingest.php) is itself a reference implementation of this exact contract: check the key, parse the JSON, store it, return200.
docs/observer-webhook.agent.md in the repo) — MUST/SHOULD
requirements plus an acceptance-test matrix — and the machine-readable payload
schema: observer-webhook.schema.json.
This page is the prose spec for a person.
| Property | Value |
|---|---|
| Method | POST |
| URL | Whatever you set as the observer's Secondary endpoint URL |
| Scheme | https (recommended) or http — both supported |
| Content-Type | application/json |
| X-API-Key | The Secondary endpoint API key you set on the observer (see §5) |
| Body | One JSON reading object per request (see §2) |
| Success | Respond 2xx once the reading is durably accepted (see §4) |
| Frequency | One POST per sensor per poll interval (default 3600 s); N sensors → N POSTs |
The observer sends a single object per POST (not an array).
{
"node_id": "a1b2c3d4e5f6", // string, REQUIRED. Stable sensor id:
// first 12 hex chars of the sensor's
// mesh public key. ("sim-01" in sim mode.)
"node_name": "North Field", // string. Human name, or == node_id if unnamed.
"ts": "2026-08-23T15:42:10Z", // string, OPTIONAL. ISO-8601 UTC from the
// device clock. OMITTED if clock unset.
// UNRELIABLE — see §3.
"moisture_pct": { // object, REQUIRED, >= 1 entry. Soil moisture %.
"6in": 31.2, // Keys are depth labels: "6in","12in","18in".
"12in": 40.1, // Unmapped channels appear as "chN".
"18in": 45.0 // Values are numbers (percent).
},
"temp_c": { // object, OPTIONAL. Per-depth soil temp (°C).
"6in": 24.3, // Same depth keys as moisture_pct.
"12in": 22.1, // OMITTED entirely if no temps were read.
"18in": 20.8
},
"battery_v": 4.01 // number, OPTIONAL. Sensor battery voltage.
}
Assume when parsing:
node_id is always present and non-empty — treat it as the device key.moisture_pct always has ≥1 numeric depth, but which depths vary
(a one-probe sensor sends only "6in"). Don't assume all three exist.ts may be absent; when present it comes from a drifting device clock (see §3).temp_c may be absent, or hold a subset of the moisture depths.battery_v may be absent.{"node_id":"a1b2c3d4e5f6","node_name":"North Field","ts":"2026-08-23T15:42:10Z","moisture_pct":{"6in":31.2,"12in":40.1,"18in":45.0},"temp_c":{"6in":24.3,"12in":22.1,"18in":20.8},"battery_v":4.01}
{"node_id":"sim-01","node_name":"Sim Node","ts":"2026-08-23T15:42:10Z","moisture_pct":{"6in":25.4,"12in":34.8,"18in":41.9},"battery_v":3.90,"temp_c":{"6in":22.0,"12in":21.4,"18in":20.9}}
Sensor and observer clocks are frequently wrong (no battery-backed RTC; NTP
only once WiFi is up). The authoritative time for a reading is the moment
your endpoint received it. Stamp each reading with your own server time on
arrival and order/graph by that; keep ts only as an informational
"device-reported time." (This is exactly what the FarmSense app does: it stores
both ts and a server-stamped received_at, and the
dashboard shows received_at.)
2xx as soon as the reading is durably stored. Any 2xx counts; the body is ignored.2xx, or a transport/TLS/connection failure, is a failed
delivery. The reading goes on an on-device retry queue and is re-POSTed
later (FIFO), throttled to ~one flush attempt per 10 s while online.
401 is logged on the device as a key mismatch.(node_id, ts) when ts is
present, else on (node_id, receive-minute), or simply make your
write idempotent (upsert).Be fast. Acknowledge (2xx) quickly and do heavy work
asynchronously — a slow endpoint holds up the device's LoRa/upload loop and can
exceed its HTTP timeout, which looks like a failure and triggers a retry.
The observer sends the Secondary endpoint API key verbatim in the
X-API-Key header — a shared secret you choose on the flasher page.
hash_equals in PHP,
crypto.timingSafeEqual in Node) and reject mismatches with 401.https
(it uses setInsecure()). Present a normal cert, but authenticate with
the X-API-Key header, not client certs.const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json({ limit: "16kb" }));
const API_KEY = process.env.FARMSENSE_KEY || "";
function keyOk(sent) {
const a = Buffer.from(String(sent || ""));
const b = Buffer.from(API_KEY);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/hook", (req, res) => {
if (!keyOk(req.get("X-API-Key"))) return res.status(401).json({ error: "bad key" });
const r = req.body;
if (!r || typeof r.node_id !== "string" || !r.node_id ||
typeof r.moisture_pct !== "object" || !Object.keys(r.moisture_pct).length) {
return res.status(400).json({ error: "node_id + moisture_pct required" });
}
const received_at = new Date().toISOString(); // authoritative time (§3)
// TODO: store {...r, received_at} idempotently (§4)
console.log("reading", r.node_id, received_at, r.moisture_pct);
res.status(200).json({ ok: true });
});
app.listen(8080);
import hmac, os
from datetime import datetime, timezone
from flask import Flask, request, jsonify
app = Flask(__name__)
API_KEY = os.environ.get("FARMSENSE_KEY", "")
@app.post("/hook")
def hook():
if not hmac.compare_digest(request.headers.get("X-API-Key", ""), API_KEY):
return jsonify(error="bad key"), 401
r = request.get_json(silent=True) or {}
if not r.get("node_id") or not isinstance(r.get("moisture_pct"), dict) or not r["moisture_pct"]:
return jsonify(error="node_id + moisture_pct required"), 400
received_at = datetime.now(timezone.utc).isoformat() # authoritative time (§3)
# TODO: store {**r, "received_at": received_at} idempotently (§4)
print("reading", r["node_id"], received_at, r["moisture_pct"])
return jsonify(ok=True), 200
On the Flash Observer page:
https://your-service.example.com/hook). Leave it blank to disable.To verify, put the observer in sim mode (or press Poll now) and watch for a
sim-01 reading arriving at your endpoint.
Prove your receiver works before wiring the device by sending the exact payload yourself:
curl -X POST https://your-service.example.com/hook \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secondary-key" \
-d '{"node_id":"test-01","node_name":"Bench Test","moisture_pct":{"6in":30.0,"12in":38.5,"18in":44.2},"temp_c":{"6in":23.1,"12in":21.7,"18in":20.4},"battery_v":3.95}'
A correct receiver replies 2xx.
FarmSense v0.1.208