Lesson 3 of 5 · 20 min
A web server and a JSON endpoint
Once the ESP32 has an IP address, anything on the network can ask it questions. The simplest way to answer is the same protocol your browser already speaks: HTTP. A phone, a dashboard or another program can request the temperature from a room by opening a URL. This lesson builds a small web server on the ESP32 with a JSON endpoint that reads a real sensor, and explains the HTTP you need to understand to debug it.
HTTP in one minute
HTTP is a text conversation. The client sends a request, the server sends one response, and that is the whole exchange. Here is what a browser sends when you open http://192.168.1.50/api/temp:
GET /api/temp HTTP/1.1
Host: 192.168.1.50
Accept: application/json
And the ESP32's reply:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 41
{"temp_c":23.4,"uptime_s":512,"rssi":-58}
The first line of a request holds the method and the path. The first line of a response holds the status code. The headers describe the body, and Content-Length says how many bytes follow. The ones you will meet most:
| Item | Common values | Meaning |
|---|---|---|
| Method | GET, POST | GET reads and must not change anything, POST sends data to act on |
| Status 200 | OK | The request succeeded |
| Status 400 | Bad Request | The client sent something invalid |
| Status 404 | Not Found | No such path |
| Status 500 | Internal Server Error | Something broke on the device |
A GET must be safe to repeat. A browser or a prefetching proxy may call it twice, so never switch a relay on with a GET. Use POST for actions.
Which server library
Two popular choices exist for the Arduino-ESP32 core:
- WebServer is built in, needs no extra installation and handles one request at a time. You call
server.handleClient()inloop(). - ESPAsyncWebServer is a separate library that handles many connections in the background through callbacks, which suits pages with several simultaneous requests such as live dashboards.
We use the built-in WebServer here because the logic is easier to follow, and the endpoints you write look nearly the same in the async library. For a sensor that a few clients check every couple of seconds, one request at a time is plenty.
The sensor
Use a TMP36 temperature sensor. It runs from 3.3 V and outputs 10 mV per degree Celsius with a 500 mV offset, so that 0 degrees is 500 mV and negative temperatures are still positive voltages:
T = (V_mV - 500) / 10
At 750 mV that gives (750 - 500) / 10 = 25 °C. Connect the sensor output to GPIO 34, which is on ADC1, so it keeps working while Wi-Fi is on, as lesson 1 explained.
One ADC reading is noisy, with a few millivolts of jitter, and a few millivolts equals a few tenths of a degree. Averaging N independent samples reduces random noise by about sqrt(N), so 16 samples give a 4 times quieter result for a few tens of milliseconds of work.
The code
Reuse the connection code from lesson 2. This sketch focuses on the server, with a short blocking connect for clarity:
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
WebServer server(80); // HTTP default port
const uint8_t TEMP_PIN = 34; // ADC1 channel, safe while Wi-Fi runs
// A tiny page that polls the JSON endpoint every 2 s
const char PAGE[] PROGMEM = R"HTML(
<!doctype html><meta name="viewport" content="width=device-width">
<title>ESP32 sensor</title>
<h1 id="t">...</h1>
<script>
async function tick() {
const r = await fetch('/api/temp');
const j = await r.json();
document.getElementById('t').textContent = j.temp_c.toFixed(1) + ' C';
}
setInterval(tick, 2000); tick();
</script>
)HTML";
float readTempC() {
uint32_t sum = 0;
for (int i = 0; i < 16; i++) { // average 16 samples to cut noise
sum += analogReadMilliVolts(TEMP_PIN); // calibrated reading in mV
delay(2);
}
float mV = sum / 16.0f;
return (mV - 500.0f) / 10.0f; // TMP36: 10 mV per degree, 500 mV offset
}
void handleRoot() {
server.send_P(200, "text/html", PAGE);
}
void handleTemp() {
char body[96];
snprintf(body, sizeof(body),
"{\"temp_c\":%.1f,\"uptime_s\":%lu,\"rssi\":%d}",
readTempC(), millis() / 1000, WiFi.RSSI());
server.sendHeader("Access-Control-Allow-Origin", "*"); // let other web pages read it
server.send(200, "application/json", body);
}
void handleNotFound() {
server.send(404, "text/plain", "Not found");
}
void setup() {
Serial.begin(115200);
Preferences prefs;
prefs.begin("wifi", true);
String ssid = prefs.getString("ssid", "");
String pass = prefs.getString("pass", "");
prefs.end();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
while (WiFi.status() != WL_CONNECTED) delay(250); // wait for the IP address
Serial.println(WiFi.localIP()); // open this in a browser
server.on("/", HTTP_GET, handleRoot); // route table: path to handler
server.on("/api/temp", HTTP_GET, handleTemp);
server.onNotFound(handleNotFound);
server.begin();
}
void loop() {
server.handleClient(); // serve at most one pending request
}
Open the printed IP address in a browser and the page updates every two seconds. Open /api/temp directly to see the raw JSON, or call it from a terminal with curl http://192.168.1.50/api/temp.
Why JSON
The response is JSON because every language can parse it, including the browser's fetch, Python and other microcontrollers. Build it with snprintf into a fixed buffer: unlike string concatenation with String, it does not fragment the heap, which matters on a device that must run for months. Check the size too: this body is about 41 bytes, so a 96-byte buffer has room to spare.
Think about the cost of each request. A response is roughly 150 bytes of headers plus the body, so polling every 2 seconds moves about 190 bytes x 0.5 per second = 95 bytes per second, which is trivial. The real cost is time: each request keeps the server busy for about 35 ms, of which 32 ms is the sensor averaging (16 x 2 ms).
Check yourself
A TMP36 outputs 750 mV. Using T = (mV - 500) / 10, what is the temperature?
Check yourself
What status code should the server return when a client requests a path that does not exist?