01 · Building a REST API on the ESP32¶
Not flashed to hardware
Reasoned through against the ESP32 Arduino core's documented
WebServer library API (on(), send(), request-argument accessors)
and the ESP8266 core's analogous ESP8266WebServer library, which
share nearly identical documented interfaces. Not compiled or flashed
to physical hardware in this environment.
From a status page to a real API¶
Level 1's capstone served a simple HTML status page. A REST API
formalizes that into predictable, machine-consumable endpoints: distinct
URL paths per resource, HTTP methods (GET to read, POST to write),
and JSON request/response bodies instead of hand-built HTML — the same
shape any mobile app or dashboard expects to talk to.
Basic GET endpoints¶
// rest-api-basic.ino
#include <WiFi.h> // ESP8266WiFi.h + ESP8266WebServer.h on ESP8266
#include <WebServer.h>
#include <ArduinoJson.h>
WebServer server(80);
float currentTempC = 22.5;
void handleStatus() {
JsonDocument doc;
doc["device"] = "esp-api-01";
doc["uptimeMs"] = millis();
doc["tempC"] = currentTempC;
String body;
serializeJson(doc, body);
// send(code, contentType, body) is WebServer's documented response API.
server.send(200, "application/json", body);
}
void handleNotFound() {
server.send(404, "application/json", "{\"error\":\"not found\"}");
}
void setup() {
Serial.begin(115200);
WiFi.begin("your-ssid", "your-password");
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println(WiFi.localIP());
server.on("/status", HTTP_GET, handleStatus);
server.onNotFound(handleNotFound); // fires for any unmatched route
server.begin();
}
void loop() {
server.handleClient(); // must be called every loop iteration, non-blocking
}
Accepting a POST body (JSON in, JSON out)¶
// rest-api-post.ino
void handleSetInterval() {
if (server.method() != HTTP_POST) {
server.send(405, "application/json", "{\"error\":\"method not allowed\"}");
return;
}
// server.arg("plain") is WebServer's documented way to read a raw
// POST body when it isn't form-encoded.
String body = server.arg("plain");
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
server.send(400, "application/json", "{\"error\":\"invalid json\"}");
return;
}
int intervalMs = doc["intervalMs"] | -1;
if (intervalMs <= 0) {
server.send(400, "application/json", "{\"error\":\"intervalMs must be positive\"}");
return;
}
// ... apply intervalMs to whatever timer drives publishing ...
server.send(200, "application/json", "{\"status\":\"ok\"}");
}
// In setup(): server.on("/config/interval", HTTP_POST, handleSetInterval);
Routing with a path parameter¶
WebServer doesn't document built-in :id-style path parameters the way
larger frameworks do, but the same effect is achieved by registering a
prefix and parsing the remainder manually, or using
server.pathArg() where supported, or simplest: a query string:
// rest-api-query-param.ino
void handleLed() {
// /led?state=on
String state = server.arg("state"); // arg() also reads query-string params
if (state == "on") {
digitalWrite(LED_BUILTIN, LOW);
} else if (state == "off") {
digitalWrite(LED_BUILTIN, HIGH);
} else {
server.send(400, "application/json", "{\"error\":\"state must be on or off\"}");
return;
}
server.send(200, "application/json", "{\"status\":\"ok\"}");
}
// In setup(): server.on("/led", HTTP_GET, handleLed);
CORS: letting a browser-based dashboard call the API¶
A dashboard hosted on a different origin (e.g. a laptop's browser, not the ESP itself) will be blocked by the browser's same-origin policy unless the response includes a documented CORS header:
void handleStatus() {
server.sendHeader("Access-Control-Allow-Origin", "*"); // dev-friendly; scope this down in production
JsonDocument doc;
doc["tempC"] = currentTempC;
String body;
serializeJson(doc, body);
server.send(200, "application/json", body);
}
How It Actually Works¶
Running an HTTP server on the ESP32 means the chip is doing full TCP server-side work in software: WiFiServer/WebServer binds a listening socket in lwIP, which maintains a TCP control block per connection tracking sequence numbers, window size, and retransmission timers — accepting a client connection completes the standard three-way handshake (SYN, SYN-ACK, ACK) entirely in firmware before your server.on() handler ever sees a byte. Parsing the HTTP request line and headers is done by scanning the raw TCP payload byte stream for delimiters as data arrives, which is why a client that sends headers across multiple small TCP segments (common on lossy Wi-Fi) can appear to "hang" briefly — the parser is genuinely waiting for enough bytes to reassemble a complete header line, not stuck.
Concurrency here is real but limited by memory, not the number of physical radios: each accepted TCP connection needs its own control block and, in the ESP32 Arduino core, typically its own small RTOS task or handler context, all competing for the same shared heap that also backs Wi-Fi buffers and TLS state — this is the concrete reason a REST API on ESP32 that "works with one client" can crash or drop connections under a handful of concurrent clients: it isn't a software concurrency bug, it's the connection count multiplied by per-connection RAM overhead exceeding the chip's total free heap.
(These examples were written and reasoned through at the register/protocol level but were not flashed to a physical board for this pass — verify timing-sensitive details against your exact chip datasheet before relying on them in production.)
Exercise¶
- Write the basic
/statusGET endpoint and confirm the JSON shape it would return by tracing the code. - Add the
/config/intervalPOST endpoint, including the 400 response for a missing/invalidintervalMsfield. - Add the
/ledquery-param endpoint and test (by reasoning through the code) both?state=onand an invalid?state=maybe. - Add the CORS header to every response and explain why a wildcard
Access-Control-Allow-Origin: *is convenient for development but should be tightened for a production device.