FarmHome+ Device API (v1)
The device API is how a FarmHome+ hub or storage module (Fridge, Freezer, Pantry Dry, Pantry Root, MedCold, MedCabinet) sends data to a household. The simulator in scripts/device-simulator.mjs and real firmware use the same API.
Base URL: https://farm-home.nl/api/v1/device
1. Authentication
Each device has its own API key, shaped like fhd_<deviceId>_<secret>.
- Send it as
Authorization: Bearer <key>, or asX-Device-Key: <key>. - FarmHome+ stores only a SHA-256 hash of the key, and the owner sees it once.
- The household is taken from the key. A device can never choose or change its household.
- If a key is lost or leaked, the owner rotates it on the device page and the old key stops working at once.
- If the owner unpairs the device, its key is revoked and the API returns
401 DEVICE_REVOKED. - Rate limit: 240 requests per minute per device. Captures have a separate limit of 60 per hour.
2. Pairing
A. Code pairing (recommended for modules with a display or app)
- The module calls
POST /pairingwith no auth:
```json
{ "device_type": "fridge", "hardware_serial": "FH-FR-000123", "firmware_version": "1.0.0", "simulated": false }
`
The response is 201:
```json
{ "pairing_code": "ABCD-2345", "pairing_token": "<secret>", "expires_at": "…", "poll_interval_sec": 5 }
`
- The module shows
pairing_codeto the owner and keepspairing_tokensecret. - The owner opens Devices → Pair a module → Code on the module and enters the code. The code is valid for 15 minutes, and too many wrong attempts pause pairing.
- The module polls
POST /pairing/pollwith{ "pairing_token": "<secret>" }until it getsstatus: "paired". That response carriesdevice_id,api_keyandapi_base, and the key is delivered once. Other statuses arepending(keep polling),expiredandcompleted(already delivered).
B. Provisioned key (technicians, DIY modules, simulator)
The owner opens Devices → Pair a module → Provision a key and gets a key shown once. Tick This is a simulator for test data: everything that device sends is then labelled Simulated in the app.
3. Envelope
Every ingest call (heartbeat, telemetry, events, ack, and the metadata of captures) uses the same JSON envelope:
| field | required | notes |
|---|---|---|
event_id | yes | Unique per event, 8–80 characters [A-Za-z0-9._:-]. A repeat returns {"status":"duplicate"} and is not stored twice. |
observed_at | recommended | ISO 8601 UTC from the device clock. If it is missing, the server receive time is used (timestamp_quality: server_received). If it is more than 10 minutes old, it is marked late. It is rejected if it is in the future or more than 30 days old. |
sequence_number | optional | A monotonic integer. |
schema_version | optional | Defaults to "1". |
firmware_version | optional | |
device_id | optional | If present, it must match the key. |
simulated | optional | Set true for any synthetic data. |
payload | yes | An object whose shape depends on the endpoint. |
Errors use the shape { "error": "<human message>", "code": "<CODE>" }. Codes:
400 INVALID_INPUT401 DEVICE_AUTH_FAILEDand401 DEVICE_REVOKED403 CAMERA_DISABLED413 PAYLOAD_TOO_LARGE429 RATE_LIMITED
4. Endpoints
POST /heartbeat
Payload: { "battery_pct": 87, "signal_quality": 72, "fault_codes": [] }. A device is shown as Online only if it sent a heartbeat or data in the last 5 minutes. After that it shows Delayed, and after 30 minutes it is Offline and an alert is raised.
POST /telemetry
{ "event_id": "t-…", "observed_at": "2026-11-11T10:00:00Z", "payload": { "readings": [
{ "sensor_type": "temperature_c", "value": 4.2 },
{ "sensor_type": "humidity_pct", "value": 61 },
{ "sensor_type": "door_open", "value": false }
] } }Allowed sensors are temperature_c (−50…80), humidity_pct (0…100), door_open (boolean), light_lux and battery_pct. You can send 1–50 readings per call. Values outside the physical range are stored as fault and ignored by the rules.
The temperature rules depend on the location the device is assigned to. These are configured ranges for reminders, not food-safety judgements:
| location | range |
|---|---|
| fridge | 0–7 °C |
| freezer | ≤ −15 °C |
| MedCold | 2–8 °C |
POST /events
The body is the envelope plus event_type, which is one of:
door_opened,door_closeditem_added,item_removedmotion,fault,tamper,reboot,camera_blocked
The payload must be under 4 KB. item_added and item_removed are logged as observations only. They never change the inventory by themselves.
POST /captures (multipart/form-data)
image: JPEG, PNG or WebP, at most 6 MB. The bytes are checked, not just the header.metadata: the JSON envelope, withpayload.triggerset todoor_event,intervaloruser.
The response is 202 { "status": "accepted", "capture_id": "…", "analysis": "queued" }.
- The image is stored privately (Vercel Blob, EU) and analysed by the vision model.
- The suggestions appear in the app as Photo suggestions to review. Nothing is written to the inventory until a household member confirms.
- Images are deleted after the household's retention period (default 7 days, 0 means delete right after analysis).
- If the owner switched the camera off, the call returns
403 CAMERA_DISABLED.
GET /config
Returns the device's name, its assigned location, camera_enabled, the capture policy (no continuous video), and the heartbeat and telemetry intervals (60 s and 300 s). It also returns server_time for clock sync.
POST /ack
The envelope with payload.ref. It acknowledges a local alert, for example when the buzzer was silenced.
5. curl examples
KEY="fhd_…" # shown once in the app
BASE=https://farm-home.nl/api/v1/device
curl -s -X POST $BASE/heartbeat -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"event_id":"hb-'$(date +%s)'","simulated":true,"payload":{"battery_pct":90}}'
curl -s -X POST $BASE/captures -H "Authorization: Bearer $KEY" \
-F image=@fridge.jpg -F 'metadata={"event_id":"cap-'$(date +%s)'","simulated":true,"payload":{"trigger":"user"}}'6. Simulator
scripts/device-simulator.mjs is a simulator. It always sends simulated: true, and its data is labelled Simulated in the app.
FARMHOME_DEVICE_KEY=fhd_… node scripts/device-simulator.mjs all --image ./fridge.jpg
node scripts/device-simulator.mjs pair --type fridge # code-pairing flow, prints the code to enter in the app7. Security notes
- Use one key per device, store only its hash, rotate it if it is exposed, and revoke it when the device is unpaired.
- Ingest is idempotent through
event_id. Late and out-of-order data is accepted and labelled with its timestamp quality. - Devices cannot read household data apart from their own config.
- Images are never public. They are served only to signed-in household members through
/api/v1/media/*.
