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
- In Telegram, open @BotFather and send
/newbot. - Choose a display name and a username ending in
bot(e.g.ShopHelperBot). - 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
/revokein 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-Tokenheader 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
DeleteWebhookto 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
429errors, wait for theretry_aftervalue. - Users can block the bot — handle
403 Forbiddenand 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.
How do I link Telegram users to accounts on my website?
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.