· 6 min read
Building a Reliable Telegram Bot Webhook Pipeline in Node.js
This guide shows how to implement a production-ready Telegram bot webhook pipeline in Node.js, with signature verification, idempotent update handling, background processing, and safe deployment patterns. The focus is on practical code and the engineering decisions that keep a bot stable under real traffic.
Telegram bots are often started as a small script, then quickly become a real service: they receive bursts of updates, retry behavior matters, webhook endpoints need to stay fast, and duplicate delivery can break stateful workflows. If you are building a bot that does more than reply to /start, you need an ingestion pipeline that is simple, observable, and resilient.
This guide walks through a practical implementation in Node.js + Express that you can use as the backbone for a production Telegram bot. The focus is on the parts that matter in real deployments:
- accepting Telegram webhooks safely
- validating and deduplicating updates
- separating request handling from business logic
- offloading slow work to a queue
- designing for retries and idempotency
- deploying without losing updates
Architecture overview
A good Telegram bot webhook stack should look like this:
- Telegram sends an update to your webhook endpoint.
- Your HTTP server validates and records the update quickly.
- A worker processes the update asynchronously.
- The worker calls Telegram’s send API or your internal services.
- State changes are idempotent, so retries do not corrupt data.
The key idea is that the webhook handler should do the minimum amount of work necessary to acknowledge delivery. Anything that can take time—database writes, external API calls, ML inference, file processing—should happen after the HTTP response is returned.
Project setup
Install dependencies:
npm init -y
npm install express axios ioredis bullmq dotenv
Suggested environment variables:
BOT_TOKEN=123456:ABCDEF...
WEBHOOK_SECRET=some-long-random-string
PUBLIC_BASE_URL=https://bot.example.com
REDIS_URL=redis://localhost:6379
PORT=3000
BOT_TOKENis your Telegram bot token.WEBHOOK_SECRETis a shared secret you use to verify incoming requests.PUBLIC_BASE_URLis the HTTPS URL Telegram can reach.REDIS_URLis used for deduplication and background jobs.
Note: Telegram webhook requests do not come with the same style of HMAC signatures as some other platforms. A common production pattern is to set a secret token via
setWebhookand validate theX-Telegram-Bot-Api-Secret-Tokenheader.
Setting up the webhook securely
Before handling updates, register your webhook with Telegram:
// set-webhook.js
import 'dotenv/config';
import axios from 'axios';
const url = `https://api.telegram.org/bot${process.env.BOT_TOKEN}/setWebhook`;
const payload = {
url: `${process.env.PUBLIC_BASE_URL}/telegram/webhook`,
secret_token: process.env.WEBHOOK_SECRET,
allowed_updates: ['message', 'callback_query'],
};
const res = await axios.post(url, payload);
console.log(res.data);
Run it once after deployment or whenever the webhook configuration changes.
Why secret_token matters
Telegram will include the configured secret in the X-Telegram-Bot-Api-Secret-Token header. Your server can reject requests without that header or with the wrong value. This is not a replacement for HTTPS, but it is an important check to avoid accidental or malicious traffic hitting your endpoint.
Webhook handler design
The webhook endpoint should:
- verify the secret token
- validate the payload shape lightly
- deduplicate by
update_id - enqueue the update for processing
- return
200 OKas fast as possible
Here is a practical Express implementation:
// server.js
import 'dotenv/config';
import express from 'express';
import Redis from 'ioredis';
import { Queue } from 'bullmq';
const app = express();
const redis = new Redis(process.env.REDIS_URL);
const updateQueue = new Queue('telegram-updates', {
connection: redis,
});
app.use(express.json({ limit: '1mb' }));
function verifyTelegramSecret(req, res, next) {
const secret = req.header('X-Telegram-Bot-Api-Secret-Token');
if (secret !== process.env.WEBHOOK_SECRET) {
return res.status(401).send('unauthorized');
}
next();
}
app.post('/telegram/webhook', verifyTelegramSecret, async (req, res) => {
const update = req.body;
if (!update || typeof update.update_id !== 'number') {
return res.status(400).send('invalid update');
}
// Deduplicate updates using Redis.
// Telegram can retry deliveries, and your app can also reprocess failures.
const dedupeKey = `tg:update:${update.update_id}`;
const seen = await redis.set(dedupeKey, '1', 'EX', 60 * 60 * 24, 'NX');
if (!seen) {
return res.status(200).send('duplicate ignored');
}
await updateQueue.add(
'process-update',
{ update },
{
jobId: String(update.update_id),
removeOnComplete: true,
removeOnFail: false,
}
);
return res.status(200).send('ok');
});
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
app.listen(process.env.PORT || 3000, () => {
console.log('server listening');
});
Why the queue matters
Telegram expects your webhook to respond quickly. If you try to do everything inside the HTTP request, you will eventually run into:
- timeouts under load
- duplicate retries when your endpoint is slow
- hard-to-debug partial failures
- cascading failures when an external dependency is down
By pushing work to a queue, you decouple update intake from update processing. This gives you a clean place to scale workers independently.
Worker implementation
Now create a worker that consumes jobs from BullMQ:
// worker.js
import 'dotenv/config';
import Redis from 'ioredis';
import { Worker } from 'bullmq';
import axios from 'axios';
const redis = new Redis(process.env.REDIS_URL);
async function sendMessage(chatId, text, extra = {}) {
const url = `https://api.telegram.org/bot${process.env.BOT_TOKEN}/sendMessage`;
return axios.post(url, {
chat_id: chatId,
text,
...extra,
});
}
const worker = new Worker(
'telegram-updates',
async job => {
const { update } = job.data;
if (update.message) {
await handleMessage(update.message);
} else if (update.callback_query) {
await handleCallbackQuery(update.callback_query);
} else {
// Ignore unsupported update types for now.
}
},
{ connection: redis }
);
async function handleMessage(message) {
const chatId = message.chat.id;
const text = message.text || '';
if (text === '/start') {
await sendMessage(chatId, 'Welcome. Send /help for commands.');
return;
}
if (text === '/help') {
await sendMessage(chatId, 'Available commands: /start, /help');
return;
}
await sendMessage(chatId, `You said: ${text}`);
}
async function handleCallbackQuery(callbackQuery) {
const chatId = callbackQuery.message?.chat.id;
if (!chatId) return;
await sendMessage(chatId, `Callback data: ${callbackQuery.data || ''}`);
}
worker.on('completed', job => {
console.log(`job completed: ${job.id}`);
});
worker.on('failed', (job, err) => {
console.error(`job failed: ${job?.id}`, err);
});
Idempotency: the part that saves you in production
Telegram can retry delivery when your endpoint fails, times out, or returns a transient error. Your queue can also re-run jobs after crashes. That means your handlers must be safe to execute more than once.
What to make idempotent
At minimum, these operations should be idempotent:
- creating a user record for a Telegram
chat.id - recording that a command was received
- moving a workflow to the next step
- sending an external side effect based on an update
Practical pattern
Use a unique key tied to the Telegram update or business entity. For example:
update_idfor raw update ingestionchat.id + command + datefor daily command processing- a business transaction ID for payment or ticket workflows
For database writes, use unique constraints and upserts rather than insert then hope.
Example with PostgreSQL-style logic:
CREATE TABLE bot_messages (
id BIGSERIAL PRIMARY KEY,
update_id BIGINT NOT NULL UNIQUE,
chat_id BIGINT NOT NULL,
text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Then in application code, treat duplicate update_id as a harmless replay.
Handling long-running tasks
Telegram interactions often trigger slow work:
- generating reports
- fetching third-party data
- rendering images
- downloading files
- calling internal workflows
Do not keep the user waiting in the webhook. Instead:
- acknowledge the command immediately
- enqueue the work
- send a follow-up message when done
Example:
async function handleMessage(message) {
const chatId = message.chat.id;
const text = message.text || '';
if (text === '/report') {
await sendMessage(chatId, 'Generating your report. I will reply shortly.');
// Trigger the long-running work in another queue/job.
// In a real app, you would enqueue this separately.
await generateReportForChat(chatId);
await sendMessage(chatId, 'Your report is ready.');
return;
}
}
async function generateReportForChat(chatId) {
await new Promise(resolve => setTimeout(resolve, 5000));
return { chatId };
}
In production, generateReportForChat should not run inline like this; it should be its own background job. The point is to keep the user-facing request path fast.
Respect Telegram rate limits
Telegram has rate limits at both the bot level and per chat. If your bot broadcasts messages or responds to bursts of callbacks, you need throttling.
A few practical rules:
- batch non-urgent messages
- avoid sending multiple messages when one would do
- back off on
429 Too Many Requests - inspect Telegram’s
retry_aftervalue when present
Example retry wrapper:
async function telegramRequest(fn, retries = 3) {
try {
return await fn();
} catch (err) {
const status = err.response?.status;
const retryAfter = err.response?.data?.parameters?.retry_after;
if (status === 429 && retries > 0) {
const delayMs = ((retryAfter || 1) * 1000);
await new Promise(resolve => setTimeout(resolve, delayMs));
return telegramRequest(fn, retries - 1);
}
throw err;
}
}
You can wrap sendMessage, editMessageText, and similar API calls with this helper.
Observability you will actually use
A Telegram bot is difficult to debug if you only log text strings. Add structured logs with the fields you need to trace an update:
update_idchat_iduser_idcommandjob_idstatus
Example:
console.log(JSON.stringify({
event: 'update_received',
update_id: update.update_id,
chat_id: update.message?.chat?.id,
user_id: update.message?.from?.id,
}));
If you use a proper logger such as Pino or Winston, make sure these values are included in every relevant log line.
Metrics worth tracking
At minimum:
- webhook request latency
- queue depth
- job failure rate
- Telegram API error rate
- duplicate update rate
These signals tell you whether failures are happening at intake, in processing, or in your outbound calls.
Deployment considerations
A webhook bot needs a few operational guarantees:
- HTTPS endpoint with a valid certificate
- stable public URL
- Redis or another durable queue backend
- clean process restarts
- worker restarts without message loss
Graceful shutdown
Your server and worker should shut down cleanly so in-flight jobs are not abandoned mid-processing.
process.on('SIGTERM', async () => {
console.log('shutting down');
await updateQueue.close();
await redis.quit();
process.exit(0);
});
If you run multiple replicas, make sure only one instance manages webhook configuration if your deployment process updates it automatically.
Common failure modes
1. Doing too much in the webhook handler
Symptom: Telegram retries, slow responses, elevated 5xx rates.
Fix: respond immediately and move work to a queue.
2. Assuming updates arrive exactly once
Symptom: duplicate messages, repeated database writes, broken state transitions.
Fix: deduplicate by update_id and make state changes idempotent.
3. Ignoring non-message updates
Symptom: inline buttons, callback queries, and edited messages appear to do nothing.
Fix: explicitly route the update types you support.
4. Hardcoding chat-specific behavior in one function
Symptom: the bot becomes unmaintainable as commands grow.
Fix: create a small router that dispatches to command handlers and callback handlers.
A simple command router
As the bot grows, move from if/else blocks to a clean routing layer:
const commandHandlers = {
'/start': async chatId => sendMessage(chatId, 'Welcome.'),
'/help': async chatId => sendMessage(chatId, 'Commands: /start, /help, /report'),
};
async function handleMessage(message) {
const chatId = message.chat.id;
const text = (message.text || '').trim();
const command = text.split(' ')[0];
if (commandHandlers[command]) {
await commandHandlers[command](chatId, message);
return;
}
await sendMessage(chatId, 'Unknown command. Send /help.');
}
This keeps command logic testable and makes it easier to add middleware-like steps such as authorization, locale lookup, or rate limiting.
Testing the pipeline
Do not only test the happy path.
Unit tests
Cover:
- secret token validation
- deduplication behavior
- command routing
- fallback behavior for unsupported update types
Integration tests
Simulate:
- duplicate webhook deliveries
- slow job processing
- Telegram API failures
- queue restarts
A good integration test sends the same update_id twice and confirms only one job is processed.
Practical next steps
Once the base pipeline works, consider adding:
- PostgreSQL for durable application state
- a job timeout strategy
- per-chat locking for workflows that must serialize
- support for inline keyboards and callback data parsing
- file download and media processing workers
- an admin-only command layer
Conclusion
A Telegram bot is easiest to maintain when you treat it like a small distributed system, not a script. The combination of secure webhook verification, fast request handling, queue-based processing, and idempotent handlers will save you from the most common production failures.
If you are building on Telegram today, start with the pipeline above and adapt the routing and worker logic to your product. The earlier you separate intake from processing, the easier it becomes to scale without surprises.