Observer webhook — receiver spec

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, return 200.
Building the receiver with a coding agent? Hand it the implementation brief (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.

1. What to listen for

PropertyValue
MethodPOST
URLWhatever you set as the observer's Secondary endpoint URL
Schemehttps (recommended) or http — both supported
Content-Typeapplication/json
X-API-KeyThe Secondary endpoint API key you set on the observer (see §5)
BodyOne JSON reading object per request (see §2)
SuccessRespond 2xx once the reading is durably accepted (see §4)
FrequencyOne POST per sensor per poll interval (default 3600 s); N sensors → N POSTs

The observer sends a single object per POST (not an array).

2. Payload schema

{
  "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:

Real example (three-probe node)

{"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}

Sim example (device in sim mode — same shape)

{"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}}

3. Timestamps — use your receive time

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.)

4. Responses, retries, and idempotency

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.

5. Authentication

The observer sends the Secondary endpoint API key verbatim in the X-API-Key header — a shared secret you choose on the flasher page.

6. Minimal reference receivers

Node.js (Express)

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);

Python (Flask)

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

7. Point the observer at your endpoint

On the Flash Observer page:

  1. Connect over USB and Read from device.
  2. Set Secondary endpoint URL to your receiver (e.g. https://your-service.example.com/hook). Leave it blank to disable.
  3. Set Secondary endpoint API key to the secret your endpoint checks.
  4. Save — it takes effect immediately, no reboot.

To verify, put the observer in sim mode (or press Poll now) and watch for a sim-01 reading arriving at your endpoint.

Quick smoke test with curl

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