Telegram
Connect to Telegram with support for groups, channels, inline keyboards, and a polling fallback for local development.
Create a new bot
Scaffold a Next.js bot with the Telegram adapter using create-chat-sdk:
pnpm create chat-sdk@latest my-bot --adapter telegram memory -yThe generated app uses the Memory state adapter for local development. To add the adapter to an existing app, start at Install.
Install
pnpm add @chat-adapter/telegram @chat-adapter/state-memoryQuick start
The adapter auto-detects TELEGRAM_ALLOWED_USER_IDS, TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET_TOKEN, TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS, TELEGRAM_BOT_USERNAME, and TELEGRAM_MENTION_ON_REPLY from the environment.
For managed credentials, see Vercel Connect.
The memory state adapter keeps subscriptions and locks in process memory, which suits local development. Use Redis or PostgreSQL in production.
Register the webhook URL with the Telegram Bot API:
Authentication
Use Vercel Connect to manage the outbound bot token, or provide the token directly. Both options use Telegram's native webhook verification; polling does not require a webhook secret.
Vercel Connect
Use Vercel Connect to resolve the outbound Telegram bot token at runtime:
Connect does not forward Telegram webhooks. Keep
TELEGRAM_WEBHOOK_SECRET_TOKEN for native webhook verification, or use
polling mode without an inbound webhook. TELEGRAM_BOT_TOKEN is not needed
when using connectTelegramAdapter.
Create the connector and attach it to your project with the Vercel CLI. Telegram webhooks go directly to your app, so omit --triggers. vercel env pull downloads a development OIDC token for local runs:
Bot token
Create a bot via BotFather and provide its token directly to the adapter:
- Send
/newbotand follow the prompts. - Copy the token to
TELEGRAM_BOT_TOKEN. - Optionally pick a username and copy it to
TELEGRAM_BOT_USERNAME.
Configuration
| Prop | Type | Default | Description |
|---|---|---|---|
allowUnverifiedWebhooks? | boolean | false | Accept webhook requests without secret-token verification. Auto-detected from `TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS=true`. Use only for local development or behind a trusted verifying proxy. |
allowedUserIds? | Array<number | string> | Telegram user IDs allowed to trigger the adapter. Auto-detected from `TELEGRAM_ALLOWED_USER_IDS` (comma-separated). All users are allowed when omitted or empty. | |
botToken? | string | (() => string | Promise<string>) | Telegram bot token or resolver invoked for each Bot API request. Auto-detected from `TELEGRAM_BOT_TOKEN`. | |
secretToken? | string | Webhook secret required in webhook mode unless unverified webhooks are explicitly allowed. Auto-detected from `TELEGRAM_WEBHOOK_SECRET_TOKEN`. | |
mode? | "auto" | "webhook" | "polling" | "auto" | Adapter mode. `auto` uses webhooks on serverless and polling everywhere else. |
longPolling? | LongPollingOptions | Long polling tuning. Fields: `timeout`, `limit`, `allowedUpdates`, `deleteWebhook`, `dropPendingUpdates`, `retryDelayMs`. | |
businessMode? | boolean | false | Enable Telegram Business mode for Connected Business Bots. Handles `business_connection`, `business_message`, and `edited_business_message` updates and passes `business_connection_id` on outbound API calls. |
mentionOnReply? | boolean | false | Treat a reply to one of the bot's own messages as a mention, so it routes to `onNewMention`. Telegram users usually continue a conversation by replying instead of repeating `@name`. Auto-detected from `TELEGRAM_MENTION_ON_REPLY=true`. Implicit forum-topic replies and the bot's own messages never count. |
userName? | string | Bot username for mention detection. Auto-detected from `TELEGRAM_BOT_USERNAME` or `getMe`. | |
nativeStreaming? | boolean | false | Use Telegram's native draft previews for streamed posts in private chats. Defaults to post-and-edit in every chat type. Config only, no env var. |
persistThreadHistory? | boolean | true | Save incoming messages and the bot's own posts in the Chat state adapter so `thread.messages` and `channel.messages` return earlier turns. Telegram has no Bot API for reading chat history, so with `false` nothing is saved in either direction and handlers see only messages cached in the current process. Turn it off when your app keeps its own transcript. Config only, no env var. |
streamingEditIntervalMs? | number | 1100 private, 3100 other | Minimum interval between edits on the post-and-edit streaming path. Acts as a floor, so a lower Chat-level `streamingUpdateIntervalMs` is raised to this value. Config only, no env var. |
longMessages? | "truncate" | "split" | "truncate" | How text posts longer than Telegram's message limit are sent. `truncate` cuts them with an ellipsis; `split` sends the full text as consecutive messages. See [Long messages](#long-messages). Config only, no env var. |
apiUrl? | string | Override the Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL`. |
botToken is always required. Webhook mode also requires secretToken unless allowUnverifiedWebhooks is explicitly enabled. Polling mode does not require webhook verification.
Environment variables
| Variable | Required | Description |
|---|---|---|
TELEGRAM_BOT_TOKEN | Yes, unless using Vercel Connect | Bot token from BotFather. |
TELEGRAM_WEBHOOK_SECRET_TOKEN | In webhook mode, unless unverified webhooks are allowed | Webhook secret token. |
TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS | No | Set to true to accept webhooks without secret-token verification. Use only for local development or behind a trusted verifying proxy. |
TELEGRAM_ALLOWED_USER_IDS | No | Comma-separated user IDs allowed to trigger the adapter. All users are allowed when unset. |
TELEGRAM_BOT_USERNAME | No | Bot username for mention detection. Falls back to getMe. |
TELEGRAM_MENTION_ON_REPLY | No | Set to true to treat replies to the bot's messages as mentions. |
TELEGRAM_API_BASE_URL | No | Override the Telegram API base URL. |
Webhooks
Telegram sends the secret_token you registered with setWebhook in the X-Telegram-Bot-Api-Secret-Token header, and the adapter compares it with secretToken. Webhook mode requires secretToken unless allowUnverifiedWebhooks is explicitly enabled.
Accepted webhook updates with an integer update_id are deduplicated for 24 hours through the configured state adapter, so use shared durable state across serverless instances. If state is unavailable, the adapter returns 503 so Telegram retries without dispatching.
Polling for local development
Polling and webhooks are mutually exclusive in Telegram. mode: "polling" deletes the webhook by default before calling getUpdates.
Each long polling getUpdates request has a deadline of longPolling.timeout (30 seconds by default) plus 30 seconds. A getUpdates request that doesn't wait for new updates has a 30 second deadline, and so do the getMe and deleteWebhook requests. If a connection drops without a reset, for example after a network switch or a suspend and resume, the request fails at the deadline instead of waiting for the operating system to close the socket, which can take 15 minutes or more. The adapter then logs a Telegram polling request failed warning whose error field is NetworkError: Telegram getUpdates request timed out, backs off exponentially from longPolling.retryDelayMs, and polls again on a new connection. Requests your handlers make, such as sending a message, have no deadline.
Polling waits for message, command, action and reaction processing before advancing its offset. If a handler fails, the adapter must successfully save the failed update and next offset in bot-scoped state before acknowledging it. Saved updates retry independently so one recipient's failure cannot pin the shared polling offset. A handler can return after saving its input to your durable queue; it does not need to wait for model execution.
Albums are buffered in bot-scoped state so parts arriving in separate polling responses can be combined. The adapter saves the parts and offset before acknowledging them, retains the buffer until processing succeeds, and resumes saved buffers when polling starts again. Album grouping uses a short settling window because Telegram does not provide an end-of-album event.
Failed updates and albums retain their input and retry independently with exponential backoff, starting at a minimum of one second and capped at thirty seconds, or longer when Telegram specifies retry_after. Retry deadlines survive restarts with persistent state. Polling continues between saved-update batches, so a rejected reply does not indefinitely block new updates for other chats. Repeated failures are logged; the adapter does not discard failed updates or guarantee exactly-once handler execution. Later updates can finish before earlier failed updates.
Use a persistent state adapter, such as Redis or PostgreSQL, for recovery across process restarts. Memory state is suitable for local development but cannot preserve acknowledged failed updates or album parts after process loss. Run only one polling instance per bot. Handlers must tolerate redelivery: a crash after an application side effect but before completion is saved can repeat that side effect. Handlers must also finish or reject: a handler that never settles can still hold up its polling batch.
Auto mode
mode: "auto" (the default) checks getWebhookInfo: if a webhook URL is set, it uses webhook mode; otherwise it falls back to polling on long-running runtimes. If getWebhookInfo fails, the adapter stays in webhook mode, which is the safe fallback.
Streaming
Streams use post-and-edit by default for consistent behavior across Telegram clients. To opt into native draft previews in private chats:
Telegram clients should dismiss a draft preview when the final message arrives, but draft rendering varies between clients. Keep the default when your bot must work consistently across Telegram clients.
Telegram recommends at most one message per second in a single chat and limits groups to 20 messages per minute. Sends and edits share flood control, so the post-and-edit path defaults to 1100ms between operations in private chats and 3100ms in other chats. This is a floor: setting a lower streamingUpdateIntervalMs on your Chat instance does not push the adapter past it. Override it with streamingEditIntervalMs:
If Telegram rate limits the final edit, the adapter waits and retries when the requested delay is 5 seconds or less. Longer delays and failed retries reject the post so it never reports text that Telegram did not receive.
Markdown formatting
On Telegram Bot API 10.1 and newer, explicit { markdown } and { ast } messages use rich messages. Standard markdown gains native headings, lists, tables, task lists, formulas, details, and separate media blocks where supported by Telegram.
Plain strings, raw messages, cards, and media captions retain their existing lightweight message paths. Cards and captions use Telegram's MarkdownV2 parse mode with context-aware escaping. Older or custom Bot API servers automatically fall back to this existing path when rich message methods are unavailable.
MarkdownV2 fallback messages and captions that fit the length limit ship exactly as rendered, including backticks and other special characters inside link destinations. When a length limit cuts through a link, code span, or underline, the incomplete entity is removed before the ellipsis.
Pass { raw: "..." } only if you need to ship a fully pre-escaped MarkdownV2 string.
Long messages
Telegram limits a message to 4,096 characters, or 32,768 for rich messages. By default, the adapter truncates longer text posts and ends them with an ellipsis. MarkdownV2 escaping counts toward the limit, so heavily punctuated markdown is cut earlier than its source length suggests.
To send the full text instead, set longMessages: "split":
Posts that fit the limit are still sent as one message. Longer posts are sent as consecutive messages in order, each within the limit. The adapter splits between paragraphs and other blocks where possible, then at line breaks or spaces, and reopens formatting such as bold text and code blocks in the next message. Ordered lists keep their numbering, and a split table repeats its header row in each message when the header fits beside a body row.
To stay within Telegram's per-chat limits, the adapter waits about 1 second between messages in private chats and about 3 seconds in groups and channels. A 40-message post to a group takes about two minutes, so make sure your function's maximum duration allows for it. If Telegram still rate limits a message after the first one is delivered, the adapter waits for the retry_after period and sends it again, up to three attempts. It gives up when Telegram asks for a wait longer than 30 seconds, when the third attempt is also rate limited, or when the waits for one post would add up to more than 60 seconds. post() then throws an AdapterRateLimitError, and the messages already sent stay in the chat. A rate limit on the first message throws right away, before anything is sent, as it does for a post that fits in one message.
With split, only the first message replies to the original message, and post() returns the last message, so edits, reactions, and deletes apply to that message only. Splitting applies to text posts. Cards, media captions, edits, and the message edited during post-and-edit streaming still truncate.
Slash commands
Use bot.onSlashCommand to handle Telegram bot commands such as /status and /status@mybot. Commands addressed to another bot are ignored as slash commands and continue through the normal message path.
Attachments
Incoming file attachments expose a lazy fetchData() served from the configured Bot API host. Downloads are limited to 25 MB and time out after 30 seconds. They use the Web Fetch API, so file downloads keep working in runtimes like Cloudflare Workers.
Incoming attachments preserve Telegram's downloadable file_id and stable file_unique_id as fetchMetadata.fileId and fetchMetadata.fileUniqueId. Photo attachments use the image/jpeg MIME type.
Incoming media groups are delivered as one message after the album settles, with attachments ordered by Telegram message ID and the shared caption preserved.
Multiple files or compatible attachments are sent as Telegram media groups. files upload as documents; attachments preserve image, audio, video, or file media type.
Threads
By default, each Telegram chat is one thread, telegram:{chatId}. A message that Telegram marks with is_topic_message belongs to a topic, and each topic is its own thread, telegram:{chatId}:{messageThreadId}. The Bot API sets that field for topics in a forum supergroup and in a private chat with the bot. Business conversations use telegram:biz:{connectionId}:{chatId}, as described in Business mode.
Replies in a regular supergroup stay in the chat's thread. Telegram also sets message_thread_id on those replies to identify the reply thread, but the adapter only treats a message as part of a topic when is_topic_message is true. A bot subscribed to the chat receives the replies in onSubscribedMessage.
Because the reply is on the chat's thread, thread.post() from its handler sends to the chat without message_thread_id. Use thread.reply() to answer a specific message. Every reply chain in the chat also shares the chat's lock, so your concurrency strategy applies across all of them. With the default drop strategy, a message that arrives while a handler is still running for another message in the same chat is discarded.
Reaction updates don't say whether the message is in a topic, so reactions always arrive on telegram:{chatId}.
Earlier versions gave each reply thread in a regular supergroup its own telegram:{chatId}:{n} thread ID. Subscriptions, thread.setState values, and anything else your bot keyed on those IDs no longer match, because the replies now arrive on telegram:{chatId}. Calling thread.subscribe() from a reply now subscribes the bot to the whole chat.
Stop posting to stored telegram:{chatId}:{n} IDs from a regular supergroup. The adapter still sends message_thread_id for them, and the message it returns keeps the stored ID, while replies to that message arrive on telegram:{chatId}.
Business mode
Telegram Connected Business Bots let a bot reply to customer messages on behalf of a business account. Enable it with businessMode: true:
Business threads use the ID format telegram:biz:{connectionId}:{chatId}. Each business conversation is its own channel, separate from any direct chat the same customer has with the bot. Outbound sends, edits, typing, file uploads, and threads created from inline-keyboard callbacks include business_connection_id. Slash commands from business chats reach onSlashCommand like any other chat.
The adapter ignores messages typed by the business owner and messages the bot sent on the account's behalf, and it stops replying when the connection is disabled or loses can_reply. Connection state lives in your state adapter, so a change reaches every instance.
A few Bot API limits apply to business threads:
delete()usesdeleteBusinessMessages, which needs thecan_delete_sent_messagesright.- Reactions are not supported.
addReactionandremoveReactionthrow aNotImplementedError. fetchThread()falls back to the chat details from messages already seen whengetChatcannot resolve the customer.
When polling, Business mode always sends an explicit allowed_updates list (the default update types plus the business ones, or your longPolling.allowedUpdates merged with them). Telegram otherwise reuses the list from an earlier call, which can silently exclude business updates. When registering a webhook yourself, add business_connection, business_message, and edited_business_message to allowed_updates.
Business thread IDs start with telegram:biz, so state adapters that shard by the first two ID segments (such as Cloudflare Agents) place every business conversation in one shard. Override the sharder with a key that includes the connection ID if that matters for your deployment.
Limitations
- Telegram does not expose full historical message APIs to bots.
fetchMessagesreturns adapter-cached messages from the current process. listThreadsis not available for Telegram chats.- Telegram limits callback data to 64 bytes, so keep
Buttonid/valuepayloads short. - Telegram keeps only the first 8 buttons of an inline keyboard row and drops the rest, so an
Actionsblock with more than 8 buttons wraps into evenly sized rows of at most 8. For example, 11 buttons render as a row of 6 and a row of 5. - Card images, select menus, and radios are not rendered. Tables, fields, and links render as text in the message body.
Thread.reply()threads with Bot APIreply_parametersand setsallow_sending_without_reply, so a reply whose target was deleted, never existed, or lives in another forum topic is delivered unthreaded instead of failing. Only the chat half of a composite target ID is validated up front.
Feature support
Messaging
| Feature | Supported |
|---|---|
| Post message | |
| Message replies | |
| Edit message | |
| Delete message | |
| File uploads | |
| Streaming | Post+Edit / Opt-in rich drafts |
| Scheduled messages |
Rich content
| Feature | Supported |
|---|---|
| Card format | MarkdownV2 + inline keyboard |
| Buttons | Inline keyboard |
| Link buttons | Inline keyboard URLs |
| Select menus | |
| Tables | Native messages / ASCII cards |
| Charts | |
| Fields | |
| Images in cards | |
| Modals |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | |
| Add reactions | |
| Remove reactions | |
| Typing indicator | |
| Mark as read | |
| Message edit events | |
| Message delete events | |
| DMs | |
| Ephemeral messages | |
| User lookup | Seen users |
| Parent subject | |
| Native client | |
| Custom API endpoint |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | Cached |
| Fetch single message | Cached |
| Fetch thread info | |
| Fetch channel messages | Cached |
| List threads | |
| Fetch channel info | |
| Post channel message |