Introduction
Imagine a small service business that wants to capture leads directly from Telegram. Users start a chat with the bot, answer a couple of questions, and the bot stores their contact info for the sales team. If you build this naively, two things can go wrong:
* The bot accepts any update without checking its origin, letting a malicious user flood your database with fake leads. * When the bot sends many messages at once (e.g., broadcasting a promo), Telegram returns HTTP 429 with a Retry-After header. Ignoring it makes the bot crash or get temporarily blocked.
In this guide we’ll build a real‑world Node.js bot using grammY that:
* Loads the bot token from environment variables. * Implements a short conversation that collects a name and phone number, then saves the lead. * Shows both webhook and long‑polling modes, with idempotency protection for webhooks. * Demonstrates a robust way to handle 429 responses, including respecting Retry-After and queuing further requests.
All code is plain JavaScript; no TypeScript is required, but you can add types later if you wish.
---
1. Project setup and environment
Create a fresh folder and initialize npm:
mkdir tg-lead-bot && cd tg-lead-bot
npm init -y
npm i grammy dotenv
Add a .env file (never commit this):
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
WEBHOOK_SECRET=mySuperSecret64charsLongEnough
PORT=3000
BOT_TOKEN comes from @BotFather. WEBHOOK_SECRET is used when setting the webhook so Telegram can verify the request originates from you. PORT is the port your Express server will listen on.
Create index.js – this will be the entry point for both modes.
---
2. Basic bot with grammY: commands and conversation
We start by loading the token, creating the bot, and defining a simple /start command that greets the user and offers to leave a lead.
require('dotenv').config();
const { Bot, Conversation, FluentSession, session } = require('grammy');
const bot = new Bot(process.env.BOT_TOKEN);
// Enable session storage (in‑memory for demo; use Redis in production)
bot.use(session({ initial: () => ({}) }));
// /start – entry point
bot.command('start', async (ctx) => {
const payload = ctx.startParam; // deep‑link payload, if any
let greeting = 'Welcome! I can help you leave a request for our service.';
if (payload) {
greeting += ` You arrived with payload: ${payload}.`;
}
await ctx.reply(greeting, {
reply_markup: {
inline_keyboard: [
[{ text: 'Leave a lead', callback_data: 'lead_start' }]
]
}
});
});
The /start handler shows how to read the deep‑link payload (ctx.startParam). If you later use t.me/YourBot?start=promo2025, the bot can tailor the greeting.
Now we define a conversation that asks for a name and a phone number. grammY’s Conversation helper makes the flow linear and easy to test.
async function leadConversation(conversation, ctx) {
await ctx.reply('Great! Let’s collect your details.');
// Step 1: name
await ctx.reply('What is your name?');
const nameResp = await conversation.wait();
const name = nameResp.message.text.trim();
// Step 2: phone number
await ctx.reply('Please share your phone number (you can use the “Share Contact” button).');
const phoneResp = await conversation.wait();
let phone = '';
if (phoneResp.message.contact) {
phone = phoneResp.message.contact.phone_number;
} else {
phone = phoneResp.message.text.trim();
}
// Save the lead – we’ll implement the storage later
const leadId = await saveLead({ name, phone, userId: ctx.from.id });
await ctx.reply(`Thanks, ${name}! Your lead has been saved with ID ${leadId}. Our team will contact you shortly.`);
}
// Create a conversation wrapper
const leadConv = Conversation.from(leadConversation);
bot.use(leadConv);
// Callback query that starts the conversation
bot.callbackQuery('lead_start', async (ctx) => {
await ctx.answerCallbackQuery(); // required to remove the loading spinner
await ctx.conversation.enter('leadConversation');
});
What happens if you skip the answerCallbackQuery? Telegram shows a loading indicator next to the button until you respond. If you never call it, the user sees a perpetual spinner and may think the bot is broken.
---
3. Persisting leads and idempotency
For a demo we’ll store leads in a simple array, but in production you must use a durable store (PostgreSQL, MongoDB, Redis, etc.) and ensure that the same update isn’t processed twice.
Add the following helper near the top of index.js:
const crypto = require('crypto');
// In‑memory demo store – replace with a real DB
const leads = [];
// Store processed update_ids to guarantee idempotency (webhook mode)
const processedUpdates = new Set();
async function saveLead(lead) {
// Generate a short, unique ID: 7 random bytes → hex (14 chars)
const id = crypto.randomBytes(7).toString('hex');
leads.push({ id, ...lead, createdAt: new Date() });
// In a real app you would INSERT into a table here.
return id;
}
The saveLead function creates a random identifier that fits the requirement "lead ids: bin2hex(random_bytes(7)) then INSERT before sendMessage". The INSERT (here a push) happens before we reply to the user, guaranteeing the lead exists even if the reply fails.
Idempotency for webhooks When you use a webhook, Telegram may resend the same update if it doesn’t receive a 2xx response quickly enough. By storing the update_id we can ignore duplicates.
We’ll add a middleware that runs before any handler:
bot.use(async (ctx, next) => {
const id = ctx.update.update_id;
if (processedUpdates.has(id)) {
// Already handled – silently drop
return;
}
processedUpdates.add(id);
// Optional: periodically clean the set to avoid unlimited growth
if (processedUpdates.size > 10000) {
processedUpdates.clear();
}
await next();
});
Place this middleware before you register any command or conversation middleware.
---
4. Webhook vs long polling and handling 429
Now we expose two ways to run the bot. Choose one based on your hosting environment.
4.1 Webhook mode (recommended for production)
We’ll use a tiny Express server. Telegram will POST updates to /webhook/<secret>.
const express = require('express');
const app = express();
// Telegram sends JSON; we need to parse it
app.use(express.json());
// Webhook endpoint – the secret is part of the URL for extra protection
app.post(`/webhook/${process.env.WEBHOOK_SECRET}`, (req, res) => {
bot.handleUpdate(req.body).then(() => res.sendStatus(200));
});
// Health check
app.get('/', (req, res) => res.send('Bot is running'));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Webhook server listening on port ${PORT}`);
// Set the webhook once when the process starts
bot.api.setWebhook({
url: `https://your-domain.com/webhook/${process.env.WEBHOOK_SECRET}`,
secret_token: process.env.WEBHOOK_SECRET,
// Drop pending updates to start with a clean slate
drop_pending_updates: true,
}).then(() => console.log('✅ Webhook set'));
});
Why the secret token in the URL? Telegram will include the X-Telegram-Bot-Api-Secret-Token header equal to the secret_token you set. By also putting it in the path we make it harder for an attacker to guess the endpoint.
Production note – expiry and replay Telegram guarantees that updates are ordered, but if your server is down for more than a few seconds you may miss updates. The drop_pending_updates:true flag clears the queue when (re)setting the webhook, preventing a burst of old updates from flooding your system after a restart.
4.2 Long‑polling mode (simpler for development)
If you cannot expose a public URL (e.g., local machine), you can fall back to long polling. grammY makes this a one‑liner.
// Uncomment the block below to use polling instead of webhook
/*
bot.start({
// Timeout in seconds – how long the request waits for an update
timeout: 30,
// Limit how many updates we request per getUpdates call
limit: 100,
}).then(() => console.log('🟢 Long polling started'));
*/
When you run the file with node index.js, the bot will start polling. Remember to comment out the webhook‑setting block above, otherwise you’ll try to set a webhook while also polling, which leads to conflicts.
4.3 Handling 429 (Too Many Requests)
Telegram returns HTTP 429 when you exceed the rate limit (roughly 30 messages per second per bot). The response includes a Retry-After header with the number of seconds you must wait.
grammy’s built‑in clientRateLimit() middleware already does this, but we’ll show a manual implementation so you understand the mechanics and can adapt it to custom queues.
Add this middleware after the bot is instantiated but before you call bot.handleUpdate or bot.start.
// Simple in‑memory queue for outgoing API calls
const outgoingQueue = [];
let isProcessing = false;
async function processQueue() {
if (isProcessing || outgoingQueue.length === 0) return;
isProcessing = true;
while (outgoingQueue.length) {
const { method, args, resolve, reject } = outgoingQueue.shift();
try {
const result = await bot.api.call(method, ...args);
resolve(result);
} catch (err) {
// If we got a 429, respect Retry-After and retry later
if (err.response && err.response.statusCode === 429) {
const retryAfter = err.response.headers['retry-after'];
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 3000;
// Put the same call back at the front of the queue after a delay
setTimeout(() => outgoingQueue.unshift({ method, args, resolve, reject }), delay);
break; // stop processing until the delay passes
}
reject(err);
}
}
isProcessing = false;
// Process next tick in case new items were added while we waited
setImmediate(processQueue);
}
// Wrap every outgoing API call so it goes through the queue
bot.api.call = (method, ...args) => {
return new Promise((resolve, reject) => {
outgoingQueue.push({ method, args, resolve, reject });
setImmediate(processQueue);
});
};
Explanation * All outgoing calls (sendMessage, editMessageText, etc.) are intercepted and placed in outgoingQueue. * A worker (processQueue) processes the queue sequentially. * If a call results in 429, we read Retry-After, wait that amount of time, then re‑queue the same call at the front. * This guarantees we never hammer Telegram with requests while we are limited, and it works whether you are using webhooks or polling.
Production note – persistent queue The in‑memory queue will be lost if the process crashes. For a production bot you should persist the queue (e.g., in Redis) and have multiple worker instances consume it, ensuring no message is lost during a restart.
---
5. Production notes
* Environment – Never hard‑code tokens or secrets. Use .env in development and a secret manager (AWS Secrets Manager, Docker secrets, etc.) in production. * Update idempotency – The processedUpdates Set shown is fine for a demo; replace it with a Redis SET keyed by update_id with an expiration of a few minutes to survive restarts. * Lead storage – Persist leads in a relational table with columns id, name, phone, user_id, created_at. Add a unique index on id to avoid duplicates if the same lead is submitted twice. * Rate limits – Besides the global 30 messages/second limit, be aware of per‑method limits (e.g., sendMessage is stricter than getMe). The queue approach above protects all methods uniformly. * Error handling – Always catch errors from bot.api.call. Log them to a monitoring service (Sentry, Loggly) and notify admins if the error persists. * Graceful shutdown – When receiving SIGTERM, stop accepting new updates, wait for the queue to drain, then exit.
---
Running the bot
1. Copy the example .env to your project and fill in your real BOT_TOKEN and a random WEBHOOK_SECRET. 2. For webhook deployment: * Make sure your server is reachable via HTTPS (use a reverse proxy like Nginx or a service like Render/Heroku that provides TLS). * Run node index.js. The script will set the webhook automatically. 3. For local testing with polling: * Comment out the webhook block and uncomment the polling block in index.js. * Run node index.js and talk to your bot in Telegram.
You now have a functional lead‑capture bot that: * Validates the origin of updates (webhook secret + idempotency check). * Safely collects user data via a conversation. * Persists leads with a randomly generated ID before replying. * Handles Telegram’s 429 responses by respecting Retry-After and queuing requests.
BotCreator — studio that ships Telegram bots / Mini Apps.
Optional further reading: