Telegram Bots

Building and Hosting a grammY Bot With Node.js

Build a grammY Telegram bot with error handling, sessions and graceful shutdown, then deploy it on Node.js with polling or webhooks — and run it on Bun or Deno.

On this page
  1. Set up the project
  2. A bot with proper error handling
  3. Filter queries
  4. Register the command menu
  5. Sessions that survive restarts
  6. Useful plugins for production
  7. Polling or webhook?
  8. Deploy on Kerit Cloud
  9. Running on Bun or Deno
  10. Structure a larger bot with composers
  11. Troubleshooting
  12. Summary

grammY is a modern Telegram bot framework for TypeScript and JavaScript, with excellent documentation, first-class types and a rich plugin ecosystem. It runs on Node.js, Deno and Bun. This guide builds a production-ready grammY bot and deploys it to a server that keeps it online 24/7.

Set up the project

mkdir tg-bot && cd tg-bot
npm init -y
npm install grammy

Add a start script and pin the Node.js engine in package.json:

{
  "name": "tg-bot",
  "main": "bot.js",
  "scripts": { "start": "node bot.js" },
  "engines": { "node": ">=18" },
  "dependencies": { "grammy": "^1.30.0" }
}

Kerit Cloud supports Node.js 16 through 22; grammY works best on a current LTS release such as 20 or 22.

A bot with proper error handling

const { Bot, GrammyError, HttpError } = require('grammy');

const bot = new Bot(process.env.BOT_TOKEN);

bot.command('start', (ctx) => ctx.reply('Welcome! Send me any text and I will echo it.'));
bot.command('help', (ctx) => ctx.reply('Commands: /start, /help'));
bot.on('message:text', (ctx) => ctx.reply(`You said: ${ctx.message.text}`));

bot.catch((err) => {
  const ctx = err.ctx;
  console.error(`Error while handling update ${ctx.update.update_id}:`);
  const e = err.error;
  if (e instanceof GrammyError) console.error('Telegram API error:', e.description);
  else if (e instanceof HttpError) console.error('Could not reach Telegram:', e);
  else console.error('Unknown error:', e);
});

process.once('SIGINT', () => bot.stop());
process.once('SIGTERM', () => bot.stop());

bot.start({
  onStart: (me) => console.log(`Polling as @${me.username}`),
});

bot.catch is essential. Without it, grammY stops the bot on the first unhandled error — so a single bad update can take a polling bot offline. With it, the error is logged and the bot keeps going.

The SIGTERM handler matters on a host: restarts and redeploys send SIGTERM, and bot.stop() lets grammY finish the current batch of updates and confirm them to Telegram, so they aren’t processed twice after the restart.

Filter queries

grammY’s bot.on() uses filter queries that are both readable and type-safe:

bot.on('message:photo', (ctx) => ctx.reply('Nice photo!'));
bot.on('callback_query:data', async (ctx) => {
  await ctx.answerCallbackQuery();              // always answer button presses
  await ctx.reply(`You chose ${ctx.callbackQuery.data}`);
});
bot.on(['message:sticker', 'message:animation'], (ctx) => ctx.reply('Fun!'));

Register the command menu

The command list users see in Telegram’s menu comes from setMyCommands. Set it once when commands change, not on every start:

await bot.api.setMyCommands([
  { command: 'start', description: 'Start the bot' },
  { command: 'help', description: 'Show help' },
]);

Sessions that survive restarts

grammY’s session plugin stores per-chat data. The default storage is in memory, which is wiped on every restart. For production, use a storage adapter:

const { session } = require('grammy');
const { FileAdapter } = require('@grammyjs/storage-file');

bot.use(session({
  initial: () => ({ count: 0 }),
  storage: new FileAdapter({ dirName: 'sessions' }),
}));

bot.command('count', (ctx) => {
  ctx.session.count++;
  return ctx.reply(`You've used this ${ctx.session.count} times.`);
});

The file adapter works well for small bots because Kerit Cloud storage persists across restarts. Larger bots should use a database or Redis adapter — grammY has official adapters for many backends. See storing Telegram bot state.

Useful plugins for production

  • @grammyjs/auto-retry — automatically retries requests that hit Telegram’s rate limits (429) after the requested delay.
  • @grammyjs/transformer-throttler — paces outgoing requests to stay under limits when broadcasting.
  • @grammyjs/runner — processes updates concurrently for high-volume polling bots, instead of one at a time.
  • @grammyjs/conversations — write multi-step dialogs as straightforward async functions.

Adding auto-retry is one line:

const { autoRetry } = require('@grammyjs/auto-retry');
bot.api.config.use(autoRetry());

Polling or webhook?

bot.start() uses long polling — no domain or certificate needed. For webhooks, use grammY’s webhookCallback with your web framework:

const express = require('express');
const { webhookCallback } = require('grammy');

const app = express();
app.use(express.json());
app.post('/telegram', webhookCallback(bot, 'express', { secretToken: process.env.WEBHOOK_SECRET }));
app.listen(process.env.PORT || 8080);

Then register the URL once with bot.api.setWebhook(url, { secret_token: process.env.WEBHOOK_SECRET }). Every Kerit Cloud Telegram server includes an HTTPS endpoint for this. Long polling vs webhooks explains when each makes sense.

Deploy on Kerit Cloud

  1. Push your project to GitHub, with node_modules/, .env and sessions/ in .gitignore.
  2. Create a server on the free plan or a paid Telegram bot plan with a Node.js 20 or 22 runtime.
  3. Upload your files or, on paid plans, connect the repository for automatic deploys on every push.
  4. Add BOT_TOKEN (and WEBHOOK_SECRET if you use webhooks) as environment variables.
  5. Set the start command to npm start and start the server.

Dependencies install from package.json. Watch the console for “Polling as @yourbot”, then message the bot.

Running on Bun or Deno

grammY runs unchanged on Bun, and on Deno via its https://deno.land/x/grammy or npm specifier. Kerit Cloud supports Bun and Deno runtimes as well as Node.js, so you can choose whichever you prefer. On Deno, grant the permissions the bot needs — typically network access and environment variables:

deno run --allow-net --allow-env bot.ts

Structure a larger bot with composers

A single bot.js gets unwieldy past a few hundred lines. grammY’s Composer lets you build features as separate modules and plug them into the bot, much like routers in a web framework:

// features/admin.js
const { Composer } = require('grammy');

const admin = new Composer();
const ADMINS = new Set((process.env.ADMIN_IDS || '').split(',').filter(Boolean).map(Number));

// Everything registered on this filtered composer only runs for admins.
const onlyAdmins = admin.filter((ctx) => ADMINS.has(ctx.from?.id));
onlyAdmins.command('stats', (ctx) => ctx.reply('Stats: all good.'));

module.exports = admin;
// bot.js
bot.use(require('./features/admin'));
bot.use(require('./features/echo'));

Order matters: middleware runs in the order it’s registered, and a handler that replies without calling next() ends the chain. Put session and logging middleware first, specific features next, and catch-all handlers such as an echo last.

Checking admins by numeric user ID rather than username is deliberate — usernames can be changed or claimed by someone else. More in securing your Telegram bot token and admin commands.

Troubleshooting

GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request) — another copy of the bot is polling. Stop the one on your computer, or check that you haven’t started the bot twice.

409: Conflict: can't use getUpdates method while webhook is active — a webhook is registered. Call bot.api.deleteWebhook() once, or pass drop_pending_updates if you want to skip the backlog.

The bot stops after one error. You haven’t registered bot.catch. Add it, as shown above.

Buttons keep showing a loading spinner. Call ctx.answerCallbackQuery() for every callback query, even when you have nothing to show.

Bad Request: can't parse entities — you’re sending text with a parse mode and unescaped user input. Escape it, or use grammY’s formatting helpers from the @grammyjs/parse-mode plugin.

High memory over time. In-memory sessions grow with every chat. Switch to a storage adapter, which also makes sessions survive restarts.

Summary

A production grammY bot registers a bot.catch handler so one bad update can’t stop it, stops cleanly on SIGTERM, answers every callback query, keeps sessions in persistent storage, and uses auto-retry to handle rate limits. Deploy it with npm start and a BOT_TOKEN variable on a host that keeps processes running, and switch to webhooks or the runner plugin only when your volume calls for it.