Tutorial 11 min read

Building a Telegram Bot with Node.js — Zero to Production

Mahmoud Hamdy
March 18, 2026

Telegram bots are one of the most developer-friendly automation tools available today. No approval process, no template reviews, no per-conversation fees. You create a bot in seconds through @BotFather, write your Node.js code, and you are live. I have built over a dozen Telegram bots — notification systems for e-commerce stores, admin panels for internal operations, customer-facing support bots — and the pattern is always the same: fast to build, easy to deploy, and incredibly reliable. This tutorial takes you from zero to a deployed production bot.

Why Telegram for Bots?

Telegram's bot API is free, has no rate limits on incoming messages (only outgoing: 30 messages/second per bot), supports rich media out of the box, and has 900 million monthly active users as of 2026. In the MENA region, Telegram has surged in popularity — particularly in Egypt, Saudi Arabia, and the UAE — making it a natural choice for customer-facing automation. The API is also significantly more developer-friendly than WhatsApp's: no approval needed, no templates required, and you can test locally without a public HTTPS endpoint (long polling works in development).

BotFather Setup

Creating a bot takes two minutes. Open Telegram, search for @BotFather, and send /newbot. You will be prompted for a name and a username (must end in "bot"). BotFather instantly returns your bot token — a string like 7234567890:AAHdqTcvCH1vGWJxfSeofSh0riPEiYZ5-OY. Store this in your .env file as TELEGRAM_BOT_TOKEN and never commit it to version control.

# .env
TELEGRAM_BOT_TOKEN=7234567890:AAHdqTcvCH1vGWJxfSeofSh0riPEiYZ5-OY
NODE_ENV=development

Project Setup with Telegraf

telegraf is the de-facto standard Node.js library for Telegram bots. It is well-maintained, TypeScript-native, and supports all Telegram Bot API features including scenes, sessions, and middleware. Install it alongside TypeScript:

npm init -y
npm install telegraf dotenv
npm install -D typescript ts-node @types/node nodemon
npx tsc --init
// src/index.ts
import { Telegraf } from 'telegraf';
import * as dotenv from 'dotenv';
dotenv.config();

const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);

bot.start(ctx => ctx.reply('Welcome! Send /help to see what I can do.'));
bot.help(ctx => ctx.reply('Commands:\n/start — Introduction\n/status — Bot status\n/menu — Main menu'));

bot.launch();
console.log('Bot is running...');

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

Registering Commands

Registering commands in @BotFather (via /setcommands) enables the blue "/" autocomplete menu in Telegram's interface. Your bot should register the same commands programmatically via the API for consistency:

// src/commands.ts
import { Telegraf } from 'telegraf';

export function registerCommands(bot: Telegraf) {
  bot.command('start', async ctx => {
    const name = ctx.from.first_name;
    await ctx.reply(
      `Hello ${name}! I am your store assistant.\n\n` +
      `Use /track to check an order\n` +
      `Use /menu for all options\n` +
      `Use /help if you need assistance`
    );
  });

  bot.command('status', async ctx => {
    await ctx.reply('All systems operational. Response time: < 1s.');
  });

  // Handle plain text (non-command) messages
  bot.on('text', async ctx => {
    const text = ctx.message.text.toLowerCase();
    if (text.includes('order') || text.includes('طلب')) {
      await ctx.reply('To track your order, please send /track followed by your order number.');
    } else {
      await ctx.reply('I did not understand that. Send /help for available commands.');
    }
  });
}

Inline Keyboards and Callbacks

Inline keyboards are buttons attached to a specific message. They are the most powerful UX tool in the Telegram bot toolkit. When a user taps a button, Telegram sends a callback_query event with the button's data string. You handle it with bot.action():

// src/menus.ts
import { Markup } from 'telegraf';

export function mainMenuKeyboard() {
  return Markup.inlineKeyboard([
    [
      Markup.button.callback('Track Order', 'track_order'),
      Markup.button.callback('My Orders', 'my_orders'),
    ],
    [
      Markup.button.callback('Browse Products', 'browse_products'),
      Markup.button.callback('Contact Support', 'contact_support'),
    ],
  ]);
}

export function registerCallbacks(bot: Telegraf) {
  bot.command('menu', async ctx => {
    await ctx.reply('What would you like to do?', mainMenuKeyboard());
  });

  bot.action('track_order', async ctx => {
    await ctx.answerCbQuery(); // Remove "loading" spinner
    await ctx.reply('Please enter your order reference number (e.g. ORD-1234):');
    // Set session state — handled in next message
  });

  bot.action('contact_support', async ctx => {
    await ctx.answerCbQuery();
    await ctx.reply(
      'Our support team is available 9AM–9PM (AST).\n' +
      'You can also email us at support@store.com',
      Markup.inlineKeyboard([
        [Markup.button.url('Send Email', 'mailto:support@store.com')],
        [Markup.button.callback('Back to Menu', 'main_menu')],
      ])
    );
  });

  bot.action('main_menu', async ctx => {
    await ctx.answerCbQuery();
    await ctx.editMessageReplyMarkup(mainMenuKeyboard().reply_markup);
  });
}

Session State with Telegraf Scenes

For multi-step conversations (like an order tracking flow that asks for an order number then shows the result), Telegraf's scene system is the cleanest approach. A scene is a self-contained conversation state that captures user input across multiple messages:

// src/scenes/track-order.ts
import { Scenes, Markup } from 'telegraf';
import { SallaClient } from '../salla/client';

export const trackOrderScene = new Scenes.WizardScene(
  'TRACK_ORDER',

  // Step 1: Ask for order number
  async ctx => {
    await ctx.reply('Enter your order number:');
    return ctx.wizard.next();
  },

  // Step 2: Fetch and display order
  async ctx => {
    if (!ctx.message || !('text' in ctx.message)) {
      await ctx.reply('Please enter a text order number.');
      return;
    }
    const orderId = ctx.message.text.trim();
    try {
      const salla = new SallaClient(process.env.SALLA_TOKEN!);
      const order = await salla.getOrderByReference(orderId);
      await ctx.reply(
        `Order #${order.reference_id}\n` +
        `Status: ${order.status.name}\n` +
        `Total: ${order.amounts.total.amount} SAR\n` +
        `Placed: ${new Date(order.date.date).toLocaleDateString('en-SA')}`
      );
    } catch {
      await ctx.reply('Order not found. Double-check the number and try again.');
    }
    return ctx.scene.leave();
  }
);

// Register in bot setup:
// const stage = new Scenes.Stage([trackOrderScene]);
// bot.use(session());
// bot.use(stage.middleware());

Integrating External APIs

Telegram bots become genuinely powerful when connected to external data sources. I have used this pattern to connect bots to Salla order APIs, weather services, database queries, and internal admin APIs. The key principle is to always handle API errors gracefully — if an external call fails, the bot should send a meaningful message, not crash silently:

// src/lib/safe-api-call.ts
export async function safeApiCall(
  fn: () => Promise,
  fallbackMessage: string
): Promise {
  try {
    return await fn();
  } catch (error) {
    console.error('[API Error]', error instanceof Error ? error.message : error);
    return null;
  }
}

// Usage in a command handler:
bot.command('stats', async ctx => {
  const stats = await safeApiCall(
    () => analyticsClient.getDailyStats(),
    'Could not load stats right now.'
  );

  if (!stats) {
    await ctx.reply('Stats are unavailable at the moment. Try again in a minute.');
    return;
  }

  await ctx.reply(
    `Today's stats:\n` +
    `Orders: ${stats.orderCount}\n` +
    `Revenue: SAR ${stats.revenue.toFixed(2)}\n` +
    `New customers: ${stats.newCustomers}`
  );
});

Webhook vs. Long Polling

Telegraf supports two modes. Long polling (bot.launch()) works out of the box for local development — the bot polls Telegram's servers for new updates every few seconds. It requires no public URL and is perfect for development. For production, webhooks are better: Telegram pushes updates to your server instantly, there is no polling overhead, and the bot is more responsive. Switch with one config change:

// src/index.ts
if (process.env.NODE_ENV === 'production') {
  const WEBHOOK_URL = process.env.WEBHOOK_URL!; // e.g. https://yourdomain.com/webhook
  bot.launch({
    webhook: {
      domain: WEBHOOK_URL,
      port: parseInt(process.env.PORT ?? '3000'),
    },
  });
} else {
  bot.launch(); // Long polling for local dev
}

Building a Store Notification Bot

This is the practical end-goal I build most often: a bot that notifies a merchant's Telegram channel every time a new order comes in from their Salla store. The bot runs alongside the webhook server. When a new order is received, it formats a rich message with order details and sends it to the merchant's private group or channel:

// src/notifications/store-alerts.ts
import { Telegraf } from 'telegraf';
import type { SallaOrder } from '../salla/types';

const ALERT_CHANNEL_ID = process.env.TELEGRAM_ALERT_CHANNEL!;

export async function sendNewOrderAlert(bot: Telegraf, order: SallaOrder): Promise {
  const itemLines = order.items
    .map(item => `  • ${item.name} × ${item.quantity} — SAR ${item.total}`)
    .join('\n');

  const message =
    `New Order Received\n\n` +
    `Order: #${order.reference_id}\n` +
    `Customer: ${order.customer.first_name} ${order.customer.last_name}\n` +
    `Phone: ${order.customer.mobile ?? 'N/A'}\n` +
    `Total: SAR ${order.amounts.total.amount}\n\n` +
    `Items:\n${itemLines}\n\n` +
    `Payment: ${order.payment_method}\n` +
    `Placed: ${new Date(order.date.date).toLocaleString('en-SA', { timeZone: 'Asia/Riyadh' })}`;

  await bot.telegram.sendMessage(ALERT_CHANNEL_ID, message, {
    parse_mode: 'HTML',
  });
}

Error Handling and Reliability

Telegram bots in production face two reliability challenges: Telegram API timeouts (rare but they happen) and unhandled promise rejections that crash the process. Telegraf has a built-in error handler:

bot.catch((err, ctx) => {
  console.error(`[Bot Error] User: ${ctx.from?.id}`, err);
  // Only reply if we are not already in an error handler to avoid loops
  ctx.reply('An error occurred. Please try again.').catch(() => {});
});

For deployment, I always use PM2 with --max-restarts 10 and a --restart-delay 3000. This handles transient errors without hammering the server. Log the restart events to catch patterns — if a bot restarts 10 times in an hour, something structural is wrong and needs investigation.

Deploying to a VPS

The full deployment on a fresh Ubuntu 22.04 VPS takes about 15 minutes:

# On the server
# 1. Install Node.js 20 via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc && nvm install 20

# 2. Clone your project
git clone https://github.com/yourname/telegram-bot.git && cd telegram-bot
npm install && npm run build

# 3. Create .env with production values
cp .env.example .env && nano .env

# 4. Install PM2 and start
npm install -g pm2
pm2 start dist/index.js --name telegram-bot
pm2 save && pm2 startup

# 5. If using webhooks — set up Nginx + Certbot for HTTPS
sudo apt install nginx certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com