A web page served by an ESP32 is nice for humans. A REST API served by an ESP32 is useful for everything else: a phone app, a Python script, Home Assistant, Node-RED, a dashboard on another computer, a shell script that switches a relay with one command. Instead of returning HTML, the ESP32 returns small, clean JSON, and any software that speaks HTTP can read your sensors or control your hardware.
The good news is that you do not need any special library. The standard WebServer library included with the ESP32 core, plus ArduinoJson, is enough to build a proper API with multiple endpoints, input validation, authentication and cross-origin support. This tutorial builds one step by step: a status endpoint, a sensor endpoint, an LED control endpoint, an API key check and CORS headers.
What a REST API Is (The Short Version)
REST is a convention for using HTTP sensibly. You organize your device into resources (things with names, such as /api/v1/sensor or /api/v1/led) and use the HTTP method to say what to do:
| Method | Meaning | Example on the ESP32 |
|---|---|---|
| GET | Read | GET /api/v1/sensor returns the temperature |
| POST | Create or trigger an action | POST /api/v1/led with {"state":"on"} turns an LED on |
| PUT | Replace or set a value | PUT /api/v1/config updates a setting |
| DELETE | Remove | DELETE /api/v1/log clears stored data |
Responses use status codes (200 success, 400 bad input, 401 unauthorized, 404 not found) and JSON bodies. Including /v1/ in the path lets you change the API later without breaking existing clients.
If you want the other side of this, the ESP32 calling someone else's API, see HTTP GET and POST requests.
What You Need
- An ESP32 board
- An LED with a 220 Ω resistor on GPIO 23 (or use the onboard LED on GPIO 2)
- Arduino IDE with the ESP32 core
- ArduinoJson version 7 from the Library Manager
- A connection to your WiFi (see the WiFi tutorial)
curl(built into Windows 10+, macOS and Linux) or the Postman app for testing
Wire the LED: GPIO 23 to the 220 Ω resistor to the LED's long leg, and the short leg to GND.
Step 1: A Minimal API With Two Endpoints
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
const char* ssid = "YOUR_WIFI_NAME";
const char* password = "YOUR_WIFI_PASSWORD";
const int LED_PIN = 23;
bool ledState = false;
WebServer server(80);
void sendJson(int code, JsonDocument& doc) {
String body;
serializeJson(doc, body);
server.send(code, "application/json", body);
}
void handleStatus() {
JsonDocument doc;
doc["device"] = "esp32-api";
doc["uptime_s"] = millis() / 1000;
doc["free_heap"] = ESP.getFreeHeap();
doc["rssi"] = WiFi.RSSI();
doc["ip"] = WiFi.localIP().toString();
sendJson(200, doc);
}
void handleSensor() {
JsonDocument doc;
doc["chip_temperature_c"] = temperatureRead(); // internal chip sensor, rough value
doc["analog_raw"] = analogRead(34);
sendJson(200, doc);
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("API at http://");
Serial.println(WiFi.localIP());
server.on("/api/v1/status", HTTP_GET, handleStatus);
server.on("/api/v1/sensor", HTTP_GET, handleSensor);
server.begin();
}
void loop() {
server.handleClient();
}
Upload it and note the IP in the Serial Monitor. From any computer on the same network:
curl http://192.168.1.184/api/v1/status
You get JSON back:
{"device":"esp32-api","uptime_s":42,"free_heap":214532,"rssi":-58,"ip":"192.168.1.184"}
temperatureRead() returns the ESP32's internal chip temperature, which runs warm and is not an accurate room temperature. It is used here as a quick demo value. In a real project, replace it with a reading from a sensor such as a BME280 or DS18B20.
Step 2: A POST Endpoint to Control the LED
Control endpoints need to read the request body, validate it and respond appropriately.
void handleLedGet() {
JsonDocument doc;
doc["state"] = ledState ? "on" : "off";
sendJson(200, doc);
}
void handleLedPost() {
if (!server.hasArg("plain")) {
JsonDocument err;
err["error"] = "Missing body";
sendJson(400, err);
return;
}
JsonDocument doc;
DeserializationError e = deserializeJson(doc, server.arg("plain"));
if (e) {
JsonDocument err;
err["error"] = "Invalid JSON";
sendJson(400, err);
return;
}
const char* state = doc["state"];
if (!state || (strcmp(state, "on") != 0 && strcmp(state, "off") != 0)) {
JsonDocument err;
err["error"] = "state must be on or off";
sendJson(422, err);
return;
}
ledState = (strcmp(state, "on") == 0);
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
JsonDocument ok;
ok["state"] = ledState ? "on" : "off";
sendJson(200, ok);
}
Register both routes in setup() on the same path with different methods:
server.on("/api/v1/led", HTTP_GET, handleLedGet);
server.on("/api/v1/led", HTTP_POST, handleLedPost);
Test it:
curl -X POST http://192.168.1.184/api/v1/led \
-H "Content-Type: application/json" \
-d '{"state":"on"}'
curl http://192.168.1.184/api/v1/led
On Windows Command Prompt, put the whole command on one line and escape the inner quotes: -d "{\"state\":\"on\"}".
The raw JSON body arrives under the special argument name "plain". Always validate input: return 400 for malformed requests and 422 when the JSON is valid but the values are not.
Step 3: Handle Unknown Routes
Without a handler, a wrong URL returns a plain-text error. A good API returns JSON for errors too.
void handleNotFound() {
JsonDocument doc;
doc["error"] = "Not found";
doc["path"] = server.uri();
sendJson(404, doc);
}
// in setup():
server.onNotFound(handleNotFound);
Step 4: Protect It With an API Key
Anyone on your network can reach the API you just built, including a guest on your WiFi or a compromised device. At minimum, require a secret key on every control endpoint.
const char* API_KEY = "change-this-to-a-long-random-string";
bool authorized() {
if (server.header("X-API-Key") == API_KEY) return true;
JsonDocument err;
err["error"] = "Unauthorized";
sendJson(401, err);
return false;
}
void handleLedPost() {
if (!authorized()) return;
// ... rest of the handler as before
}
The WebServer only keeps headers you ask for, so register the header in setup() before server.begin():
const char* headerKeys[] = {"X-API-Key"};
server.collectHeaders(headerKeys, 1);
Call it with the key:
curl -X POST http://192.168.1.184/api/v1/led \
-H "X-API-Key: change-this-to-a-long-random-string" \
-H "Content-Type: application/json" \
-d '{"state":"on"}'
Be realistic about what this protects. The built-in WebServer speaks plain HTTP, so the key travels unencrypted across your WiFi. That is fine on a trusted home network, but anyone who can sniff the traffic can read it. Do not expose this API to the internet by forwarding a port. For remote access, put the ESP32 behind a VPN or a reverse proxy that adds HTTPS. Keep read-only endpoints open if you like, and put authentication on anything that changes the world.
Step 5: CORS, So a Web Page Can Call Your API
If you build a dashboard page on another host (or open a local HTML file) that calls your ESP32 with JavaScript, browsers block the request unless the ESP32 sends CORS headers.
void addCors() {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key");
}
void sendJson(int code, JsonDocument& doc) {
String body;
serializeJson(doc, body);
addCors();
server.send(code, "application/json", body);
}
void handleOptions() {
addCors();
server.send(204);
}
// in setup():
server.on("/api/v1/led", HTTP_OPTIONS, handleOptions);
Browsers send an OPTIONS "preflight" request before a POST with custom headers, which is why that handler exists. Using * allows any website to call the API, so on a real device restrict it to your own dashboard's origin.
A tiny page to test it:
<button onclick="toggle()">Toggle LED</button>
<script>
async function toggle() {
const r = await fetch("http://192.168.1.184/api/v1/led", {
method: "POST",
headers: {"Content-Type": "application/json", "X-API-Key": "your-key"},
body: JSON.stringify({state: "on"})
});
console.log(await r.json());
}
</script>
Design Tips for a Good ESP32 API
- Use nouns, not verbs.
POST /api/v1/ledis better than/api/v1/turnLedOn. - Return the new state after a change so the client does not need a second request.
- Keep responses small. A few hundred bytes is ideal. The ESP32 has limited RAM.
- Validate everything. Never trust input, especially numbers that drive hardware.
- Do not block in handlers. A handler that waits several seconds stalls every other client. Start a task or set a flag and return immediately.
- Version your paths with
/v1/so changes do not break your apps. - Document it. A short list of endpoints, methods and example bodies in your project README saves hours later.
Example endpoint plan for a small greenhouse node:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/status | Uptime, memory, WiFi signal |
| GET | /api/v1/sensor | Temperature, humidity, soil moisture |
| GET | /api/v1/relay | Current relay state |
| POST | /api/v1/relay | Switch the pump relay |
| POST | /api/v1/config | Change the watering threshold |
Common Problems
404 Not Found for a route you defined. Check the exact path, including leading slash and case. /api/v1/led and /api/v1/led/ are different routes.
POST body is empty. The body lives in server.arg("plain"). It only exists when the Content-Type is JSON or plain text, and curl needs -d.
X-API-Key header always empty. You forgot collectHeaders() before server.begin().
Browser console shows a CORS error. Add the CORS headers and the OPTIONS handler.
API freezes when two clients connect. The sync WebServer handles one request at a time. For several simultaneous clients and WebSocket support, look at an asynchronous server library, or see the WebSocket tutorial.
Works from the laptop, not from the phone. The two devices may be on different networks (for example a guest network). Check the IP ranges.
Frequently Asked Questions
Do I need ESPAsyncWebServer for a REST API? No. The built-in WebServer works well for simple APIs with a few clients. An async server helps when many clients connect at once, or you need WebSockets or file serving at speed.
Can I call this API from Home Assistant? Yes. A rest_command or a REST sensor in Home Assistant can read and control these endpoints. For a deeper setup, see Home Assistant without MQTT.
REST or MQTT, which is better? REST is request and response and great for apps and scripts. MQTT pushes updates as they happen and suits sensors and automation.
How do I add a static address so the API URL never changes? Follow the static IP guide.
Is it safe to expose this to the internet? Not without HTTPS and proper authentication. Use a VPN or a secure reverse proxy instead.
Where do I go next? Make the data live with WebSockets, or build a WiFi smart switch.