· 7 min read

Building a Practical Telegram Bot Infrastructure: Webhooks, Queues, State, and Reliability

This tutorial shows how to design Telegram bot infrastructure that can survive real traffic, retries, and deployment changes. You’ll build a practical architecture with webhook ingestion, durable job processing, state storage, and operational safeguards.

If you are building on Telegram, the hard part is usually not sending a message or answering a callback query. The hard part is everything around it: how updates arrive, how you process them safely, how you keep state, and how you deploy without losing messages.

This tutorial focuses on a practical implementation for a Telegram bot that needs to be reliable under load. The goal is not to cover every API method, but to show a production-friendly shape you can actually use.

What we are building

We will design a bot architecture with four pieces:

  1. Webhook receiver that accepts Telegram updates quickly.
  2. Queue-backed worker that processes updates asynchronously.
  3. Persistent state store for user/session data.
  4. Operational controls for retries, idempotency, and observability.

This pattern works well for bots that do more than simple replies, especially when you need database calls, external APIs, and long-running tasks.


Why webhook-first is usually better

Telegram supports both long polling and webhooks. For a real service, webhooks are usually the better choice because:

  • they reduce latency
  • they avoid a constantly running polling loop
  • they fit standard deployment and scaling patterns
  • they make it easier to put a load balancer, reverse proxy, and queue in front of your logic

Long polling is fine for prototypes, but once you need multiple workers or a clean separation between ingestion and processing, webhooks are easier to operate.


The architecture

A simple production flow looks like this:

Telegram -> HTTPS webhook endpoint -> validation -> queue -> workers -> database / APIs -> Telegram response

Key rule: the webhook handler should do as little work as possible.

It should:

  • verify the request is valid
  • extract the update payload
  • store or enqueue it
  • return HTTP 200 quickly

Do not run business logic directly in the webhook process unless the bot is tiny and low-traffic. Telegram may retry if your endpoint is slow or fails, so keep the response path short.


Step 1: Set up the webhook receiver

Your webhook endpoint should accept JSON POST requests from Telegram. The handler should parse the Update object and hand it off to a queue.

A minimal Node.js example with Express:

import express from 'express';
import bodyParser from 'body-parser';
import Redis from 'ioredis';

const app = express();
const redis = new Redis(process.env.REDIS_URL);

app.use(bodyParser.json());

app.post('/telegram/webhook', async (req, res) => {
  try {
    const update = req.body;

    // Basic sanity check
    if (!update || typeof update !== 'object' || !update.update_id) {
      return res.sendStatus(400);
    }

    // Push raw update to a queue for later processing
    await redis.lpush('telegram:updates', JSON.stringify(update));

    // Acknowledge immediately so Telegram does not retry unnecessarily
    return res.sendStatus(200);
  } catch (err) {
    console.error('Webhook error:', err);
    return res.sendStatus(500);
  }
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Why enqueue raw updates?

Keeping the raw update gives you a durable audit trail and makes debugging easier. If your processing code changes later, you still have the original payload.

If you want stricter validation, validate the payload before enqueueing, but avoid expensive operations in the request path.


Step 2: Process updates in workers

A worker can safely spend time on business logic, database calls, and network requests. It should read from the queue, decode the update, and route it to the right handler.

Example worker loop:

import Redis from 'ioredis';
import axios from 'axios';

const redis = new Redis(process.env.REDIS_URL);
const botToken = process.env.BOT_TOKEN;

async function sendMessage(chatId, text) {
  await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
    chat_id: chatId,
    text,
  });
}

async function handleUpdate(update) {
  if (update.message?.text) {
    const chatId = update.message.chat.id;
    const text = update.message.text.trim();

    if (text === '/start') {
      await sendMessage(chatId, 'Welcome. Send me a command.');
      return;
    }

    await sendMessage(chatId, `You said: ${text}`);
  }
}

async function run() {
  while (true) {
    const item = await redis.brpop('telegram:updates', 0);
    const update = JSON.parse(item[1]);

    try {
      await handleUpdate(update);
    } catch (err) {
      console.error('Update processing failed:', err);
      // In a real system, move to a dead-letter queue or retry mechanism
    }
  }
}

run().catch(console.error);

Worker design notes

  • Run multiple worker processes if needed.
  • Keep handlers small and composable.
  • Separate transport logic from business logic.
  • Add retry handling for transient failures.

If your workload grows, Redis lists are enough for a small setup, but you may want a more durable queue like RabbitMQ, SQS, or Kafka depending on your stack.


Step 3: Make update handling idempotent

Telegram can retry webhooks if your endpoint times out, and your own queue system may also deliver messages more than once in failure cases. Your code should tolerate duplicates.

The simplest idempotency strategy is to store processed update_id values.

Example approach:

async function wasProcessed(updateId) {
  const key = `telegram:processed:${updateId}`;
  return (await redis.set(key, '1', 'NX', 'EX', 86400)) === null;
}

A better pattern is to do this in your database with a unique constraint.

For example:

  • create a telegram_updates table
  • store update_id as unique
  • insert before processing
  • skip if the insert fails due to duplication

That gives you a reliable way to deduplicate even if the queue or worker crashes.

Example table

CREATE TABLE telegram_updates (
  update_id BIGINT PRIMARY KEY,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  payload JSONB NOT NULL,
  processed_at TIMESTAMP NULL,
  status TEXT NOT NULL DEFAULT 'queued'
);

This table gives you visibility into every update and helps with replay, debugging, and monitoring.


Step 4: Manage state explicitly

Telegram bots usually need state for multi-step flows:

  • onboarding
  • form collection
  • approval workflows
  • payments
  • support tickets

Avoid storing state only in memory. A restarted process should not lose a user’s progress.

A practical state model

Store session state keyed by chat or user:

CREATE TABLE bot_sessions (
  chat_id BIGINT PRIMARY KEY,
  state TEXT NOT NULL,
  data JSONB NOT NULL DEFAULT '{}'::jsonb,
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Examples of state:

  • idle
  • awaiting_email
  • awaiting_confirmation
  • collecting_shipping_address

In your handler, read the current state, decide the next action, and update the session atomically.

Example state transition logic

async function handleMessage(chatId, text) {
  const session = await getSession(chatId);

  if (!session || session.state === 'idle') {
    if (text === '/register') {
      await setSession(chatId, 'awaiting_email', {});
      await sendMessage(chatId, 'Please send your email address.');
      return;
    }

    await sendMessage(chatId, 'Use /register to begin.');
    return;
  }

  if (session.state === 'awaiting_email') {
    if (!text.includes('@')) {
      await sendMessage(chatId, 'That does not look like an email. Try again.');
      return;
    }

    await setSession(chatId, 'awaiting_confirmation', { email: text });
    await sendMessage(chatId, `Confirm this email: ${text}? Reply yes or no.`);
    return;
  }

  if (session.state === 'awaiting_confirmation') {
    if (text.toLowerCase() === 'yes') {
      await completeRegistration(chatId, session.data.email);
      await clearSession(chatId);
      await sendMessage(chatId, 'Registration complete.');
      return;
    }

    await clearSession(chatId);
    await sendMessage(chatId, 'Registration cancelled.');
  }
}

This approach scales well because the bot’s behavior is driven by explicit state, not by hidden assumptions in memory.


Step 5: Handle callback queries and inline keyboards

A lot of Telegram bots are really state machines wrapped around buttons. If you use inline keyboards, you must handle callback queries promptly.

Example button flow:

async function handleCallbackQuery(callbackQuery) {
  const chatId = callbackQuery.message.chat.id;
  const data = callbackQuery.data;

  if (data === 'approve') {
    await answerCallbackQuery(callbackQuery.id, 'Approved');
    await sendMessage(chatId, 'Thanks. Your request was approved.');
    return;
  }

  if (data === 'reject') {
    await answerCallbackQuery(callbackQuery.id, 'Rejected');
    await sendMessage(chatId, 'Request rejected.');
    return;
  }
}

Remember:

  • answer callback queries quickly
  • use short callback data values
  • do not embed sensitive data directly in callback payloads
  • if you need rich context, store it server-side and reference it by ID

A safe pattern is to put a short token in callback_data and look up the full action in your database.


Step 6: Protect the webhook

A public webhook endpoint needs basic protection.

Use a secret path or secret token

Telegram supports a secret token header for webhooks. Verify it in your handler so random internet traffic cannot spoof updates.

Keep HTTPS enabled

Use a proper TLS certificate. In practice this means placing your bot behind a reverse proxy such as Nginx, Caddy, or a cloud load balancer.

Rate limit abusive clients

Telegram itself will be the main caller, but your endpoint should still guard against abuse. Add rate limiting at the proxy or application layer.

Reject invalid content

Only accept JSON POST requests. Reject anything that does not match the expected shape.


Step 7: Send messages safely

When sending messages to Telegram, treat the API as an external dependency.

Add timeouts and retries

Network hiccups happen. Use short timeouts and retry only for transient failures.

Respect rate limits

If you send many messages at once, batch your work and keep an eye on Telegram’s limits. Build a throttling layer if your bot can emit bursts.

Avoid duplicate sends

If your worker retries after an unknown failure, you may accidentally send the same message twice. One practical solution is to record outbound actions with a deduplication key.

Example table:

CREATE TABLE outbound_messages (
  id BIGSERIAL PRIMARY KEY,
  dedupe_key TEXT UNIQUE NOT NULL,
  chat_id BIGINT NOT NULL,
  payload JSONB NOT NULL,
  sent_at TIMESTAMP NULL
);

Before sending, insert the row. If it already exists, skip or resume safely.


Step 8: Add observability

If you cannot see what the bot is doing, debugging becomes guesswork.

Track at least these metrics:

  • webhook request count
  • queue depth
  • worker success/failure count
  • sendMessage success/failure count
  • update processing latency
  • callback query latency

Also log:

  • update_id
  • chat ID
  • handler name
  • processing time
  • error stack traces

Use structured logs if possible. A JSON log line is much easier to search than freeform text.

Example:

console.log(JSON.stringify({
  event: 'update_processed',
  update_id: update.update_id,
  chat_id: chatId,
  handler: 'handleMessage',
  duration_ms: 42,
}));

Step 9: Deploy without downtime headaches

A few deployment tips make Telegram infrastructure much easier to operate:

  • run the webhook server and workers as separate services
  • keep configuration in environment variables
  • use a managed database if you can
  • ensure the webhook endpoint returns quickly even during partial outages
  • deploy workers independently from the webhook API

If you need to update handler logic, roll out the workers gradually. Because updates are queued, you can usually update processing code without dropping requests.

For webhook changes, set the new URL carefully and verify that old and new instances are not both receiving updates unless that is intentional.


A useful folder structure

For a medium-sized bot, a structure like this works well:

src/
  webhook/
    server.js
    validate.js
  workers/
    update-worker.js
  handlers/
    message.js
    callback.js
  services/
    telegram.js
    sessions.js
    updates.js
  db/
    migrations/
    queries.js

This keeps transport, domain logic, and persistence separate.


Common mistakes to avoid

1. Doing too much in the webhook

If your webhook performs slow logic, Telegram may retry and you can get duplicates.

2. Keeping state in process memory

This breaks as soon as you restart or scale horizontally.

3. Ignoring idempotency

Retries happen. Design for them.

4. Sending messages without tracking outcomes

If a send fails, you need to know whether it failed before or after Telegram accepted it.

5. Mixing handlers and storage access everywhere

Centralize bot state and API logic so the code stays maintainable.


A practical checklist

Before shipping your bot, confirm that you have:

  • a webhook endpoint behind HTTPS
  • secret-token validation
  • a queue or durable handoff from webhook to worker
  • database-backed session state
  • duplicate update protection
  • outbound message deduplication where needed
  • logs with update IDs and chat IDs
  • retry handling for Telegram API calls
  • a plan for dead-letter or failed jobs

If those boxes are checked, your bot will behave much better than a simple single-process script.

Final thought

Telegram bots are easy to start and surprisingly easy to outgrow. The difference between a prototype and a reliable service is not the bot token or the first command handler. It is the infrastructure around update ingestion, state, retries, and observability.

If you build those pieces early, your bot will be much easier to scale, debug, and extend later.

← All posts