ESP32-CAM Telegram Bot: Request and Receive Photos on Your Phone

Viewing an ESP32-CAM stream from outside your home normally means port forwarding, dynamic DNS and security worries. A Telegram bot avoids all of that. The ESP32-CAM connects outward to Telegram's servers, checks for new messages, and replies with a photo. You can ask for a picture from anywhere with mobile data, and no port on your router is opened.

In this project you will create a bot, find your chat ID, and program the ESP32-CAM to respond to /photo, /flash and /start commands. Messages from anyone other than you are ignored.

How the Telegram Bot API Works

A Telegram bot is an account controlled by a program through HTTPS requests to api.telegram.org. Every request includes the bot token. The ESP32-CAM uses two methods:

MethodDirectionUse in this project
getUpdatesESP32 asks Telegram for new messagesPolled every second; returns commands you typed
sendMessageESP32 sends textReplies to /start and confirms /flash
sendPhotoESP32 uploads an imageSends the JPEG as multipart/form-data

The UniversalTelegramBot library handles getUpdates and sendMessage, including JSON parsing with ArduinoJson. For sendPhoto, this sketch builds the multipart request itself, writing the JPEG straight from the frame buffer in small chunks, so the whole image never needs a second copy in RAM.

Creating the Bot and Finding Your Chat ID

  1. In Telegram, open a chat with @BotFather and send /newbot.
  2. Choose a display name and a username ending in bot. BotFather replies with a token like 123456789:AA.... Keep it secret; anyone with it controls your bot.
  3. Open a chat with @userinfobot (or a similar ID bot) and press Start. It replies with your numeric user ID, which is your chat ID for private chats.
  4. Open a chat with your new bot and press Start, so the bot is allowed to message you.
  5. In the Arduino Library Manager install UniversalTelegramBot by Brian Lough and ArduinoJson by Benoit Blanchon.

Complete Code

Telegram bot for ESP32-CAM: /photo sends a JPEG, /flash toggles flash for photos
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <UniversalTelegramBot.h>
#include <ArduinoJson.h>
#include "esp_camera.h"

const char *ssid     = "YourWiFiName";
const char *password = "YourWiFiPassword";
String BOT_TOKEN = "123456789:YOUR_BOT_TOKEN";
String CHAT_ID   = "111111111";      // your numeric user ID

// 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
#define FLASH_PIN       4

WiFiClientSecure client;
UniversalTelegramBot bot(BOT_TOKEN, client);
bool useFlash = false;
unsigned long lastPoll = 0;

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;
}

// Upload one fresh photo with sendPhoto (multipart/form-data)
bool sendPhotoTelegram() {
  if (useFlash) { digitalWrite(FLASH_PIN, HIGH); delay(150); }
  camera_fb_t *fb = esp_camera_fb_get();
  if (fb) esp_camera_fb_return(fb);      // discard stale frame
  fb = esp_camera_fb_get();
  digitalWrite(FLASH_PIN, LOW);
  if (!fb) return false;

  const String boundary = "ESP32CAMboundary";
  String head = "--" + boundary + "\r\n"
                "Content-Disposition: form-data; name=\"chat_id\"\r\n\r\n" +
                CHAT_ID + "\r\n--" + boundary + "\r\n"
                "Content-Disposition: form-data; name=\"photo\"; filename=\"esp32cam.jpg\"\r\n"
                "Content-Type: image/jpeg\r\n\r\n";
  String tail = "\r\n--" + boundary + "--\r\n";
  size_t totalLen = head.length() + fb->len + tail.length();

  client.stop();                         // close any connection left by the bot
  if (!client.connect("api.telegram.org", 443)) {
    esp_camera_fb_return(fb);
    return false;
  }
  client.print("POST /bot" + BOT_TOKEN + "/sendPhoto HTTP/1.1\r\n");
  client.print("Host: api.telegram.org\r\n");
  client.print("Content-Length: " + String(totalLen) + "\r\n");
  client.print("Content-Type: multipart/form-data; boundary=" + boundary + "\r\n");
  client.print("Connection: close\r\n\r\n");
  client.print(head);
  for (size_t i = 0; i < fb->len; i += 1024) {     // send JPEG in chunks
    size_t n = (fb->len - i < 1024) ? fb->len - i : 1024;
    client.write(fb->buf + i, n);
  }
  client.print(tail);
  esp_camera_fb_return(fb);

  // Read the status line, e.g. "HTTP/1.1 200 OK"
  unsigned long t0 = millis();
  while (!client.available() && millis() - t0 < 10000) delay(50);
  String status = client.readStringUntil('\n');
  client.stop();
  Serial.println("Telegram: " + status);
  return status.indexOf(" 200 ") > 0;
}

void handleMessages(int count) {
  for (int i = 0; i < count; i++) {
    String chatId = bot.messages[i].chat_id;
    String text = bot.messages[i].text;
    if (chatId != CHAT_ID) {             // ignore strangers
      bot.sendMessage(chatId, "Unauthorised user", "");
      continue;
    }
    if (text == "/start") {
      bot.sendMessage(CHAT_ID, "Commands: /photo, /flash", "");
    } else if (text == "/flash") {
      useFlash = !useFlash;
      bot.sendMessage(CHAT_ID, useFlash ? "Flash ON for photos" : "Flash OFF", "");
    } else if (text == "/photo") {
      if (!sendPhotoTelegram()) bot.sendMessage(CHAT_ID, "Photo failed", "");
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(FLASH_PIN, OUTPUT);
  digitalWrite(FLASH_PIN, LOW);
  if (!initCamera()) {
    Serial.println("Camera init failed");
    while (true) delay(1000);
  }
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" connected");
  client.setCACert(TELEGRAM_CERTIFICATE_ROOT);  // root CA from the library
  bot.sendMessage(CHAT_ID, "ESP32-CAM online. Send /photo", "");
}

void loop() {
  if (millis() - lastPoll > 1000) {
    int n = bot.getUpdates(bot.last_message_received + 1);
    while (n) {
      handleMessages(n);
      n = bot.getUpdates(bot.last_message_received + 1);
    }
    lastPoll = millis();
  }
}

Code Walkthrough

  • client.setCACert(TELEGRAM_CERTIFICATE_ROOT) makes the TLS connection verify Telegram's certificate using the root certificate bundled with UniversalTelegramBot. If Telegram changes its certificate chain, update the library.
  • bot.getUpdates(bot.last_message_received + 1) fetches only messages newer than the last one handled, so a command is never processed twice.
  • The multipart body has two parts: chat_id as a plain field and photo as a file. Content-Length must equal the exact byte count of head, image and tail, so the code calculates it before sending anything.
  • Writing 1024-byte chunks keeps TLS buffers small; the JPEG stays in PSRAM until the last chunk is sent.
  • Every incoming chat ID is compared with yours. Without this check, anyone who finds your bot's username could request photos of your home.

Testing and Extending the Bot

Before uploading the sketch, check the token and chat ID from a computer. Open https://api.telegram.org/bot<token>/getMe in a browser, replacing the placeholder with your token: a correct token returns your bot's name in JSON. Then send a message to the bot and open the getUpdates URL in the same way; the reply shows the chat object with the ID of the chat you wrote from. If these URLs do not work, the ESP32-CAM will not work either.

Note that while the browser test is open, the bot and the ESP32-CAM may compete for the same updates. Close the tab before you power the camera so the board receives every command.

Useful Additions

  • Automatic alerts: call sendPhotoTelegram() when a PIR sensor or a door switch on GPIO 13 changes state, with a cooldown of a minute so you are not flooded with photos.
  • Status command: reply to /status with the Wi-Fi signal from WiFi.RSSI(), the uptime from millis() and free PSRAM from ESP.getFreePsram().
  • Group chats: add the bot to a family group and use the group's chat ID, which is a negative number, in place of your personal ID.
  • Polling interval: one second gives a quick response; a longer interval reduces traffic and power use.

Troubleshooting

  • No reply to commands: the token is wrong, or you never pressed Start in the bot chat. Check the Serial Monitor for connection errors.
  • Status line shows 400 Bad Request: the chat ID is wrong or the multipart body is malformed; compare quotes and line endings with the code.
  • Status line empty: the TLS connection failed or timed out. Check the time-out, signal strength and that the certificate line is present.
  • Resets when sending: TLS plus Wi-Fi transmission draws current peaks; improve the 5 V supply.
  • Slow photos: lower the frame size to VGA; the upload time grows with file size.

Summary

A Telegram bot gives the ESP32-CAM secure remote access without opening router ports. UniversalTelegramBot polls for commands and sends text, while a hand-built multipart sendPhoto request streams the JPEG from the frame buffer in chunks. Restrict commands to your own chat ID, verify the TLS certificate, and the same structure can later send photos automatically when a PIR or door sensor triggers.