← 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.

Polling — your bot pulls
  1. Your botRepeated GET getUpdates
  2. Telegram Bot APIReturns queued updates
Webhook — Telegram pushes
  1. Telegram Bot APIPOST as soon as an update arrives
  2. 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.
  1. Open Telegram and message @BotFather
  2. Send /newbot
  3. Choose a display name, then a username that ends in bot
  4. 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=3000

WEBHOOK_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+):

Run
npm install
node --env-file=.env server.js

The 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.

Run
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 POST and path /telegram/webhook
  • JSON with update_id and message.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.
Inbound update
  1. Telegram appUser sends a city
  2. Telegram Bot APIHTTPS POST update
  3. MercurPublic HTTPS :443
  4. Local agentForwards to your machine
  5. Node.jshttp://127.0.0.1:3000
  1. Create a free Mercur account
  2. Start an HTTP Endpoint aimed at http://127.0.0.1:3000
  3. Connect an agent and start forwarding
  4. Copy the Endpoint URL and call setWebhook again (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:

Run
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:

Run
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.
Outbound reply — not via Mercur
  1. Node.jssendMessage
  2. api.telegram.orgBot API — not via Mercur
  3. 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 getUpdates conflict — stop any polling process, then call setWebhook again. To go back to polling, clear the webhook:
Run
curl -sS "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook"
  • getWebhookInfo shows last_error_message — wrong URL, agent disconnected, or your handler returned 4xx/5xx. Compare the URL in getWebhookInfo with the Endpoint URL.
  • Delivery in Mercur, no Telegram reply — inbound worked; check BOT_TOKEN, Open-Meteo connectivity, and the sendMessage response. Guest tester URLs never reach server.js.
  • 401 in Mercursecret_token on setWebhook does not match WEBHOOK_SECRET.
  • Path mismatch — Telegram POSTs to the exact path in setWebhook. The Express route is /telegram/webhook.

Related

Inspect a Telegram delivery now

telegramwebhookslocalhost