← Blog/Telegram
Create a Telegram bot with webhooks
Create a BotFather bot, handle updates on a local Node.js weather server, and point Telegram’s webhook at a Mercur HTTPS URL so deliveries reach localhost.
Telegram can push every bot update to a URL you control. That is faster than polling
getUpdates, but the URL has to be public HTTPS — localhost cannot receive those POSTs.
This walkthrough builds a small weather bot in Node.js and uses Mercur so Telegram can reach
the process on your laptop.
What you will build
A bot that answers with the current temperature:
/start— short instructions- a city name such as
New York— lookup via Open-Meteo (no extra API key)
You will create the bot with @BotFather, run Express on port 3000, then point Telegram’s webhook at a Mercur HTTPS URL that forwards to that port.
Polling vs webhooks
Telegram supports two ways to receive updates. Polling means your process keeps calling
getUpdates. Webhooks mean Telegram POSTs each update as soon as it arrives. You cannot
use both at once — a leftover getUpdates loop will conflict with setWebhook.
- Your botRepeated GET getUpdates
- Telegram Bot APIReturns queued updates
- Telegram Bot APIPOST as soon as an update arrives
- Your HTTPS URLWebhook handler
Telegram’s webhook guide is the source for the TLS and port rules below. A tunnel does not change those rules; it just gives you a URL that already satisfies them.
What Telegram requires
A webhook URL must:
- Use HTTPS with TLS 1.2 or newer (plain HTTP is rejected)
- Listen on port 443, 80, 88, or 8443
- Be reachable over IPv4 (IPv6 webhooks are not supported)
You do not terminate TLS on the laptop, and you do not bind port 88 locally. Mercur
presents a public https:// URL on 443. Your Node process stays on http://127.0.0.1:3000.
Create the bot and get the token
Open Telegram, message @BotFather, run /newbot, and store the bot token as BOT_TOKEN in a local .env file — never in Mercur and never in git.- Open Telegram and message @BotFather
- Send
/newbot - Choose a display name, then a username that ends in
bot - Copy the token BotFather returns (
123456:ABC…)
Create a project folder and a .env file. Add .env to .gitignore.
BOT_TOKEN=123456:replace-me
WEBHOOK_SECRET=choose-a-long-random-string
PORT=3000WEBHOOK_SECRET is not the bot token. You will send it as secret_token to setWebhook.
Telegram then sets X-Telegram-Bot-Api-Secret-Token on every delivery so you can reject
traffic that did not come through your webhook.
Run a local Node.js weather server
Start an Express app on port 3000 that accepts POST /telegram/webhook, checks X-Telegram-Bot-Api-Secret-Token, and replies with sendMessage after looking up the city on Open-Meteo.{
"name": "telegram-weather-bot",
"private": true,
"type": "module",
"dependencies": {
"express": "^4.21.0"
}
}server.js:
import express from "express";
const token = process.env.BOT_TOKEN;
const secret = process.env.WEBHOOK_SECRET;
const port = Number(process.env.PORT ?? 3000);
const telegram = `https://api.telegram.org/bot${token}`;
if (!token || !secret) {
throw new Error("Set BOT_TOKEN and WEBHOOK_SECRET in the environment.");
}
const app = express();
app.use(express.json());
app.post("/telegram/webhook", async (req, res) => {
if (req.get("X-Telegram-Bot-Api-Secret-Token") !== secret) {
res.sendStatus(401);
return;
}
res.sendStatus(200);
const message = req.body?.message;
const chatId = message?.chat?.id;
const text = typeof message?.text === "string" ? message.text.trim() : "";
if (!chatId || !text) return;
try {
if (text === "/start") {
await sendMessage(chatId, "Send a city name, for example New York.");
return;
}
const city = text.replace(/^\/weather\s+/i, "");
const reply = await lookupWeather(city);
await sendMessage(chatId, reply);
} catch {
await sendMessage(chatId, "Could not fetch weather for that city.");
}
});
async function sendMessage(chatId, text) {
await fetch(`${telegram}/sendMessage`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }),
});
}
async function lookupWeather(city) {
const geoUrl = new URL("https://geocoding-api.open-meteo.com/v1/search");
geoUrl.searchParams.set("name", city);
geoUrl.searchParams.set("count", "1");
const geo = await fetch(geoUrl).then((r) => r.json());
const place = geo.results?.[0];
if (!place) return `No location found for "${city}".`;
const wxUrl = new URL("https://api.open-meteo.com/v1/forecast");
wxUrl.searchParams.set("latitude", String(place.latitude));
wxUrl.searchParams.set("longitude", String(place.longitude));
wxUrl.searchParams.set("current", "temperature_2m");
const wx = await fetch(wxUrl).then((r) => r.json());
const temp = wx.current?.temperature_2m;
const unit = wx.current_units?.temperature_2m ?? "°C";
return `${place.name}: ${temp}${unit}`;
}
app.listen(port, "127.0.0.1", () => {
console.log(`Weather bot listening on http://127.0.0.1:${port}`);
});Install and start (load .env however you prefer — node --env-file=.env server.js on
Node 20+):
npm install
node --env-file=.env server.jsThe handler answers 200 before it talks to Open-Meteo or sendMessage. Telegram retries
when the webhook is slow or non-2xx; a fast 200 avoids duplicate weather lookups.
Inspect a delivery on a public URL
Point setWebhook at a Mercur guest tester URL, send /start to the bot, and read the JSON update in request history. Guest mode records the POST and returns 204 without forwarding, so the bot will not reply yet.Open the webhook tester
Copy the temporary HTTPS URL and keep the path /telegram/webhook so it matches the server
you will run next.
curl -sS "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
--data-urlencode "url=$MERCUR_URL/telegram/webhook" \
--data-urlencode "secret_token=$WEBHOOK_SECRET"Send /start to the bot in Telegram. Open request history on the tester:
- method
POSTand path/telegram/webhook - JSON with
update_idandmessage.text - header
X-Telegram-Bot-Api-Secret-Token
Guest mode always responds 204 No Content and does not run your code. Telegram treats 2xx as
success, so the delivery looks fine on their side while the chat stays silent. That is
expected until you forward.
Forward Telegram updates to localhost
Create a Mercur Endpoint aimed at http://127.0.0.1:3000, connect the macOS app or npm CLI, and call setWebhook with the Endpoint URL plus /telegram/webhook and a secret_token.- Telegram appUser sends a city
- Telegram Bot APIHTTPS POST update
- MercurPublic HTTPS :443
- Local agentForwards to your machine
- Node.jshttp://127.0.0.1:3000
- Create a free Mercur account
- Start an HTTP Endpoint aimed at
http://127.0.0.1:3000 - Connect an agent and start forwarding
- Copy the Endpoint URL and call
setWebhookagain (this replaces the guest URL)
Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.
Replace the host with the one from the console — the subdomain below is a placeholder:
curl -sS "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
--data-urlencode "url=https://your-endpoint.mercur.sh/telegram/webhook" \
--data-urlencode "secret_token=$WEBHOOK_SECRET"Confirm what Telegram stored:
curl -sS "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"You want url matching the Endpoint, pending_update_count at 0, and an empty
last_error_message. Keep BOT_TOKEN in your shell environment — not in Mercur.
Message the bot and confirm the reply
Send a city name in Telegram, confirm the POST in Mercur request history, and read the weather reply in the chat. The sendMessage call goes to api.telegram.org and does not pass through Mercur.- Node.jssendMessage
- api.telegram.orgBot API — not via Mercur
- Telegram appWeather reply
Send New York (or /weather New York) in the chat. Mercur request history should show the same POST
Telegram sent. The bot’s sendMessage call goes straight to api.telegram.org, so you will
not see the reply in Mercur — only the inbound update.
After you fix a handler, you often want the same payload again. In the console, open request history for the Endpoint and choose Retry. Mercur re-sends the stored method, path, headers, and body through the public URL.
Requirements:
- Endpoint in proxy mode (not logging-only)
- Agent connected
- Payload still stored (within plan retention and size limits)
Troubleshooting
- 409 or
getUpdatesconflict — stop any polling process, then callsetWebhookagain. To go back to polling, clear the webhook:
curl -sS "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook"getWebhookInfoshowslast_error_message— wrong URL, agent disconnected, or your handler returned 4xx/5xx. Compare the URL ingetWebhookInfowith the Endpoint URL.- Delivery in Mercur, no Telegram reply — inbound worked; check
BOT_TOKEN, Open-Meteo connectivity, and thesendMessageresponse. Guest tester URLs never reachserver.js. - 401 in Mercur —
secret_tokenonsetWebhookdoes not matchWEBHOOK_SECRET. - Path mismatch — Telegram POSTs to the exact path in
setWebhook. The Express route is/telegram/webhook.
Related
- How to test webhooks
- Test webhooks locally
- Test Stripe webhooks
- Test GitHub webhooks
- Receive a webhook
- Expose a local service
- CLI agent
Inspect a Telegram delivery now