How to Perform ESP32 Firmware OTA Updates via HTTP POST

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:

  1. 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.
  2. Client side: Any HTTP client (curl, Python requests, a CI job) POSTs the .bin file with a proper Content-Length header.
  3. 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:

HTTPHandlersFirmwareUpdate.cpp
#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:

HTTPServer.cpp
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

flash.sh
curl -X POST \
  --data-binary "@build/MyFirmware.bin" \
  -H "Content-Type: application/octet-stream" \
  http://10.0.0.42/api/firmware/update

Python

firmware_updater.py
#!/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:

output.txt
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():

do-not-do-this.cpp
// 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 (httpd supports 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 of httpd_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.

Check out similar posts by category: ESP32 Embedded