This post shows how to implement a firmware OTA (over-the-air) update endpoint on an ESP32 running the ESP-IDF HTTP server, where the client uploads the raw .bin firmware image via a simple POST request. It also covers two pitfalls that cost me a lot of debugging time: chunked transfer encoding and a silently failing reboot task.
Overview
The procedure has three parts:
- Device side: An HTTP handler receives the binary body, writes it to the inactive OTA partition using
esp_ota_begin()/esp_ota_write()/esp_ota_end(), sets it as the boot partition, and reboots. - Client side: Any HTTP client (curl, Python
requests, a CI job)POSTs the.binfile with a properContent-Lengthheader. - Reboot: After the response is sent, the device restarts into the new firmware.
Device side: the HTTP handler
Register a POST URI handler, e.g. /api/firmware/update. The handler must read exactly req->content_len bytes using httpd_req_recv() in a loop, since the body arrives in chunks even though the total length is known upfront:
#include <esp_http_server.h>
#include <esp_log.h>
#include <esp_ota_ops.h>
#include <esp_system.h>
#include <esp_timer.h>
#include <cJSON.h>
static const char *TAG = "fw_update";
static void reboot_after_update(void *) {
esp_restart();
}
static esp_err_t firmware_update_handler(httpd_req_t *req) {
if (req->content_len <= 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing Content-Length");
return ESP_OK;
}
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(nullptr);
if (!update_partition) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No OTA partition");
return ESP_OK;
}
esp_ota_handle_t ota_handle;
esp_err_t err = esp_ota_begin(update_partition, req->content_len, &ota_handle);
if (err != ESP_OK) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "esp_ota_begin failed");
return ESP_OK;
}
// Receive body in chunks until content_len bytes have been read
int remaining = req->content_len;
char buf[4096];
while (remaining > 0) {
int to_read = remaining < (int)sizeof(buf) ? remaining : (int)sizeof(buf);
int received = httpd_req_recv(req, buf, to_read);
if (received <= 0) {
if (received == HTTPD_SOCK_ERR_TIMEOUT) continue; // retry on timeout
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed");
return ESP_OK;
}
err = esp_ota_write(ota_handle, buf, received);
if (err != ESP_OK) {
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed");
return ESP_OK;
}
remaining -= received;
}
if (esp_ota_end(ota_handle) != ESP_OK ||
esp_ota_set_boot_partition(update_partition) != ESP_OK) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA finalize failed");
return ESP_OK;
}
// Send JSON response BEFORE rebooting
httpd_resp_set_type(req, "application/json");
char response[160];
snprintf(response, sizeof(response),
"{\"status\":\"success\",\"partition\":\"%s\",\"written\":%d}",
update_partition->label, req->content_len);
httpd_resp_sendstr(req, response);
// Schedule reboot AFTER the response has been flushed (see pitfall #2)
const esp_timer_create_args_t timer_args = {
.callback = reboot_after_update,
.arg = nullptr,
.dispatch_method = ESP_TIMER_TASK,
.name = "fw_reboot",
.skip_unhandled_events = false,
};
esp_timer_handle_t reboot_timer = nullptr;
if (esp_timer_create(&timer_args, &reboot_timer) == ESP_OK) {
esp_timer_start_once(reboot_timer, 1200 * 1000); // 1.2 s
} else {
esp_restart(); // fallback: reboot immediately
}
return ESP_OK;
}Register it in your server setup:
static const httpd_uri_t firmware_update_uri = {
.uri = "/api/firmware/update",
.method = HTTP_POST,
.handler = firmware_update_handler,
.user_ctx = nullptr,
};
httpd_register_uri_handler(server, &firmware_update_uri);Client side
curl
curl -X POST \
--data-binary "@build/MyFirmware.bin" \
-H "Content-Type: application/octet-stream" \
http://10.0.0.42/api/firmware/updatePython
#!/usr/bin/env python3
import os
import sys
import requests
host, bin_path = sys.argv[1], sys.argv[2]
size = os.path.getsize(bin_path)
# IMPORTANT: pass the file object directly so requests sends a fixed
# Content-Length. Do NOT pass a generator — that triggers chunked
# transfer encoding, which the ESP-IDF HTTP server does not accept here.
with open(bin_path, "rb") as f:
r = requests.post(
f"http://{host}/api/firmware/update",
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(size),
},
timeout=120,
)
print(r.status_code, r.text)A successful update returns:
200 {"status":"success","partition":"ota_1","written":1462400}Pitfall 1: chunked transfer encoding → HTTP 400
If the client sends Transfer-Encoding: chunked instead of a fixed Content-Length, the ESP-IDF HTTP server rejects the request with 400 Bad request syntax — req->content_len is not usable for chunked bodies.
This happens easily in Python when you pass a generator (or any iterable without a length) as data= to requests.post(). Passing a file object directly — as shown above — makes requests emit a normal Content-Length request.
Pitfall 2: xTaskCreate for the reboot can silently fail
A common pattern is to spawn a task that delays, then calls esp_restart():
// BAD: silently fails on a fragmented heap
(void)xTaskCreate(reboot_task, "fw_reboot", 2048, nullptr, 5, nullptr);On a long-running device, the internal heap can become so fragmented that no contiguous block for the new task stack remains. xTaskCreate then fails — and since the return value is ignored, the device reports a successful OTA, sets the boot partition, but never reboots. It keeps running the old firmware indefinitely. Verify reboot by checking uptime after the update!
esp_timer runs its callback in the already-existing timer task, so no new stack needs to be allocated — this works even on a badly fragmented heap.
Verification checklist
- Response contains HTTP 200 and the JSON
status: success, the target partition and the byte count. - The device reboots on its own ~1 s after the response (uptime resets).
- After reboot, the application is healthy (in my case: MQTT reconnects, node registry intact).
Notes
- This endpoint is unauthenticated in the example. On any untrusted network, protect it — at minimum with HTTP digest auth (
httpdsupports basic auth via a custom handler check) or by only exposing it on a management VLAN. - For HTTPS (TLS) on the device, use
httpd_ssl_start()instead ofhttpd_start()— the handler code is identical. esp_ota_end()validates the image; an interrupted upload will fail validation (ESP_ERR_OTA_VALIDATE_FAILED) and the device keeps running the old firmware — a retry is safe.