How to buildOctober 11, 20268 min read
APIs & IntegrationsStep 6 of 10

Telegram bot: Bot API, commands and a webhook

In short

A Telegram bot is a program that receives updates from Telegram’s servers and responds with Bot API calls over HTTPS using a token from @BotFather. Updates arrive either through getUpdates (long polling — handy during development) or through a webhook to your HTTPS endpoint that checks the secret_token header. In this tutorial a bot on Node.js 24 and grammY takes a request through buttons and forwards it to a manager, and it is verified by tests without a real token.

TutorialLevel: BeginnerTelegram BotsPractice: ~1.5–3 h

After this you will be able to

  • Create a bot in @BotFather and keep its token safely in environment variables
  • Handle the /start command, inline buttons with callback_data and a contact request button
  • Switch the bot from long polling to a webhook with secret_token and register the command menu
  • Handle Bot API errors and 429 responses and test the bot without a real token

Verified with

Node.js 24 LTS (24.18)grammY 1.46 (Bot API 10.3)@grammyjs/auto-retry 2.0TypeScript 7.0Vitest 5.0

Checked against docs: October 11, 2026

Contents
  1. 01How the Bot API works
  2. 02BotFather and the token
  3. 03Receiving updates: getUpdates or a webhook
  4. 04The project and handlers: commands, buttons, a request
  5. 05Running it: long polling for development, a webhook on the server
  6. 06Errors and limits
  7. 07Testing without a real token
  8. 08Exercises and next steps

How the Bot API works

This tutorial is for beginner backend developers and people who automate a business: you need to be able to run Node.js and understand HTTP and JSON. The scenario is a bot for a salon or a studio: a customer taps /start, picks a service with a button, leaves a phone number, and the managers get the request in their work chat.

A Telegram bot is a special account backed by your program. A customer writes to the bot, the message lands on Telegram’s servers, and your program fetches it as an Update object and answers by calling Bot API methods. Each call is an HTTPS request of the form https://api.telegram.org/bot<token>/METHOD_NAME. The response is always JSON with an ok field: on success the data is in result, on failure there is an explanation in description and sometimes a hint in parameters, such as how many seconds to wait.

  1. 01Customer in Telegram
  2. 02Telegram servers
  3. 03Your Node.js bot
  4. 04Managers’ chat
The bot never talks to the customer directly: everything goes through the servers and the Bot API.

You can write the requests by hand — the Bot API has no complicated authorisation. A library is more convenient, though: we’ll use grammY 1.46, which supports Bot API 10.3, provides types for every object and ready-made webhook adapters. The code runs on Node.js 24 without a build step.

bash
curl "https://api.telegram.org/bot$BOT_TOKEN/getMe"
curl "https://api.telegram.org/bot$BOT_TOKEN/getUpdates?timeout=30"
curl "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"
Raw Bot API calls: the token goes into the URL after the word bot (not run in this verification — no real token)

BotFather and the token

  1. Open @BotFather

    It is Telegram’s official bot for creating and managing bots.

  2. Send /newbot

    BotFather asks for a name (what users see) and a username. The username is 5–32 characters of Latin letters, digits and underscores and must end in bot, for example salon_almaty_bot. It can’t be changed later.

  3. Save the token

    A token like 123456789:AA… authorises the bot in the Bot API. Put it in .env on the server and in a password manager — not in the code and not in a chat.

  4. Create a separate bot for testing

    The Telegram documentation suggests testing changes on a second bot with its own token so real customers aren’t affected.

  5. Find the managers’ chat id

    Add the bot to the managers’ group and send a command there, for example /help@your_bot. While the bot isn’t running, open getUpdates from the example above — the group’s chat.id will be in the response.

.env.example
# Copy to .env and never commit .env
BOT_TOKEN=123456789:REPLACE_WITH_TOKEN_FROM_BOTFATHER
MANAGER_CHAT_ID=-1001234567890
# Empty: long polling (development). Set it on the server to switch to the webhook.
WEBHOOK_SECRET=
PUBLIC_URL=https://bot.example.kz
PORT=8080
Settings come from the environment; .env goes into .gitignore

Receiving updates: getUpdates or a webhook

There are two ways to receive updates and they are mutually exclusive: while a webhook is set, getUpdates doesn’t work. Telegram keeps undelivered updates for no longer than 24 hours.

getUpdates (long polling)

  • The bot asks Telegram itself and waits for an answer up to timeout seconds
  • No domain, HTTPS or open port needed — works from a laptop
  • An update is confirmed when the next request comes with an offset higher than its update_id
  • The bot process must run all the time

Webhook (setWebhook)

  • Telegram POSTs each Update to your HTTPS endpoint
  • Requires TLS 1.2+ and port 443, 80, 88 or 8443
  • The X-Telegram-Bot-Api-Secret-Token header proves the request came from your webhook
  • If you answer anything other than 2xx, Telegram redelivers — the handler must respond quickly
JSON
{
  "update_id": 1,
  "message": {
    "message_id": 1,
    "date": 1760000000,
    "chat": { "id": 1001, "type": "private", "first_name": "Айгерим" },
    "from": { "id": 1001, "is_bot": false, "first_name": "Айгерим" },
    "text": "/start",
    "entities": [{ "type": "bot_command", "offset": 0, "length": 6 }]
  }
}
An Update for the /start command — this exact shape is used in the tests

The update_id field increases sequentially and helps you drop a repeat if the same update arrives twice. Each update has at most one of the optional fields filled in: message, callback_query and others. With allowed_updates you can ask for only the types you need — our bot handles message and callback_query.

The project and handlers: commands, buttons, a request

bash
mkdir salon-bot && cd salon-bot
npm init -y && npm pkg set type=module
npm install grammy@1.46 @grammyjs/auto-retry@2
npm install -D typescript@7 vitest@5 @types/node
The project: grammY, the auto-retry plugin, TypeScript for type checking and Vitest
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Type checking only: Node 24 runs .ts files itself
src/leads.ts
export type Lead = {
  id: number;
  chatId: number;
  name: string;
  service: string;
  phone: string;
  createdAt: string;
  notified: boolean; // has the manager received it
};

// In-memory storage keeps the tutorial short. In production it is a database table:
// a lead must survive a restart and a failed notification.
export function createLeadStore() {
  const leads: Lead[] = [];
  return {
    add(data: Pick<Lead, 'chatId' | 'name' | 'service' | 'phone'>): Lead {
      const lead = { ...data, id: leads.length + 1, createdAt: new Date().toISOString(), notified: false };
      leads.push(lead);
      return lead;
    },
    all: () => leads,
    unnotified: () => leads.filter((l) => !l.notified),
  };
}

export type LeadStore = ReturnType<typeof createLeadStore>;
Lead storage: in memory for the tutorial, a database table in production
src/bot.ts
import { autoRetry } from '@grammyjs/auto-retry';
import { Bot, GrammyError, HttpError, InlineKeyboard, Keyboard, type Api, type BotConfig, type Context } from 'grammy';
import type { Lead, LeadStore } from './leads.ts';

export const SERVICES = [
  { id: 'haircut', title: 'Стрижка' },
  { id: 'manicure', title: 'Маникюр' },
  { id: 'consult', title: 'Консультация' },
];

const PHONE = /^\+?\d[\d ()-]{9,17}$/;

type Options = { managerChatId: number; leads: LeadStore; config?: BotConfig<Context> };

export function createBot(token: string, { managerChatId, leads, config }: Options) {
  const bot = new Bot(token, config);
  // On 429 waits retry_after seconds and repeats; also retries 5xx and network errors.
  bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));

  // chat id → chosen service. A real bot keeps this in a database or the sessions plugin.
  const drafts = new Map<number, string>();

  // Every handler below runs inside this boundary: an error is logged, the update counts
  // as handled, and the webhook gets 200 instead of an endless redelivery.
  const safe = bot.errorBoundary((err) => {
    const e = err.error;
    const why = e instanceof GrammyError ? `Bot API: ${e.description}` : e instanceof HttpError ? 'network' : e;
    console.error(`update ${err.ctx.update.update_id} failed:`, why);
  });

  const servicesKeyboard = () => {
    const kb = new InlineKeyboard();
    for (const s of SERVICES) kb.text(s.title, `svc:${s.id}`).row();
    return kb;
  };

  safe.command('start', (ctx) =>
    ctx.reply('Здравствуйте! Выберите услугу — администратор перезвонит и подберёт время.', {
      reply_markup: servicesKeyboard(),
    }),
  );

  safe.command('help', (ctx) => ctx.reply('/start — оставить заявку\n/cancel — отменить заявку'));

  safe.command('cancel', (ctx) => {
    drafts.delete(ctx.chat.id);
    return ctx.reply('Заявка отменена. Начать заново — /start', { reply_markup: { remove_keyboard: true } });
  });

  safe.callbackQuery(/^svc:(\w+)$/, async (ctx) => {
    const service = SERVICES.find((s) => s.id === ctx.match[1]);
    if (!service || !ctx.chat) return ctx.answerCallbackQuery({ text: 'Эта услуга недоступна' });
    drafts.set(ctx.chat.id, service.title);
    await ctx.answerCallbackQuery(); // stops the loading spinner on the button
    await ctx.reply(`${service.title}. Оставьте номер телефона — кнопкой ниже или сообщением.`, {
      reply_markup: new Keyboard().requestContact('Отправить номер').resized().oneTime(),
    });
  });

  // Buttons from old messages, or data we no longer understand.
  safe.on('callback_query:data', (ctx) => ctx.answerCallbackQuery({ text: 'Кнопка устарела, нажмите /start' }));

  safe.on(['message:contact', 'message:text'], async (ctx) => {
    const service = drafts.get(ctx.chat.id);
    if (!service) return ctx.reply('Чтобы оставить заявку, нажмите /start');

    const phone = (ctx.message.contact?.phone_number ?? ctx.message.text ?? '').trim();
    if (!PHONE.test(phone)) return ctx.reply('Не похоже на номер. Пример: +7 700 123 45 67');

    drafts.delete(ctx.chat.id);
    const lead = leads.add({ chatId: ctx.chat.id, name: ctx.from.first_name, service, phone });
    await notifyManager(bot.api, managerChatId, lead); // the lead is saved first: a failure here loses nothing
    await ctx.reply('Спасибо! Заявка принята, администратор свяжется с вами.', {
      reply_markup: { remove_keyboard: true },
    });
  });

  return bot;
}

export async function notifyManager(api: Api, managerChatId: number, lead: Lead) {
  try {
    // No parse_mode: the customer's name is plain text and can't break the markup.
    await api.sendMessage(
      managerChatId,
      `Заявка #${lead.id}\nУслуга: ${lead.service}\nИмя: ${lead.name}\nТелефон: ${lead.phone}`,
    );
    lead.notified = true;
  } catch (err) {
    // e.g. the bot was removed from the managers' group. A scheduled job retries unnotified leads.
    console.error(`lead ${lead.id}: manager not notified`, err instanceof GrammyError ? err.description : err);
  }
}
All the bot logic: commands, inline buttons, contact request, manager notification, errors
  • command('start') fires on /start: grammY looks at the bot_command entity in the message
  • InlineKeyboard puts buttons under a message. A press doesn’t post text to the chat; it sends a callback_query with callback_data of up to 64 bytes, so it carries a short service id, not the title
  • answerCallbackQuery is required for every press: until it is called, the button shows a loading indicator
  • Keyboard().requestContact() is a regular keyboard with a “Share number” button that works in private chats only. remove_keyboard hides it after the request
  • errorBoundary catches errors from every handler: the error is logged instead of crashing the bot or causing endless webhook redeliveries
  • The request is saved first and sent to the manager second: if the managers’ chat is unavailable, nothing is lost

Running it: long polling for development, a webhook on the server

src/main.ts
import { createBot } from './bot.ts';
import { createLeadStore } from './leads.ts';
import { createWebhookServer } from './webhook.ts';

const token = process.env.BOT_TOKEN; // from @BotFather, never in the code or the repository
const managerChatId = Number(process.env.MANAGER_CHAT_ID);
if (!token || !managerChatId) throw new Error('BOT_TOKEN and MANAGER_CHAT_ID are required');

const bot = createBot(token, {
  managerChatId,
  leads: createLeadStore(),
  // Unset in production. Set it to use a local Bot API server or a mock.
  config: { client: { apiRoot: process.env.TELEGRAM_API_ROOT } },
});

if (process.env.WEBHOOK_SECRET) {
  // Production: Telegram pushes updates to us. The webhook itself is set by set-webhook.ts.
  const port = Number(process.env.PORT ?? 8080);
  createWebhookServer(bot, process.env.WEBHOOK_SECRET).listen(port, () =>
    console.log(`webhook: http://localhost:${port}/telegram`),
  );
} else {
  // Development: long polling. bot.start() removes the webhook first: the two modes exclude each other.
  bot.catch((err) => console.error('polling error:', err.error));
  process.once('SIGINT', () => bot.stop());
  process.once('SIGTERM', () => bot.stop());
  await bot.start({
    allowed_updates: ['message', 'callback_query'],
    onStart: (me) => console.log(`@${me.username} is polling`),
  });
}
One entry point, two modes: long polling without WEBHOOK_SECRET, webhook with it
src/webhook.ts
import { createServer } from 'node:http';
import { webhookCallback, type Bot } from 'grammy';

// Telegram sends every update as a POST with the X-Telegram-Bot-Api-Secret-Token header;
// webhookCallback answers 401 if the header doesn't match the secret passed to setWebhook.
export function createWebhookServer(bot: Bot, secretToken: string, path = '/telegram') {
  const handleUpdate = webhookCallback(bot, 'http', { secretToken });

  return createServer(async (req, res) => {
    if (req.method !== 'POST' || req.url !== path) {
      res.writeHead(404).end();
      return;
    }
    try {
      await handleUpdate(req, res);
    } catch (err) {
      // Timeout (10 s by default) or an error outside errorBoundary: Telegram will redeliver.
      console.error('webhook failed:', err);
      if (!res.headersSent) res.writeHead(500).end();
    }
  });
}
Webhook server on node:http: one path, POST only, the secret token is checked by grammY
src/set-webhook.ts
import { Api } from 'grammy';

// Run once per deploy: node src/set-webhook.ts
const { BOT_TOKEN, PUBLIC_URL, WEBHOOK_SECRET, TELEGRAM_API_ROOT } = process.env;
if (!BOT_TOKEN || !PUBLIC_URL || !WEBHOOK_SECRET) {
  throw new Error('BOT_TOKEN, PUBLIC_URL and WEBHOOK_SECRET are required');
}

const api = new Api(BOT_TOKEN, { apiRoot: TELEGRAM_API_ROOT });

await api.setWebhook(`${PUBLIC_URL}/telegram`, {
  secret_token: WEBHOOK_SECRET, // 1–256 characters: A-Z, a-z, 0-9, _ and -
  allowed_updates: ['message', 'callback_query'], // only what the bot handles
});

// The command menu next to the input field.
await api.setMyCommands([
  { command: 'start', description: 'Оставить заявку' },
  { command: 'cancel', description: 'Отменить заявку' },
  { command: 'help', description: 'Помощь' },
]);

const info = await api.getWebhookInfo();
console.log(`webhook: ${info.url}, pending updates: ${info.pending_update_count}`);
Registering the webhook and the command menu
bash
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
A random secret_token: 64 hex characters fit the allowed alphabet
bash
node --env-file=.env src/main.ts          # development: long polling
node --env-file=.env src/set-webhook.ts   # on the server, once per deploy
npx tsc -p . && npx vitest run            # type check and tests, no token needed
Node 24 reads .env itself with --env-file
  1. Development

    Leave WEBHOOK_SECRET empty and run src/main.ts. Before polling, bot.start() calls deleteWebhook — the polling-mode test shows this too.

  2. Server

    You need a domain with HTTPS. Usually a reverse proxy such as Nginx holds the certificate and forwards requests to the app’s port.

  3. Secret

    Generate WEBHOOK_SECRET with the command above and set it in the server environment.

  4. Register the webhook

    Run src/set-webhook.ts: it calls setWebhook with secret_token and allowed_updates and sets the command menu via setMyCommands.

  5. Check

    getWebhookInfo shows the webhook URL and the number of queued updates; if the queue grows, the server isn’t answering or answers with errors.

Errors and limits

The Bot API returned ok: false

How it looks in grammY
GrammyError with error_code and description
What to do
Log it; a bad request won’t be fixed by retrying

Telegram is unreachable

How it looks in grammY
HttpError
What to do
Retry later — auto-retry does this

A limit was exceeded, response 429

How it looks in grammY
parameters.retry_after — how many seconds to wait
What to do
Wait and retry: auto-retry handles it

The bot was removed from the managers’ chat

How it looks in grammY
GrammyError with code 403
What to do
The request is already saved; resend on a schedule

A handler took longer than 10 seconds

How it looks in grammY
A timeout error in webhookCallback
What to do
Move long work into a queue

The limits named in the Telegram FAQ: in a single chat, no more than one message per second (short bursts are allowed, then 429 errors begin); in a group, no more than 20 messages per minute; bulk notifications, about 30 messages per second. The grammY documentation stresses that the exact limits are not published and change, so the only correct reaction to 429 is to wait retry_after seconds and retry.

The auto-retry plugin does exactly that: it intercepts a response with retry_after, waits and repeats the request, and also retries 5xx responses and network failures. The maxRetryAttempts and maxDelaySeconds options cap the number of attempts and the longest wait so the webhook handler doesn’t hang.

  • The token and the webhook secret live only in environment variables
  • Every inline button press gets answerCallbackQuery
  • The request is saved before the notification is sent
  • Handler errors are logged and don’t crash the process
  • On 429 the bot waits retry_after instead of retrying immediately
  • The webhook checks secret_token and accepts only POST on its path
  • Long operations are moved out of the handler

Testing without a real token

You need neither a token nor internet access to test the bot. grammY lets you set your own Bot API address via apiRoot — that’s how a local Bot API server is connected, and in tests, a mock. Our double imitates the methods we need, records every call and returns 429 or 403 on demand. The tests feed sample Updates into bot.handleUpdate and check what the bot sent back.

test/fake-telegram.ts
// A local imitation of the Bot API: POST /bot<token>/<method> → { ok, result }.
// It records every call and can be told to fail. Not Telegram — only what the bot uses.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';

type Call = { method: string; payload: Record<string, any> };
type Failure = { method: string; chatId?: number; times: number; status: number; body: object };

export async function startFakeTelegram() {
  const calls: Call[] = [];
  const failures: Failure[] = [];
  const updates: { update_id: number }[] = []; // queue for getUpdates (long polling)
  let webhookUrl = '';
  let messageId = 0;

  function result(method: string, p: Record<string, any>): unknown {
    switch (method) {
      case 'getMe':
        return { id: 42, is_bot: true, first_name: 'Salon', username: 'salon_test_bot', can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false };
      case 'sendMessage':
        return { message_id: ++messageId, date: Math.floor(Date.now() / 1000), chat: { id: p.chat_id, type: 'private' }, text: p.text };
      case 'setWebhook':
        webhookUrl = p.url;
        return true;
      case 'deleteWebhook':
        webhookUrl = '';
        return true;
      case 'getUpdates': {
        // An update is confirmed once getUpdates is called with a higher offset.
        while (updates.length && updates[0].update_id < (p.offset ?? 0)) updates.shift();
        return updates.slice(0, p.limit ?? 100);
      }
      case 'getWebhookInfo':
        return { url: webhookUrl, has_custom_certificate: false, pending_update_count: 0 };
      default:
        return true; // answerCallbackQuery, setMyCommands
    }
  }

  const server = createServer(async (req, res) => {
    const chunks: Buffer[] = [];
    for await (const c of req) chunks.push(c as Buffer);
    const method = (req.url ?? '').split('/').pop() ?? '';
    const payload = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
    calls.push({ method, payload });
    if (method === 'getUpdates' && updates.length === 0) await new Promise((r) => setTimeout(r, 200)); // a short "long poll"

    const failure = failures.find((f) => f.method === method && (f.chatId === undefined || f.chatId === payload.chat_id) && f.times > 0);
    if (failure) {
      failure.times--;
      res.writeHead(failure.status, { 'content-type': 'application/json' }).end(JSON.stringify(failure.body));
      return;
    }
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, result: result(method, payload) }));
  });
  await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));

  return {
    apiRoot: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
    calls,
    sent: (chatId?: number) => calls.filter((c) => c.method === 'sendMessage' && (chatId === undefined || c.payload.chat_id === chatId)),
    pushUpdate: (u: { update_id: number }) => void updates.push(u),
    /** Flood control: the next `times` calls get 429 with retry_after. */
    floodWait(method: string, retryAfter: number, chatId?: number, times = 1) {
      failures.push({ method, chatId, times, status: 429, body: { ok: false, error_code: 429, description: `Too Many Requests: retry after ${retryAfter}`, parameters: { retry_after: retryAfter } } });
    },
    /** Any other API error, e.g. 403 when the bot was removed from the chat (description is illustrative). */
    fail(method: string, status: number, description: string, chatId?: number, times = 1) {
      failures.push({ method, chatId, times, status, body: { ok: false, error_code: status, description } });
    },
    close: () => new Promise<void>((r) => server.close(() => r())),
  };
}
A local Bot API double: records calls, serves getUpdates, returns 429 and other errors on demand
test/bot.test.ts
import { execFile, spawn } from 'node:child_process';
import type { AddressInfo } from 'node:net';
import { promisify } from 'node:util';
import type { Update } from 'grammy/types';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { createBot } from '../src/bot.ts';
import { createLeadStore, type LeadStore } from '../src/leads.ts';
import { createWebhookServer } from '../src/webhook.ts';
import { startFakeTelegram } from './fake-telegram.ts';

const TOKEN = '123456:TEST-TOKEN-NOT-REAL'; // a made-up token: no request reaches api.telegram.org
const MANAGERS = -100200300; // the managers' group
const USER = { id: 1001, is_bot: false, first_name: 'Айгерим' };
const chat = { id: USER.id, type: 'private' as const, first_name: USER.first_name };

let tg: Awaited<ReturnType<typeof startFakeTelegram>>;
let leads: LeadStore;
let bot: ReturnType<typeof createBot>;
let updateId = 0;

// Sample updates shaped like the Update objects from the Bot API reference.
const text = (t: string): Update => ({
  update_id: ++updateId,
  message: {
    message_id: updateId, date: 1760000000, chat, from: USER, text: t,
    ...(t.startsWith('/') && { entities: [{ type: 'bot_command', offset: 0, length: t.split(' ')[0].length }] }),
  },
});
const press = (data: string): Update => ({
  update_id: ++updateId,
  callback_query: { id: `cb${updateId}`, from: USER, chat_instance: 'ci1', data, message: { message_id: 1, date: 1760000000, chat, text: 'menu' } as never },
});
const contact = (phone: string): Update => ({
  update_id: ++updateId,
  message: { message_id: updateId, date: 1760000000, chat, from: USER, contact: { phone_number: phone, first_name: USER.first_name, user_id: USER.id } },
});

beforeEach(async () => {
  tg = await startFakeTelegram();
  leads = createLeadStore();
  bot = createBot(TOKEN, { managerChatId: MANAGERS, leads, config: { client: { apiRoot: tg.apiRoot } } });
  await bot.init(); // getMe goes to the fake
});
afterEach(() => tg.close());

it('/start shows the services as inline buttons', async () => {
  await bot.handleUpdate(text('/start'));
  const [msg] = tg.sent(USER.id);
  const buttons = msg.payload.reply_markup.inline_keyboard.flat();
  expect(buttons.map((b: { callback_data: string }) => b.callback_data)).toEqual(['svc:haircut', 'svc:manicure', 'svc:consult']);
});

it('a button press is answered and asks for the phone', async () => {
  await bot.handleUpdate(press('svc:manicure'));
  expect(tg.calls.find((c) => c.method === 'answerCallbackQuery')?.payload.callback_query_id).toBe(`cb${updateId}`);
  const [ask] = tg.sent(USER.id);
  expect(ask.payload.text).toMatch(/^Маникюр/);
  expect(ask.payload.reply_markup.keyboard[0][0]).toEqual({ text: 'Отправить номер', request_contact: true });
});

it('a shared contact creates a lead and notifies the managers', async () => {
  await bot.handleUpdate(press('svc:manicure'));
  await bot.handleUpdate(contact('+77001234567'));
  expect(leads.all()).toMatchObject([{ service: 'Маникюр', phone: '+77001234567', notified: true }]);
  expect(tg.sent(MANAGERS)[0].payload.text).toBe('Заявка #1\nУслуга: Маникюр\nИмя: Айгерим\nТелефон: +77001234567');
  expect(tg.sent(USER.id).at(-1)?.payload.reply_markup).toEqual({ remove_keyboard: true });
});

it('accepts a typed phone and rejects anything else', async () => {
  await bot.handleUpdate(press('svc:haircut'));
  await bot.handleUpdate(text('завтра после обеда'));
  expect(tg.sent(USER.id).at(-1)?.payload.text).toMatch(/Не похоже на номер/);
  await bot.handleUpdate(text('+7 700 123 45 67'));
  expect(leads.all()).toHaveLength(1);
});

it('asks to press /start when there is no draft, answers stale buttons', async () => {
  await bot.handleUpdate(text('+7 700 123 45 67'));
  expect(tg.sent(USER.id)[0].payload.text).toMatch(/нажмите \/start/);
  await bot.handleUpdate(press('old:button'));
  expect(tg.calls.at(-1)).toMatchObject({ method: 'answerCallbackQuery', payload: { text: 'Кнопка устарела, нажмите /start' } });
});

it('waits retry_after seconds on 429 and repeats the request', async () => {
  tg.floodWait('sendMessage', 1, MANAGERS);
  await bot.handleUpdate(press('svc:consult'));
  const started = Date.now();
  await bot.handleUpdate(contact('+77001234567'));
  expect(Date.now() - started).toBeGreaterThanOrEqual(1000);
  expect(tg.sent(MANAGERS)).toHaveLength(2); // 429, then success
  expect(leads.all()[0].notified).toBe(true);
});

it('keeps the lead when the managers chat is unavailable', async () => {
  const log = vi.spyOn(console, 'error').mockImplementation(() => {});
  tg.fail('sendMessage', 403, 'Forbidden: bot is not a member of the chat', MANAGERS);
  await bot.handleUpdate(press('svc:consult'));
  await bot.handleUpdate(contact('+77001234567'));
  expect(leads.unnotified()).toHaveLength(1);
  expect(tg.sent(USER.id).at(-1)?.payload.text).toMatch(/Заявка принята/);
  expect(log).toHaveBeenCalledWith('lead 1: manager not notified', 'Forbidden: bot is not a member of the chat');
  log.mockRestore();
});

it('an API error inside a handler is logged, not thrown', async () => {
  const log = vi.spyOn(console, 'error').mockImplementation(() => {});
  tg.fail('sendMessage', 400, 'Bad Request: example error', USER.id);
  await expect(bot.handleUpdate(text('/start'))).resolves.toBeUndefined();
  expect(log.mock.calls[0][1]).toBe('Bot API: Bad Request: example error');
  log.mockRestore();
});

it('webhook: checks the secret token and handles the update', async () => {
  const server = createWebhookServer(bot, 'test-secret_123');
  await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
  const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
  const post = (path: string, headers: Record<string, string> = {}) =>
    fetch(url + path, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(text('/start')) });

  expect((await post('/telegram')).status).toBe(401);
  expect((await post('/telegram', { 'X-Telegram-Bot-Api-Secret-Token': 'wrong' })).status).toBe(401);
  expect((await post('/other', { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret_123' })).status).toBe(404);
  expect(tg.sent(USER.id)).toHaveLength(0);

  expect((await post('/telegram', { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret_123' })).status).toBe(200);
  expect(tg.sent(USER.id)).toHaveLength(1);
  await new Promise((r) => server.close(r));
});

it('set-webhook.ts registers the webhook and the command menu', async () => {
  const { stdout } = await promisify(execFile)(process.execPath, ['src/set-webhook.ts'], {
    env: { ...process.env, BOT_TOKEN: TOKEN, PUBLIC_URL: 'https://bot.example.test', WEBHOOK_SECRET: 'test-secret_123', TELEGRAM_API_ROOT: tg.apiRoot },
  });
  expect(stdout.trim()).toBe('webhook: https://bot.example.test/telegram, pending updates: 0');
  expect(tg.calls.find((c) => c.method === 'setWebhook')?.payload).toEqual({
    url: 'https://bot.example.test/telegram',
    secret_token: 'test-secret_123',
    allowed_updates: ['message', 'callback_query'],
  });
  expect(tg.calls.find((c) => c.method === 'setMyCommands')?.payload.commands).toHaveLength(3);
});

it('main.ts in polling mode: removes the webhook, then answers via getUpdates', async () => {
  const child = spawn(process.execPath, ['src/main.ts'], {
    env: { ...process.env, BOT_TOKEN: TOKEN, MANAGER_CHAT_ID: String(MANAGERS), TELEGRAM_API_ROOT: tg.apiRoot, WEBHOOK_SECRET: '' },
  });
  try {
    tg.pushUpdate(text('/start'));
    await vi.waitFor(() => expect(tg.sent(USER.id)).toHaveLength(1), { timeout: 5000 });
    const methods = tg.calls.map((c) => c.method);
    expect(methods.indexOf('deleteWebhook')).toBeLessThan(methods.indexOf('getUpdates'));
    // the next poll confirms the processed update by passing a higher offset
    await vi.waitFor(() => expect(tg.calls.filter((c) => c.method === 'getUpdates').at(-1)?.payload.offset).toBe(updateId + 1));
  } finally {
    child.kill('SIGTERM');
  }
});
Handler tests on sample Update objects, plus the webhook, set-webhook.ts and polling mode
terminal
$ npx vitest run --reporter=verbose
 ✓ test/exercise.test.ts > retryNotifications delivers leads that failed earlier
 ✓ test/bot.test.ts > /start shows the services as inline buttons
 ✓ test/bot.test.ts > a button press is answered and asks for the phone
 ✓ test/bot.test.ts > a shared contact creates a lead and notifies the managers
 ✓ test/bot.test.ts > accepts a typed phone and rejects anything else
 ✓ test/bot.test.ts > asks to press /start when there is no draft, answers stale buttons
 ✓ test/bot.test.ts > waits retry_after seconds on 429 and repeats the request
 ✓ test/bot.test.ts > keeps the lead when the managers chat is unavailable
 ✓ test/bot.test.ts > an API error inside a handler is logged, not thrown
 ✓ test/bot.test.ts > webhook: checks the secret token and handles the update
 ✓ test/bot.test.ts > set-webhook.ts registers the webhook and the command menu
 ✓ test/bot.test.ts > main.ts in polling mode: removes the webhook, then answers via getUpdates

 Test Files  2 passed (2)
      Tests  12 passed (12)
Real output of the check (timings removed)

Another approach from the grammY documentation is a transformer: a function in bot.api.config.use that intercepts API calls without an HTTP server. A mock server checks request serialisation and auto-retry more honestly; a transformer is simpler and faster.

Exercises and next steps

Next: how to keep tokens and secrets in the environment — in the article on secrets and environment variables; how to move long work out of a handler — in the article on background jobs; how webhooks work in general — in a separate guide.

How this was verified4 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.