ESP32 HTTP POST to a REST API: Sending JSON Sensor Data to Flask

HTTP is the language of the web, and almost every server framework, cloud function and database service accepts it. If you are building a final-year project with a custom web dashboard, the simplest architecture is often an ESP32 that POSTs JSON to a REST endpoint on your own server.

In this tutorial you will write a small Python Flask API that stores readings in SQLite, then program an ESP32 to send a reading every 30 seconds with the HTTPClient library. You will also add an API key header, check HTTP status codes properly, and read a configuration value back from the server's JSON response.

REST in Two Minutes

A REST API exposes resources at URLs and uses HTTP methods to act on them. For IoT the important ones are POST to create a new reading and GET to fetch data or settings. The request body and response are usually JSON, announced with the header Content-Type: application/json. The server replies with a status code that the device must check.

Status codeMeaningWhat the ESP32 should do
200 OK / 201 CreatedRequest acceptedContinue normally
400 Bad RequestMalformed JSON or missing fieldFix the firmware; retrying will not help
401 UnauthorizedMissing or wrong API keyCheck the key; do not retry in a tight loop
500 / 503Server error or overloadedRetry later with a delay
Negative valueHTTPClient error, no HTTP response (e.g. connection refused, timeout)Check Wi-Fi and server address, retry later

Step 1: The Flask Server

Install Python 3, then run pip install flask. Save the file below as server.py and start it with python server.py. It listens on all interfaces at port 5000 so the ESP32 on the same Wi-Fi network can reach it.

server.py: minimal REST API that stores readings in SQLite
from flask import Flask, request, jsonify
import sqlite3, time

API_KEY = "change-this-key"
DB = "readings.db"
app = Flask(__name__)

def db():
    con = sqlite3.connect(DB)
    con.execute("""CREATE TABLE IF NOT EXISTS readings(
        ts INTEGER, device TEXT, temp_c REAL, hum_pct REAL)""")
    return con

@app.post("/api/readings")
def add_reading():
    if request.headers.get("X-API-Key") != API_KEY:
        return jsonify(error="bad key"), 401
    data = request.get_json(silent=True)
    if not data or "device" not in data or "temp_c" not in data:
        return jsonify(error="missing fields"), 400
    with db() as con:
        con.execute("INSERT INTO readings VALUES (?,?,?,?)",
                    (int(time.time()), data["device"],
                     data["temp_c"], data.get("hum_pct")))
    # tell the device how long to wait before the next reading
    return jsonify(ok=True, next_s=30), 201

@app.get("/api/readings/latest")
def latest():
    row = db().execute("SELECT * FROM readings ORDER BY ts DESC LIMIT 1").fetchone()
    return jsonify(row=row)

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

Find the PC's IP address with ipconfig on Windows or hostname -I on Linux. Test the endpoint with curl before touching the ESP32. If curl fails, the ESP32 will fail too.

Test the API from the PC
curl -i -X POST http://192.168.1.50:5000/api/readings \
  -H "Content-Type: application/json" -H "X-API-Key: change-this-key" \
  -d '{"device":"pc-test","temp_c":27.5,"hum_pct":55}'

Step 2: The ESP32 Client

The sketch uses the DHT22 on GPIO 4 from earlier tutorials. HTTPClient is part of the ESP32 Arduino core, so only ArduinoJson and the DHT library need installing.

ESP32 POSTs JSON readings and reads next_s from the server response
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <DHT.h>

const char* WIFI_SSID = "YourWiFi";
const char* WIFI_PASS = "YourWiFiPassword";
const char* API_URL   = "http://192.168.1.50:5000/api/readings";
const char* API_KEY   = "change-this-key";

DHT dht(4, DHT22);
uint32_t waitMs = 30000;     // updated from server reply
uint32_t lastPost = 0;

int postReading(float t, float h) {
  WiFiClient client;
  HTTPClient http;
  http.setTimeout(5000);                      // ms to wait for the server
  if (!http.begin(client, API_URL)) return -1;
  http.addHeader("Content-Type", "application/json");
  http.addHeader("X-API-Key", API_KEY);

  JsonDocument doc;
  doc["device"]  = "esp32-lab";
  doc["temp_c"]  = t;
  doc["hum_pct"] = h;
  String body;
  serializeJson(doc, body);

  int code = http.POST(body);                 // blocking call
  if (code > 0) {
    String reply = http.getString();
    Serial.printf("HTTP %d: %s", code, reply.c_str());
    Serial.println();
    JsonDocument res;
    if (!deserializeJson(res, reply) && res["next_s"].is<int>()) {
      waitMs = res["next_s"].as<int>() * 1000UL;
    }
  } else {
    // negative codes are library errors, e.g. connection refused
    Serial.println("POST failed: " + http.errorToString(code));
  }
  http.end();                                 // free the connection
  return code;
}

void setup() {
  Serial.begin(115200);
  dht.begin();
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("WiFi OK: " + WiFi.localIP().toString());
}

void loop() {
  if (millis() - lastPost < waitMs) return;
  lastPost = millis();

  if (WiFi.status() != WL_CONNECTED) { WiFi.reconnect(); return; }

  float t = dht.readTemperature();
  float h = dht.readHumidity();
  if (isnan(t) || isnan(h)) { Serial.println("Sensor error"); return; }

  int code = postReading(t, h);
  if (code >= 500 || code < 0) waitMs = 60000;  // back off on errors
}

Key Points in the Code

  • http.begin(client, url) takes a WiFiClient for plain HTTP. For HTTPS you pass a WiFiClientSecure with a CA certificate instead; the security tutorial covers that.
  • addHeader must be called after begin() and before POST(). The API key travels in a custom header rather than the URL so it does not end up in server access logs.
  • Return value of POST() is the HTTP status code when positive and a library error when negative. http.errorToString(code) turns the negative value into readable text.
  • http.end() releases the socket. Forgetting it leaks memory and, after many requests, causes failures.
  • Server-controlled interval: reading next_s from the reply lets you slow down every device from the server without reflashing.
  • Back-off: on server errors the device waits longer, so a struggling server is not hammered by every node at once.

Open readings.db with any SQLite browser, or visit http://<pc-ip>:5000/api/readings/latest in a browser, to confirm that rows are arriving.

Expected Serial Monitor output

WiFi OK: 192.168.1.42
HTTP 201: {"next_s":30,"ok":true}
HTTP 201: {"next_s":30,"ok":true}
POST failed: connection refused

The last line is what you see if you stop the Flask server: the device backs off to a 60-second interval and keeps trying, and recovers automatically once the server is back. Change next_s in the server to 10 and restart it; after the next POST the ESP32 starts reporting every 10 seconds without being reflashed.

Moving from the lab to a real server

Flask's built-in server is meant for development. On a real deployment, run the same app behind a production WSGI server such as Gunicorn with Nginx in front, and enable HTTPS with a certificate from Let's Encrypt. The ESP32 code then changes in two places only: the URL starts with https://, and the WiFiClient becomes a WiFiClientSecure with the server's root CA certificate set by setCACert(). Store a different API key for each device so a single compromised board can be blocked without touching the others.

Troubleshooting

  • Error -1 (connection refused): wrong IP, the server is bound to 127.0.0.1 instead of 0.0.0.0, or Windows Firewall blocks Python. Allow Python on private networks when Windows asks.
  • 401 every time: the header name or key differs between sketch and server; header names are case-insensitive but values are not.
  • 400 with valid-looking data: the Content-Type header is missing, so Flask does not parse the body as JSON.
  • Works at home, fails at college: guest and campus networks often isolate clients from each other. Use a phone hotspot for testing or host the API on a public server.
  • Using localhost in the ESP32 URL: localhost on the ESP32 means the ESP32 itself. Always use the server's LAN IP or domain name.

Summary

You built a complete HTTP pipeline: a Flask API with key checking and SQLite storage, and an ESP32 client that POSTs JSON, checks status codes, reads settings from the reply and backs off on errors. The same HTTPClient pattern works for any REST service, including ThingSpeak and Firebase, which are covered next.