raspberry-pi intermediate 35 min

Raspberry Pi: build a sensor REST API with Flask

Expose your Pi's sensors as a JSON REST API with Flask. GET readings, POST control commands, and the structure that keeps it honest.

Code available for: Python
Published Sep 22, 2026

Every Pi project with a sensor eventually needs to answer a question over the network: “what is the temperature right now?” A REST API is the answer that outlives every dashboard: any client (a phone, a cron job, another Pi, an ESP32) speaks HTTP JSON, no special app required.

Flask is the right tool at this scale: one file, no build step, and the whole API fits on one screen.

What you need

  • Raspberry Pi with Raspberry Pi OS (any model; a Pi Zero 2 W handles this easily)
  • SSH access or a terminal on the Pi
  • A sensor already working (the DHT22 Python tutorial is a good sibling; this one uses a placeholder function you replace)

Install

sudo apt install python3-flask -y
# or, in a venv (the cleaner way):
python3 -m venv ~/venvs/sensorapi && ~/venvs/sensorapi/bin/pip install flask

The code

# ~/sensorapi/app.py
from flask import Flask, jsonify, request
import random   # stand-in for your real sensor code

app = Flask(__name__)

# Stand-in for your sensor driver. Replace with gpiozero/DHT/etc.
def read_temp():
    return round(20 + random.random() * 5, 1)

# The shared-state pattern: whatever the API can change, a background
# thread or task maintains. The API only reads/writes this dict.
state = {"pump": "off"}

@app.route("/api/reading", methods=["GET"])
def reading():
    return jsonify({"temp": read_temp(), "unit": "C", "ok": True})

@app.route("/api/history", methods=["GET"])
def history():
    n = request.args.get("n", default=24, type=int)
    return jsonify({"points": get_history(min(n, 1000))})

@app.route("/api/pump", methods=["POST"])
def pump():
    data = request.get_json(force=True)
    if "on" not in data:
        return jsonify(error="missing 'on' field"), 400
    state["pump"] = bool(data["on"])
    return jsonify({"pump": state["pump"]}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

Run it (python3 app.py), then from any machine on the LAN:

curl http://192.168.1.50:8000/api/reading
# {"ok": true, "reading": 22.3, "unit": "C"}

curl -X POST http://192.168.1.50:8000/api/pump -d '{"on": true}'

The response contract

JSON with a consistent shape, always. Three rules that age well:

  1. Status codes mean what they say. 200 read, 200 written, 400 bad input, 404 unknown route. Clients check codes, not prose.
  2. A boolean ok field beats exceptions across the wire. Your future self parses JSON with one path, not two.
  3. Version the path. /api/reading today, /api/v2/reading when the shape changes. It costs nothing now and saves a migration later.

The real sensor wiring-in

Replace the stand-in with your driver. The one subtlety: GPIO access and Flask want to run forever, so either keep reads fast and inline:

# gpiozero read, 50 ms, fine inline
from gpiozero import DistanceSensor
sensor = DistanceSensor(echo=17, trigger=4)

@app.route("/api/reading")
def reading():
    return jsonify({"distance_cm": round(sensor.distance * 100, 1)})

…or run slow sensors (e.g. an ultrasonic needing 60 ms settle) in a background thread that updates state every second, and have every route read state instantly. Threads started with threading.Thread work fine alongside Flask for this.

Serving it for real

app.run() is the dev server. For anything always-on, run it under a proper service:

# /etc/systemd/system/sensorapi.service
[Unit]
Description=Sensor API
After=network.target

[Service]
ExecStart=/home/brian/venvs/sensorapi/bin/python /home/brian/sensorapi/app.py
Restart=always
User=brian

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now sensorapi

Now it survives reboots and crashes with restart. The port stays 8000 and the API lives at a stable address (e.g. pair this with the Wireguard tutorial to reach it from outside without opening ports).

What you learned

  • Flask turns Pi sensor functions into HTTP endpoints in one file.
  • The response contract (codes, shape, ok field) is what makes an API outlive its dashboard.
  • systemd runs it as a service with restart-on-crash.

When something breaks

  • Connection refused from another machine: app.run() defaults to localhost only. host="0.0.0.0" is the fix you already have; check the Pi’s firewall if it still refuses.
  • 404s on the right route: trailing slash mismatch (e.g. Flask treats /api/reading/ and /api/reading as different). Pick one and be consistent.
  • GPIO error on second run: a previous instance still holds the pin. sudo systemctl stop sensorapi before re-running by hand.
  • API slow when history grows: you are reading a CSV on every GET. This is the SQLite tutorial’s whole reason to exist.

What to build next

  • The SQLite logging tutorial gives /api/history a real database.
  • The InfluxDB + Grafana tutorial is the “I want graphs, not endpoints” alternative.
  • Pair with the Wireguard tutorial for secure remote access.