Articles › C#

Build a Telegram Bot with C# and .NET: From Polling to Webhooks

Build a Telegram bot in C#: BotFather setup, the Telegram.Bot library, commands, inline keyboards, conversation state and secure webhooks in ASP.NET Core.

Telegram bots are one of the fastest ways to give users a useful interface: no app to install, notifications built in, and a rich API with buttons, files and payments. Businesses use them for order notifications, support, internal tools and alerts. With C# and the Telegram.Bot library you can build a robust bot in an afternoon. This guide goes from a first "hello" to a webhook-based bot hosted in ASP.NET Core.

Step 1: create the bot with BotFather

  1. In Telegram, open @BotFather and send /newbot.
  2. Choose a display name and a username ending in bot (e.g. ShopHelperBot).
  3. BotFather gives you a token like 123456:ABC-DEF....

Warning: The token is a password — anyone with it controls your bot. Never commit it; keep it in user secrets, environment variables or a secret store. If it leaks, revoke it with /revoke in BotFather.

Also set the command list (/setcommands) so users see a menu:

start - Start the bot
orders - My recent orders
help - Help and support

Step 2: a minimal polling bot

dotnet new worker -n ShopBot
cd ShopBot
dotnet add package Telegram.Bot
dotnet user-secrets set "Bot:Token" "123456:ABC-DEF..."

Long polling asks Telegram for new updates in a loop — perfect for development and works behind NAT without a public URL.

using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;

public class BotWorker(ITelegramBotClient bot, IServiceScopeFactory scopes, ILogger<BotWorker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var me = await bot.GetMe(stoppingToken);
        logger.LogInformation("Bot @{Username} started", me.Username);

        await bot.ReceiveAsync(
            updateHandler: HandleUpdateAsync,
            errorHandler: HandleErrorAsync,
            receiverOptions: new ReceiverOptions { AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery] },
            cancellationToken: stoppingToken);
    }

    private async Task HandleUpdateAsync(ITelegramBotClient _, Update update, CancellationToken ct)
    {
        using var scope = scopes.CreateScope();
        var handler = scope.ServiceProvider.GetRequiredService<UpdateHandler>();
        await handler.HandleAsync(update, ct);
    }

    private Task HandleErrorAsync(ITelegramBotClient _, Exception ex, HandleErrorSource source, CancellationToken ct)
    {
        logger.LogError(ex, "Telegram polling error ({Source})", source);
        return Task.CompletedTask;
    }
}
// Program.cs
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(builder.Configuration["Bot:Token"]!));
builder.Services.AddScoped<UpdateHandler>();
builder.Services.AddHostedService<BotWorker>();
builder.Build().Run();

Notice that the handler is resolved in a scope per update — so it can use scoped services like a DbContext.

Tip: Method names vary between major versions of Telegram.Bot (older versions use GetMeAsync, SendTextMessageAsync). Check the version you install; the concepts are identical.

Step 3: handling commands and messages

public class UpdateHandler(ITelegramBotClient bot, IOrderService orders)
{
    public async Task HandleAsync(Update update, CancellationToken ct)
    {
        switch (update)
        {
            case { Message: { Text: { } text } message }:
                await HandleMessageAsync(message, text, ct);
                break;
            case { CallbackQuery: { } query }:
                await HandleCallbackAsync(query, ct);
                break;
        }
    }

    private async Task HandleMessageAsync(Message message, string text, CancellationToken ct)
    {
        var chatId = message.Chat.Id;
        var command = text.Split(' ', 2)[0].Split('@')[0].ToLowerInvariant();

        switch (command)
        {
            case "/start":
                await bot.SendMessage(chatId,
                    $"Hi {message.From?.FirstName}! 👋\nI can show your orders and connect you to support.",
                    replyMarkup: MainMenu(), cancellationToken: ct);
                break;

            case "/orders":
                var list = await orders.GetRecentAsync(chatId, ct);
                var body = list.Count == 0
                    ? "You have no orders yet."
                    : string.Join("\n", list.Select(o => $"• <b>#{o.Number}</b> — {o.Status} — {o.Total:N0}"));
                await bot.SendMessage(chatId, body, parseMode: ParseMode.Html, cancellationToken: ct);
                break;

            default:
                await bot.SendMessage(chatId, "I didn't understand that. Try /help.", cancellationToken: ct);
                break;
        }
    }
}

Use ParseMode.Html for formatting, and escape user-provided text (WebUtility.HtmlEncode) before inserting it, or a stray < breaks the message.

Step 4: inline keyboards and callbacks

Inline buttons appear under a message and send a callback query when tapped:

private static InlineKeyboardMarkup MainMenu() => new(new[]
{
    new[] { InlineKeyboardButton.WithCallbackData("📦 My orders", "orders"),
            InlineKeyboardButton.WithCallbackData("💬 Support", "support") },
    new[] { InlineKeyboardButton.WithUrl("🌐 Website", "https://example.com") },
});

private async Task HandleCallbackAsync(CallbackQuery query, CancellationToken ct)
{
    await bot.AnswerCallbackQuery(query.Id, cancellationToken: ct);   // stop the loading spinner

    switch (query.Data)
    {
        case "orders":
            await HandleMessageAsync(query.Message!, "/orders", ct);
            break;
        case "support":
            await bot.SendMessage(query.Message!.Chat.Id, "Write your question and our team will reply.", cancellationToken: ct);
            break;
    }
}

Callback data is limited to 64 bytes — store IDs ("order:1542"), not whole objects. Always call AnswerCallbackQuery, otherwise the button keeps spinning for the user. You can also edit the original message with EditMessageText for menu-style navigation.

Step 5: conversation state

Multi-step flows ("send your phone number", then "send your address") need state per chat:

public enum Step { None, AwaitingPhone, AwaitingAddress }

public class ConversationStore(IMemoryCache cache)
{
    public Step Get(long chatId) => cache.TryGetValue(chatId, out Step s) ? s : Step.None;
    public void Set(long chatId, Step step) => cache.Set(chatId, step, TimeSpan.FromMinutes(30));
}

In-memory state is fine for one instance; for restarts and multiple instances, store it in the database or Redis.

To ask for a phone number safely, use a contact-request button — Telegram sends the user's verified number:

var keyboard = new ReplyKeyboardMarkup(KeyboardButton.WithRequestContact("📱 Share my number")) { ResizeKeyboard = true, OneTimeKeyboard = true };

Step 6: webhooks with ASP.NET Core

In production, webhooks are more efficient: Telegram POSTs each update to your HTTPS endpoint.

app.MapPost("/telegram/{secret}", async (
    string secret, Update update, UpdateHandler handler,
    IConfiguration config, CancellationToken ct) =>
{
    if (secret != config["Bot:WebhookSecret"]) return Results.NotFound();
    await handler.HandleAsync(update, ct);
    return Results.Ok();
});

Register the webhook once at startup:

await bot.SetWebhook(
    url: $"https://bot.example.com/telegram/{config["Bot:WebhookSecret"]}",
    secretToken: config["Bot:HeaderSecret"],     // Telegram sends it in X-Telegram-Bot-Api-Secret-Token
    allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
    dropPendingUpdates: true);

Requirements and tips:

  • A public HTTPS URL with a valid certificate (ports 443, 80, 88 or 8443).
  • Verify the X-Telegram-Bot-Api-Secret-Token header so only Telegram can call the endpoint.
  • Respond fast. If handling takes long (reports, AI calls), queue the work (a Channel<Update> with a background worker) and return 200 immediately; otherwise Telegram retries and you may process updates twice.
  • You can't use polling and a webhook at the same time; call DeleteWebhook to go back to polling.

Sending notifications from your app

The same client can push messages from anywhere — for example, when an order ships:

public class OrderShippedNotifier(ITelegramBotClient bot)
{
    public Task NotifyAsync(long chatId, string orderNumber, CancellationToken ct) =>
        bot.SendMessage(chatId, $"🚚 Order <b>#{orderNumber}</b> has been shipped!", parseMode: ParseMode.Html, cancellationToken: ct);
}

A bot can only message users who have started it; store each user's chatId when they send /start (linked to their account, e.g. via a one-time deep link t.me/ShopHelperBot?start=TOKEN).

Limits and reliability

  • Roughly 30 messages per second overall and about 1 per second per chat; bulk sends need throttling. On 429 errors, wait for the retry_after value.
  • Users can block the bot — handle 403 Forbidden and mark them inactive.
  • Log every update ID and handle duplicates idempotently.
  • In regions where Telegram is filtered, the server hosting the bot must have reliable access to api.telegram.org.

Summary

Create the bot with BotFather and keep the token secret, use Telegram.Bot with a hosted service and scoped handlers, build menus with inline keyboards and always answer callbacks, persist conversation state for multi-step flows, and switch to secured webhooks with fast responses in production. With these patterns a bot becomes a reliable channel for notifications, support and internal tools.

FAQ

Polling or webhook?

Polling for development and simple internal bots; webhooks for production — they're instant, efficient and scale with your web server.

Can a bot receive files and photos?

Yes. Messages include Photo, Document, Voice etc.; download them with GetFile and DownloadFile. Bots can download files up to 20 MB with the standard API.

Generate a one-time token on your site, send the user to t.me/YourBot?start=TOKEN, and when the bot receives /start TOKEN, store their chatId with the matching account.