Project_12_ESP32_Telegram_Bot.ino. Needs UniversalTelegramBot, ArduinoJson (v6+), and the DHT libraries, plus your Wi-Fi and a bot token.
diagram.json above and paste its wiring in to load the circuit.
🎯 Objective
By the end of this project, you will:
- Use an existing chat app as your device's user interface: no page to design, nothing to install on the phone.
- Understand why polling an outside service reaches your board on networks where hosting a web server never would.
- Parse a text command and dispatch to the right action.
- Send a message the other way, unprompted, when a sensor event happens: a push notification from your own hardware.
- Meet HTTPS on a microcontroller, and see what a root certificate is for.
- Restrict who can control the board by checking the sender's chat id before acting on a command, the same authentication idea behind API keys and passwords.
- Compare three control models: local web server (Projects 5 to 9), cloud dashboard (Project 11), and chat (this one).
1How it works
Telegram runs a bot API over HTTPS. The ESP32 asks Telegram "any new messages?" once a second and acts on them, and it can also send you a message when something happens (motion).
Because it is just outbound HTTPS, this reaches your phone even on networks where a self-hosted web server would not.
| Command | What it does |
|---|---|
/status | temperature, humidity, light state |
/light_on · /light_off | switch the LED or relay |
/help | list the commands |
2Create your bot
- In Telegram, chat with @BotFather and send
/newbot. - Pick a name and a username ending in
bot. Copy the HTTP API token. - Open @userinfobot to get your numeric chat id. Setting it locks the bot to just you (Step 3 below) and lets it push motion alerts; leave it blank and the bot answers anyone who finds its username.
3Install the required libraries
From the Arduino Library Manager (setup: Foundation 3):
- UniversalTelegramBot (by Brian Lough)
- ArduinoJson (by Benoit Blanchon, version 6 or newer)
- DHT sensor library (Adafruit) and Adafruit Unified Sensor
4Parts and wiring
| Qty | Part | Notes |
|---|---|---|
| 1 | ESP32 · breadboard · jumper wires | as always |
| 1 | DHT11 | S to GPIO 4, + to 3.3 V, - to GND |
| 1 | LED + 220 Ohm or relay | to GPIO 26 |
| 1 | PIR (optional) | VCC to 5 V, OUT to GPIO 27, GND to GND |
5Set your details and upload
Edit the marked block: WIFI_SSID, WIFI_PASS, BOT_TOKEN, and CHAT_ID (leave it "" to accept commands from anyone and skip motion pushes, or set it to restrict the bot to just you). Upload, open Serial Monitor at 115200, then send /start to your bot in Telegram.
============================================== Project 12: ESP32 Telegram Bot ============================================== Connecting to Wi-Fi: MyNetwork .... Wi-Fi connected! Bot ready. Open Telegram and send /start to your bot.
6Code Walkthrough: Understanding the Sketch
More moving parts than anything before it, but each one is something you have already met.
Step 1: A secure client, and a bot that rides on it
#include <WiFi.h> #include <WiFiClientSecure.h> #include <UniversalTelegramBot.h> #include <ArduinoJson.h> WiFiClientSecure secured_client; UniversalTelegramBot bot(BOT_TOKEN, secured_client);
Two objects, stacked. WiFiClientSecure is a TCP connection with TLS on top: the https:// part. UniversalTelegramBot sits above it and knows Telegram's API, turning sendMessage() into the right HTTPS request. ArduinoJson is underneath both, decoding the replies, which is why the library needs it even though your code never mentions it.
Step 2: Trusting Telegram, in setup()
secured_client.setCACert(TELEGRAM_CERTIFICATE_ROOT);
HTTPS is only useful if the client can tell the real server from an impostor, and that check works by trusting a root certificate. Your laptop ships with hundreds. An ESP32 ships with none, so the library bundles the one Telegram's certificate chains up to and this line installs it. Skip it and every request fails to connect, even with a perfect token.
Step 3: Authorizing who can talk to your bot
String chat_id = bot.messages[i].chat_id; ... if (String(CHAT_ID).length() > 0 && chat_id != String(CHAT_ID)) { bot.sendMessage(chat_id, "Unauthorized user", ""); continue; }
The token in Step 1 stops outsiders from pretending to be your bot, but says nothing about who is allowed to talk to it: a bot's username is public, so anyone who finds it can open a chat and send /light_on. This check closes that gap with the same idea behind a login screen, comparing an identity (the sender's chat_id) against an allow list of one (CHAT_ID) before any command runs. continue skips straight to the next queued message, so a stranger gets one polite rejection and nothing else executes, just like the real bot below.

String(CHAT_ID).length() > 0 is the escape hatch: an empty placeholder short-circuits the whole check, so the sketch still runs for a first test before you have your id, just with an open bot.
Step 4: handleNewMessages() reads the text and dispatches
for (int i = 0; i < numNewMessages; i++) { String chat_id = bot.messages[i].chat_id; String text = bot.messages[i].text; String from = bot.messages[i].from_name; if (text == "/light_on") { digitalWrite(LED_PIN, HIGH); bot.sendMessage(chat_id, "Light is ON", ""); } ... else { bot.sendMessage(chat_id, "Unknown command. Send /help.", ""); } }
Several messages can be waiting, so the function takes a count and loops, running Step 3's check on each one before deciding anything else. Each message that passes carries who sent it (from_name), what they typed (text), and which conversation it came from (chat_id). Replying to chat_id rather than a hardcoded value still matters with a single authorized sender: set CHAT_ID to a group's id instead of a person's and this is what sends the reply back to that group.
The chain of if/else if is a command dispatcher, the same job the URL parsing did in Project 5. The final else matters: a bot that ignores what it does not understand feels broken, while one that says "send /help" teaches its own interface.
/status reads the sensor at the moment you ask, with the same isnan() guard as Project 9, and builds the reply with String(t, 1) so 24.63 prints as 24.6.
Step 5: loop() pushes on motion, then polls for commands
bool motion = digitalRead(PIR_PIN); if (motion && !lastMotion && String(CHAT_ID).length() > 0) { bot.sendMessage(CHAT_ID, "Motion detected!", ""); Serial.println("[PIR] motion -> alert sent"); } lastMotion = motion; if (millis() - lastBotCheck > BOT_CHECK_INTERVAL) { int numNewMessages = bot.getUpdates(bot.last_message_received + 1); while (numNewMessages) { handleNewMessages(numNewMessages); numNewMessages = bot.getUpdates(bot.last_message_received + 1); } lastBotCheck = millis(); }
motion && !lastMotion is Project 4's edge detection, doing real work here: without it, standing in front of the sensor would send a message every few milliseconds until Telegram rate-limited the bot.
The second half is polling. getUpdates(last_message_received + 1) asks "anything newer than the last one I saw?", and the + 1 is what stops the board replying to the same message forever. The inner while drains the queue, since a burst may not arrive in one response.
Note who does the asking: the ESP32 always opens the connection outwards, which is exactly why this works behind a router that would never let an incoming request through.
Key concepts: what each line really does
| Code | What it does |
|---|---|
WiFiClientSecure secured_client | A TCP connection with TLS on top. The s in https. |
setCACert(TELEGRAM_CERTIFICATE_ROOT) | Installs the root certificate the board needs to trust Telegram's server. |
UniversalTelegramBot bot(TOKEN, client) | Wraps the Telegram API: your bot's identity plus the secure connection it speaks over. |
bot.getUpdates(bot.last_message_received + 1) | Asks for messages newer than the last one handled, and returns how many arrived. |
bot.messages[i].chat_id | Which conversation a message came from. Reply here to answer the right person. |
chat_id != String(CHAT_ID) | True when the message came from someone other than the configured id: the actual identity check. |
bot.messages[i].text | What the user typed, ready to compare against your commands. |
bot.sendMessage(chat_id, text, "") | Sends a reply. The third argument is the parse mode, empty for plain text. |
motion && !lastMotion | Fires once on the rising edge, so one person walking past sends one alert. |
String(CHAT_ID).length() > 0 | The shared escape hatch: an empty id skips both the authorization check and the motion push. |
String(t, 1) | Formats a float to one decimal place for a readable reply. |
millis() - lastBotCheck > BOT_CHECK_INTERVAL | The non-blocking timer again, now pacing how often you poll Telegram. |
/light_on before you've set CHAT_ID, does the light turn on? Check Step 3's logic, then confirm on your own bot (with CHAT_ID still blank).7Demonstration

🎯 Knowledge Check
1. Why does the Telegram bot use outbound polling instead of hosting a web server?
2. What must you install to make HTTPS work on the ESP32?
3. How does the bot prevent unauthorized users from controlling it?
4. What does getUpdates(last_message_received + 1) do?
5. What pattern prevents Telegram rate-limiting when motion is detected?
9Wrapping Up: What You've Learned
You have built a device with a user interface you did not have to design, reachable from any network in the world. The key takeaways:
- The best UI is often one people already have. No app to install, no page to style, no instructions beyond
/help. - Outbound polling beats inbound hosting. Your board opens the connection, so home routers, campus Wi-Fi, and mobile networks all work. A web server reaches only the local network.
- The
+ 1is the whole trick. Asking for updates newer than the last one handled is how a polling client avoids replaying its own history. - Both directions matter. Replies are pull, motion alerts are push, and a device that can interrupt you is qualitatively more useful than one you have to check.
- Edge detection is not optional once a message costs something.
- HTTPS needs someone to trust. A microcontroller has no certificate store, so you install the root certificate yourself.
- A public bot still needs its own login check. The token secures the transport, but nothing stops a stranger from finding your bot and typing commands unless you check the sender's
chat_idyourself. - Compare the three models you have now built: a local page (fast, private, same network only), a cloud dashboard (history and charts, needs an account), and a chat bot (instant, personal, no history).
Now try
- Inline keyboard buttons: Replace the text commands with Telegram's inline keyboard buttons that users can tap instead of typing.
- Motion photo alert: When motion is detected, send a message with a timestamp and the current temperature reading alongside the motion alert.
10Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Bot never replies | Wrong token | Re-copy from BotFather |
Error on TELEGRAM_CERTIFICATE_ROOT | Wrong library | Use UniversalTelegramBot by Brian Lough |
| JSON compile error | Old ArduinoJson | Update to v6 or newer |
| Replies are slow | Normal | It polls once per second |
| Bot replies "Unauthorized user" | CHAT_ID does not match your chat | Re-check the id from @userinfobot, digits only, no spaces |
| No motion alerts | CHAT_ID empty or wrong | Get your id from @userinfobot |
| Wi-Fi never connects | 5 GHz network | ESP32 is 2.4 GHz only |
11Going further
- Add a command that returns more sensors: light from an LDR, the current motion state.
- Robust message parsing. The dispatcher in Step 4 matches commands with plain
==, which is exact: a stray space, different capitalization, or Telegram appending your bot's username in a group chat (/light_on@MyBotinstead of/light_on) all fail to match and fall through to "Unknown command". A slightly more forgiving dispatcher trims whitespace and strips anything from@onward before comparing:String text = bot.messages[i].text; text.trim(); // drop leading/trailing whitespace int at = text.indexOf('@'); if (at != -1) text = text.substring(0, at); // "/light_on@MyBot" -> "/light_on" if (text == "/light_on") { ... }
Do this once, right after readingtext, and every existingif/else ifbelow it keeps working unchanged. - Inline keyboards.
bot.sendMessage()replies with plain text; Universal Telegram Bot'ssendMessageWithInlineKeyboard()attaches tappable buttons to the message instead:String keyboardJson = "[[{ \"text\" : \"Light ON\", \"callback_data\" : \"/light_on\" }," " { \"text\" : \"Light OFF\", \"callback_data\" : \"/light_off\" }]]"; bot.sendMessageWithInlineKeyboard(chat_id, "Choose:", "", keyboardJson);
Each button carries acallback_datastring, which arrives back as its own update (withbot.messages[i].textbecoming that string, sent from a tap instead of typed), so the same dispatcher above can handle it with no extra code. This turns/help's list of commands into buttons a user can tap instead of memorize. - Rate limits. Telegram doesn't publish exact numbers, but the community‑verified limits are roughly 1 message per second to the same chat, 30 messages per second across different chats, and 20 messages per minute in a group. This project's edge‑detected motion alert and once‑a‑second poll already stay well inside those limits; the thing to watch is any feature that might reply to every message in a burst (like echoing every sensor change) without a check like
motion && !lastMotionto space it out. Telegram enforces this with an HTTP429 Too Many Requestsresponse and aretry_aftervalue telling you how long to wait. - Random Nerd Tutorials' Telegram: Control ESP32/ESP8266 Outputs is where the chat id allow list in Step 3 comes from; it also covers the ESP8266 side of the same library if you ever port a sketch to that board.
- Alvaro Morales' Telegram MQTT bot + ESP32 puts a Raspberry Pi running Node-RED between the bot and several ESP32s over MQTT, so one chat reaches many boards and, notably, warns you the moment one goes offline: a natural next step once Project 11's MQTT and this project's chat are both in your toolbox.
- Combine this with the Ubidots dashboard from Project 11: dashboard for history, Telegram for quick checks and alerts. Both patterns feed the capstone.