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