You've flashed your ESP32, deployed a handful of them around your house, workshop, or office, and everything is running great. Then you find a bug. Or ship a new feature. Now what?
If you're on a managed IoT platform, firmware updates are handled for you. But in a lean, DIY setup, rolling your own over-the-air (OTA) update system usually means spinning up an API, a database, or at minimum a server your devices can check in with. That's a lot of infrastructure for the question: "Is there a newer .bin available?"
There's a simpler way. Host a tiny JSON file — containing your latest firmware version and download URL — and have each ESP32 check it on boot. No server. No database. No backend. Just a public JSON endpoint your devices can read in a handful of lines of code.
The Plan
- Create a JSON document that holds the current firmware version and a direct link to the
.binfile. - On boot, the ESP32 fetches that document and compares the remote version against its own compiled-in version string.
- If the remote version is newer, it downloads and flashes the new firmware automatically.
- When you ship a new release, you update the JSON with a single
curlcommand — and every deployed device picks it up on its next boot.
For hosting the version manifest, we'll use JSONhost. It gives you an instant public GET endpoint with no server to maintain. For the firmware binary itself, you'll need somewhere that serves raw files over HTTPS — GitHub Releases is a solid free option.
Step 1: Design the Version Manifest
The JSON document is intentionally small:
{
"version": "1.0.0",
"url": "https://github.com/yourname/yourrepo/releases/download/v1.0.0/firmware.bin",
"notes": "Initial release"
}
-
version— the current release as a semantic version string. -
url— a direct HTTPS link to the compiled.binfile. -
notes— optional release notes, useful to log on the device for diagnostics.
Step 2: Host the Manifest on JSONhost
Create the document using the JSONhost Management API. You'll need an account and a user-level API token from your profile page (/profile).
curl -X POST "https://jsonhost.com/api/v1/json/manage/create" \
-H "Authorization: your_api_token_here" \
-H "Content-Type: application/json" \
-H "X-Alias: my-firmware" \
-H "X-Description: Firmware version manifest for my ESP32 project" \
-d '{
"version": "1.0.0",
"url": "https://github.com/yourname/yourrepo/releases/download/v1.0.0/firmware.bin",
"notes": "Initial release"
}'
The response includes a url_api field — that's the endpoint your devices will call. It looks like:
https://jsonhost.com/json/exampleID123456789
GET requests on JSONhost are public, so no API token is needed on the device side.
Step 3: Write the ESP32 Sketch
Here's a complete working sketch. It connects to Wi-Fi, fetches the version manifest, compares versions, and triggers an OTA update if one is available.
You'll need one external library:
- ArduinoJson — for parsing the JSON response. Install it via the Arduino Library Manager.
HTTPUpdate is already bundled with the ESP32 Arduino core — no extra install needed.
#include <WiFi.h>
#include <HTTPClient.h>
#include <HTTPUpdate.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
// ── Configuration ─────────────────────────────────────────────
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASSWORD = "your-password";
// Your JSONhost manifest endpoint
const char* VERSION_URL = "https://jsonhost.com/json/exampleID123456789";
// Version compiled into this build — update this string for each release
const char* FIRMWARE_VERSION = "1.0.0";
// ─────────────────────────────────────────────────────────────
void connectWiFi() {
Serial.print("Connecting to WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" connected.");
}
// Parses "X.Y.Z" into a comparable integer (X*10000 + Y*100 + Z).
// Works for version components 0–99.
int versionToInt(const char* v) {
int major = 0, minor = 0, patch = 0;
sscanf(v, "%d.%d.%d", &major, &minor, &patch);
return major * 10000 + minor * 100 + patch;
}
void checkAndUpdate() {
HTTPClient http;
http.begin(VERSION_URL);
int code = http.GET();
if (code != 200) {
Serial.printf("Version check failed: HTTP %d\n", code);
http.end();
return;
}
String body = http.getString();
http.end();
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
Serial.println("JSON parse error");
return;
}
const char* remoteVersion = doc["version"];
const char* firmwareUrl = doc["url"];
const char* notes = doc["notes"];
Serial.printf("Running: %s\n", FIRMWARE_VERSION);
Serial.printf("Available: %s\n", remoteVersion);
if (versionToInt(remoteVersion) <= versionToInt(FIRMWARE_VERSION)) {
Serial.println("Firmware is up to date.");
return;
}
Serial.printf("New firmware available: %s\n", notes);
Serial.println("Starting OTA update...");
WiFiClientSecure client;
client.setInsecure(); // Replace with a proper CA cert in production
t_httpUpdate_return result = httpUpdate.update(client, firmwareUrl);
switch (result) {
case HTTP_UPDATE_OK:
Serial.println("Update complete. Rebooting...");
// The device reboots automatically on success — no ESP.restart() needed
break;
case HTTP_UPDATE_FAILED:
Serial.printf("Update failed: %s\n", httpUpdate.getLastErrorString().c_str());
break;
case HTTP_UPDATE_NO_UPDATES:
Serial.println("Server reported no update available.");
break;
}
}
void setup() {
Serial.begin(115200);
connectWiFi();
checkAndUpdate();
}
void loop() {
// Your normal application logic goes here
}
A few things worth noting
Version comparison — versionToInt() parses each X.Y.Z segment numerically, so 1.10.0 correctly beats 1.9.0. A pure string comparison (strcmp) breaks down once any segment reaches two digits, so this numeric approach is safer.
TLS verification — client.setInsecure() skips certificate validation. That's fine for a quick prototype, but in a production deployment you should install the root CA certificate for your firmware host so the device can verify it's talking to the right server.
Automatic reboot — after a successful httpUpdate.update(), the ESP32 reboots into the new firmware without any extra call. If the update fails, execution continues normally so your device keeps running its current firmware.
Step 4: Ship a New Release
Your release workflow becomes two steps:
- Build the new firmware and upload
firmware.binto GitHub Releases (or wherever you host it). - Update the manifest JSON:
curl -X POST "https://jsonhost.com/json/exampleID123456789" \
-H "Authorization: your_per_doc_api_token_here" \
-d '{
"version": "1.1.0",
"url": "https://github.com/yourname/yourrepo/releases/download/v1.1.0/firmware.bin",
"notes": "Fixed sensor calibration drift"
}'
That's the entire deployment. The next time any of your devices boots, it fetches the manifest, sees 1.1.0 > 1.0.0, and updates itself.
No CI/CD pipeline. No webhook. One curl.
Bonus: Check Periodically, Not Just on Boot
Devices that run for days or weeks without rebooting won't pick up updates until they restart. A simple periodic check with millis() solves this without blocking the main loop:
const unsigned long CHECK_INTERVAL_MS = 6UL * 60 * 60 * 1000; // every 6 hours
unsigned long lastCheck = 0;
void loop() {
if (millis() - lastCheck >= CHECK_INTERVAL_MS) {
lastCheck = millis();
checkAndUpdate();
}
// ... the rest of your application
}
Keep in mind that each GET to the manifest counts as one API request against your JSONhost daily quota. With a 6-hour interval, that's 4 requests per device per day — very light even across a fleet.
When to Graduate to Something More
This pattern is a great fit for hobby projects, home automation setups, and small deployments where you control all the hardware. It's deliberately minimal.
If you need rollback support, staged rollouts, per-device targeting, or cryptographic firmware signing, you'll want a purpose-built OTA platform — ESP-IDF's native OTA partition scheme, or a managed service like AWS IoT Jobs. Those tools solve harder problems, but they also require a lot more infrastructure to get running.
For everything in between — a dozen sensors, a basement full of custom boards, a small farm of nodes doing useful things — a public JSON manifest and a version check on boot is often all you need.
Wrapping Up
Here's what you built:
- A firmware version manifest hosted at a stable public URL.
- An ESP32 that checks the manifest on boot (or on a schedule) and self-updates when a newer version is available.
- A release process that amounts to uploading a
.binand running onecurlcommand.
The same pattern works on any platform that can make HTTPS GET requests: ESP8266, Raspberry Pi Pico W, Arduino with an Ethernet shield, or a device running MicroPython. The manifest stays simple; the host-side logic is minimal.
If you build on this or adapt it for a different microcontroller, drop a note in the comments — always curious what people are running in the wild.
The firmware manifest in this article is hosted using JSONhost — a lightweight service for hosting and serving JSON over HTTP. GET requests are always public, making it a clean fit for device-readable configuration and version manifests.

Top comments (0)