Telegram bots are one of the fastest paths from "idea" to "working chatbot" in 2026. The Bot API is stable, the webhook model is simple, and grammY — the leading TypeScript-first framework — reduces boilerplate to almost nothing. Whether you're building a notification bot, a workflow tool, or a full conversational assistant, the same core patterns apply.
What changed in 2026
- grammY v2 is the stable release — breaking changes from v1 are mainly in session and plugin APIs; the middleware model is unchanged.
- Bot API 7.x added refined message reactions, story sharing, and improved inline query response types.
- Edge deployments work. grammY's
webhookCallback runs natively on Cloudflare Workers and Vercel Edge, making serverless Telegram bots practical.
- Long-polling still works fine for development. No need to expose a local port;
bot.start() handles it.
Project setup
mkdir telegram-bot && cd telegram-bot
npm init -y
npm install grammy
Create bot.mjs:
import { Bot } from 'grammy';
const bot = new Bot(process.env.BOT_TOKEN);
bot.command('start', (ctx) => ctx.reply('Hello! I am alive.'));
bot.command('ping', (ctx) => ctx.reply('Pong!'));
bot.start(); // long-polling for local dev
Get your BOT_TOKEN from @BotFather — /newbot, follow the prompts.
Registering commands
await bot.api.setMyCommands([
{ command: 'start', description: 'Start the bot' },
{ command: 'ping', description: 'Check latency' },
{ command: 'help', description: 'Show help' },
]);
Run this once at deploy time (not every boot). Commands appear in Telegram's / autocomplete UI.
Switching to webhooks for production
import { Bot, webhookCallback } from 'grammy';
import express from 'express';
const bot = new Bot(process.env.BOT_TOKEN);
// ... register handlers ...
const app = express();
app.use(express.json());
app.post(`/webhook/${process.env.BOT_TOKEN}`, webhookCallback(bot, 'express'));
app.listen(3000);
// Tell Telegram where to send updates:
await bot.api.setWebhook(`https://your-domain.com/webhook/${process.env.BOT_TOKEN}`);
Including the token in the webhook path provides a simple shared secret — Telegram will only call that URL, and random callers won't know it.
Sessions for per-user state
import { Bot, session } from 'grammy';
const bot = new Bot(process.env.BOT_TOKEN);
bot.use(session({ initial: () => ({ step: 0, answers: [] }) }));
bot.command('quiz', async (ctx) => {
ctx.session.step = 1;
await ctx.reply('Question 1: What is 2 + 2?');
});
bot.on('message:text', async (ctx) => {
if (ctx.session.step === 1) {
ctx.session.answers.push(ctx.message.text);
ctx.session.step = 0;
await ctx.reply(`Got it: ${ctx.message.text}`);
}
});
For persistence, plug in @grammyjs/storage-redis or any grammY storage adapter — sessions survive restarts.
Deployment comparison
| Platform |
Polling or webhook |
Cold start |
Cost |
| VPS (Railway, Hetzner) |
Polling or webhook |
None |
~$5/mo |
| Cloudflare Workers |
Webhook only |
~0ms |
Free tier generous |
| Vercel Edge |
Webhook only |
~50ms |
Free tier generous |
| AWS Lambda |
Webhook only |
~200ms |
Pay per invocation |
For bots that need persistent state (DB connections, sessions), a VPS or container is simpler. For stateless notification bots, Workers/Vercel Edge are ideal.
How to start
- Create a bot with @BotFather; save the token.
- Install grammY; wire up a minimal
bot.command('start', ...).
- Run
bot.start() locally to test via long-polling.
- Deploy to your chosen platform; call
setWebhook with the public URL.
- Register commands with
setMyCommands as a one-time deploy step.
Common mistakes
Handling bot.on('message') without guards. Every message — including group messages to other bots — will trigger it. Use bot.on('message:text') or filter by ctx.chat.type.
Forgetting await on ctx.reply(). grammY is async throughout; missing await means errors are silently swallowed.
Polling in production. Long-polling opens a persistent HTTP connection per process. Under load, switch to webhooks.
Storing the token in version control. Use dotenv locally and environment secrets in CI/CD.
What to skip
- node-telegram-bot-api — unmaintained, callback-based, no TypeScript types by default.
- Storing conversation state in module-level variables — it breaks on multi-instance deploys. Use sessions.
- DIY webhook secret validation — let grammY's
webhookCallback handle it; it checks Telegram's X-Telegram-Bot-Api-Secret-Token header automatically.
FAQ
Do I need a domain for a webhook?
Yes — Telegram requires HTTPS with a valid certificate. Use Let's Encrypt, Cloudflare, or a platform that provisions TLS automatically. For local dev, long-polling needs no domain.
Can the same bot talk to multiple chats?
Yes. ctx.chat.id identifies the conversation. Sessions are keyed per chat (or per user) depending on your getSessionKey function.
How do I send proactive messages (not in response to a user)?
Call bot.api.sendMessage(chatId, text) directly. Store chatId from the first /start interaction.
Is grammY production-proven?
Yes — grammY powers thousands of production bots. Its middleware stack, typed context, and plugin ecosystem are mature.
Where to go next