Upload ESP32-CAM Photos to a Web Server with HTTP POST and Flask

Storing photos on the ESP32-CAM's microSD card is simple, but someone has to remove the card to see them. Sending each photo to a server over Wi-Fi is far more useful: images are available immediately, they can be processed by Python or shown on a dashboard, and a stolen camera does not take the evidence with it.

In this tutorial the ESP32-CAM takes a photo every 30 seconds and uploads it with an HTTP POST request. On the other side, a short Python Flask application checks that the data is a real JPEG, verifies a shared key and saves the file with a timestamped name. You can run the server on a laptop, a Raspberry Pi or a cloud machine.

Two Ways to POST an Image

MethodRequest bodyBest for
Raw binaryOnly the JPEG bytes, Content-Type: image/jpegYour own server; simplest and smallest code on the ESP32
multipart/form-dataJPEG wrapped in a form part with boundary linesServers that expect browser-style file uploads, such as PHP move_uploaded_file or third-party APIs

Raw binary is used here because the ESP32 HTTPClient can send a buffer directly with http.POST(fb->buf, fb->len), without copying the image or building boundaries. Extra information such as the camera name and a key travels in HTTP headers.

The Flask Receiver

Install Flask with pip install flask and run the script below. It listens on port 5000 on all network interfaces so the ESP32-CAM can reach it. Note the computer's local IP address (for example with ipconfig on Windows) for use in the Arduino sketch, and allow port 5000 through the firewall.

Flask server that accepts raw JPEG uploads and saves them by camera and time
import os
from datetime import datetime
from flask import Flask, request

app = Flask(__name__)
API_KEY = "change-this-key"       # must match the ESP32 sketch
SAVE_DIR = "uploads"
MAX_BYTES = 2 * 1024 * 1024       # reject anything larger than 2 MB

@app.route("/upload", methods=["POST"])
def upload():
    if request.headers.get("X-Api-Key") != API_KEY:
        return "forbidden", 403
    data = request.get_data()
    if not data or len(data) > MAX_BYTES:
        return "bad size", 400
    # Every JPEG starts with FF D8 and ends with FF D9
    if data[:2] != b"\xff\xd8" or data[-2:] != b"\xff\xd9":
        return "not a jpeg", 400

    cam = request.headers.get("X-Camera-Id", "unknown")
    cam = "".join(ch for ch in cam if ch.isalnum() or ch in "-_")  # safe name
    folder = os.path.join(SAVE_DIR, cam)
    os.makedirs(folder, exist_ok=True)
    name = datetime.now().strftime("%Y%m%d_%H%M%S.jpg")
    with open(os.path.join(folder, name), "wb") as f:
        f.write(data)
    print(f"saved {cam}/{name} ({len(data)} bytes)")
    return "ok", 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

The ESP32-CAM Sketch

Capture a photo every 30 s and upload it as raw JPEG with HTTPClient
#include <WiFi.h>
#include <HTTPClient.h>
#include "esp_camera.h"

const char *ssid      = "YourWiFiName";
const char *password  = "YourWiFiPassword";
const char *serverUrl = "http://192.168.1.10:5000/upload";  // PC running Flask
const char *apiKey    = "change-this-key";
const char *cameraId  = "gate-cam";
const unsigned long INTERVAL_MS = 30000;

// AI-Thinker ESP32-CAM pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y9_GPIO_NUM    35
#define Y8_GPIO_NUM    34
#define Y7_GPIO_NUM    39
#define Y6_GPIO_NUM    36
#define Y5_GPIO_NUM    21
#define Y4_GPIO_NUM    19
#define Y3_GPIO_NUM    18
#define Y2_GPIO_NUM     5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

bool initCamera() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO_NUM;  c.pin_d1 = Y3_GPIO_NUM;
  c.pin_d2 = Y4_GPIO_NUM;  c.pin_d3 = Y5_GPIO_NUM;
  c.pin_d4 = Y6_GPIO_NUM;  c.pin_d5 = Y7_GPIO_NUM;
  c.pin_d6 = Y8_GPIO_NUM;  c.pin_d7 = Y9_GPIO_NUM;
  c.pin_xclk = XCLK_GPIO_NUM;   c.pin_pclk = PCLK_GPIO_NUM;
  c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
  c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
  c.pin_pwdn = PWDN_GPIO_NUM;   c.pin_reset = RESET_GPIO_NUM;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.frame_size   = FRAMESIZE_SVGA;
  c.jpeg_quality = 12;
  c.fb_count     = 1;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.grab_mode    = CAMERA_GRAB_LATEST;
  return esp_camera_init(&c) == ESP_OK;
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;
  WiFi.begin(ssid, password);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(WiFi.status() == WL_CONNECTED ? " connected" : " failed");
}

void uploadPhoto() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (fb) esp_camera_fb_return(fb);     // discard stale frame
  fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Capture failed");
    return;
  }

  HTTPClient http;
  http.begin(serverUrl);
  http.setTimeout(15000);               // ms, allow for slow links
  http.addHeader("Content-Type", "image/jpeg");
  http.addHeader("X-Api-Key", apiKey);
  http.addHeader("X-Camera-Id", cameraId);

  unsigned long t0 = millis();
  int code = http.POST(fb->buf, fb->len);  // send the frame buffer directly
  unsigned long dt = millis() - t0;
  esp_camera_fb_return(fb);              // buffer no longer needed

  if (code > 0) {
    Serial.printf("HTTP %d in %lu ms: %s\n", code, dt, http.getString().c_str());
  } else {
    Serial.printf("POST failed: %s\n", http.errorToString(code).c_str());
  }
  http.end();
}

void setup() {
  Serial.begin(115200);
  if (!initCamera()) {
    Serial.println("Camera init failed");
    while (true) delay(1000);
  }
  connectWiFi();
}

void loop() {
  static unsigned long last = 0;
  if (millis() - last >= INTERVAL_MS || last == 0) {
    last = millis();
    connectWiFi();                       // reconnect if the router dropped us
    if (WiFi.status() == WL_CONNECTED) uploadPhoto();
  }
}

How It Works

  • http.POST(fb->buf, fb->len) streams the JPEG straight from the frame buffer, so no second copy of the image is needed in RAM.
  • The buffer is returned to the camera driver right after the POST, before the response is printed, so the camera is never blocked for long.
  • The server checks the JPEG start (FF D8) and end (FF D9) markers. A truncated upload, which happens when Wi-Fi drops mid-transfer, fails this check and is rejected instead of being saved as a broken file.
  • The camera ID header is cleaned to letters, digits, dash and underscore before being used as a folder name, which prevents path tricks such as ../.
  • connectWiFi() is called before every upload with a 15 second limit, so the camera recovers by itself after a router restart.

When the Server Expects a Form Upload

Many shared-hosting PHP scripts and online services only accept files sent the way a browser sends them, as multipart/form-data. In that case the body must wrap the JPEG between boundary lines, and the Content-Type header must name the same boundary. The structure looks like this:

POST /upload.php HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=CamBoundary
Content-Length: <exact total of everything below>

--CamBoundary
Content-Disposition: form-data; name="imageFile"; filename="cam.jpg"
Content-Type: image/jpeg

<JPEG bytes>
--CamBoundary--

On the ESP32 you build the text before and after the image as two strings, add their lengths to fb->len for Content-Length, then write the head, the JPEG and the tail to a WiFiClient in order. The field name, here imageFile, must match what the server reads, for example $_FILES["imageFile"] in PHP. The Telegram photo bot tutorial uses exactly this technique over HTTPS.

Ideas for Extending the Server

  • Add a route that lists the newest files as an HTML gallery, so you can check the camera from a phone browser.
  • Delete files older than a set number of days with a scheduled task, so the disk does not fill up.
  • Run OpenCV on each uploaded image to compare it with the previous one and keep only images where something changed.
  • Return a short command in the response body, such as a new interval, and let the ESP32 parse it with http.getString().

Troubleshooting

  • POST failed: connection refused: Flask is not running, is bound to 127.0.0.1 instead of 0.0.0.0, or the firewall blocks port 5000.
  • HTTP 403: the key in the sketch and the server do not match exactly.
  • HTTP 400 not a jpeg: the upload was cut off; check Wi-Fi signal and increase the timeout.
  • Uploads take several seconds: reduce the frame size or raise the JPEG quality number to make smaller files.
  • Server works in a browser but not from the ESP32: the PC and the ESP32 are on different networks, such as a guest Wi-Fi with client isolation.

Summary

With HTTPClient the ESP32-CAM can upload a frame buffer as a raw image/jpeg POST in a few lines, adding identification and a key in headers. A small Flask app validates the size, key and JPEG markers before saving, which keeps broken files and strangers out. From here you can add image processing on the server, a gallery page, or HTTPS for uploads over the internet.