Repository navigation
Telegram API
import { Telegram } from '@tundraconnect/telegram';
const client = new Telegram({
botToken: '123456789:AAExampleToken',
timeout: 30,
});botToken is required — the client throws TelegramError
(CONFIG_INVALID_BOT_TOKEN) at construction if it's missing, blank, or not
a string. Get one from @BotFather with /newbot.
There is no auth option. Telegram authenticates a bot by embedding its
token directly in the request path — https://api.telegram.org/bot<token>/<method>
(literally bot concatenated with the token, no separator) — not via a
header or query parameter, so it doesn't fit RESTler's auth: RESTlerAuth
model at all (similar to how a webhook URL doesn't). Instead, botToken is
validated up front and folded into baseURL before the request layer is
initialized, so every endpoint call below just uses a plain relative path
like /sendMessage.
baseURL defaults to https://api.telegram.org/bot<token>. An explicit
baseURL names only the server origin to target — for example a
self-hosted Bot API server,
which routes with the identical /bot<token>/<method> shape — and the
/bot<token> segment is always appended to it (a trailing slash is
normalized away first, and a value that already ends in /bot<token> is
left as-is). An override can change where requests go, but never strips
the credential segment from the request path:
const client = new Telegram({
botToken: '123456789:AAExampleToken',
baseURL: 'http://localhost:8081',
});
// requests go to http://localhost:8081/bot123456789:AAExampleToken/<method>timeout is expressed in seconds and defaults to 30.
Every method validates its request locally before sending it and throws
TelegramError (REQUEST_VALIDATION_ERROR) without making a request when
it fails. Request schemas are strict: an unknown key is an error, not
silently dropped, and booleans and numbers are not coerced from strings.
| Method | Endpoint | Result |
|---|---|---|
sendMessage() |
POST /sendMessage |
The sent Message
|
editMessageText() |
POST /editMessageText |
The edited Message, or true
|
editMessageReplyMarkup() |
POST /editMessageReplyMarkup |
The edited Message, or true
|
deleteMessage() |
POST /deleteMessage |
true |
answerCallbackQuery() |
POST /answerCallbackQuery |
true |
setWebhook() |
POST /setWebhook |
true |
deleteWebhook() |
POST /deleteWebhook |
true |
getWebhookInfo() |
GET /getWebhookInfo |
WebhookInfo |
setMyCommands() |
POST /setMyCommands |
true |
getMyCommands() |
POST /getMyCommands |
BotCommand[] ([] when none set) |
deleteMyCommands() |
POST /deleteMyCommands |
true |
getMe() |
GET /getMe |
The bot's own User
|
Three static helpers need no client and make no request:
| Helper | Purpose |
|---|---|
Telegram.verifyWebhookRequest(options) |
Check the webhook secret header in constant time, then parse the body |
Telegram.updateKind(update) |
Name an update's kind: 'message', 'callback_query', …, or 'unknown'
|
Telegram.parseCommand(message, botName?) |
Parse /command@BotName args into { command, args, rawArgs, botUsername }
|
const message = await client.sendMessage({
chat_id: '@examplechannel', // or an integer chat id
text: 'Deployment finished successfully.',
});
console.log(message.message_id, message.chat.type);chat_id accepts either the chat's integer id or an @username string
(public channels/supergroups only). Supported options include:
-
parse_mode—'HTML','MarkdownV2'or the legacy'Markdown'. It's mutually exclusive withentitiesper Telegram's docs; that rule isn't enforced locally, and Telegram answers a request with both with a400 Bad Request. -
link_preview_options—{ is_disabled, url, prefer_small_media, prefer_large_media, show_above_text }. The legacydisable_web_page_preview: trueis still accepted; setting both is refused locally. -
disable_notification— deliver silently. -
reply_parameters—{ message_id, chat_id?, quote?, … }; needsmessage_id(orephemeral_message_id). -
reply_markup— an inline keyboard, a custom reply keyboard ({ keyboard: [['/today', '/week']] }),{ remove_keyboard: true }or{ force_reply: true }.
await client.sendMessage({
chat_id: -1001234567890,
text: '<b>Weekly summary</b>\n1,204 links, 98,311 clicks',
parse_mode: 'HTML',
disable_notification: true,
link_preview_options: { is_disabled: true },
reply_parameters: { message_id: 42, allow_sending_without_reply: true },
reply_markup: {
inline_keyboard: [
[{ text: 'Open dashboard', url: 'https://example.com/dashboard' }],
[
{ text: 'Mute 1h', callback_data: 'mute:ops:3600' },
{ text: 'Mute 1d', callback_data: 'mute:ops:86400' },
],
],
},
});Limits checked locally:
-
textis 1-4096 characters. Telegram counts UTF-16 code units after entity parsing; the local check runs before parsing and counts the raw string's UTF-16 code units (text.length, so an emoji counts as 2). Withoutparse_modethe counts agree. Withparse_modethe markup also counts locally, so text whose tags push it past 4096 is refused here even if Telegram would accept it. Split long formatted messages well below the limit. - Each inline keyboard button needs exactly one action:
url,callback_data,web_app,login_url,switch_inline_query,switch_inline_query_current_chat,switch_inline_query_chosen_chat,copy_text,callback_gameorpay.urlmust behttp(s)://ortg://. -
callback_datais 1-64 bytes of UTF-8, not characters: 64 ASCII characters fit, but only 32 Cyrillic letters or 16 emoji.
const edited = await client.editMessageText({
chat_id: -1001234567890,
message_id: 42,
text: 'Abuse report 17: resolved by @ada',
});
if (edited !== true) console.log(edited.edit_date);
// Replace the buttons only; leave out reply_markup to remove them.
await client.editMessageReplyMarkup({
chat_id: -1001234567890,
message_id: 42,
reply_markup: {
inline_keyboard: [[{ text: 'Reopen', callback_data: 'reopen:17' }]],
},
});Target either chat_id + message_id (a message the bot sent to a chat)
or inline_message_id (a message sent via the bot in inline mode), never
both. Telegram returns the edited Message for the first and true for
the second, so the result type is Message | true. Only an inline keyboard
can be attached to an edited message. Editing a message to the text and
markup it already has fails with a BAD_REQUEST ("message is not
modified").
await client.answerCallbackQuery({
callback_query_id: '4382bfdwdsb323b2d9',
text: 'Muted Ops alerts for 1 hour', // 0-200 characters
show_alert: false,
cache_time: 0,
});Answer every callback query, even without text: the user's client shows
a progress indicator on the button until you do.
await client.deleteMessage({ chat_id: -1001234567890, message_id: 42 });Telegram refuses (BAD_REQUEST) to delete a message older than 48 hours,
and in groups and channels the bot needs the matching administrator rights.
await client.setWebhook({
url: 'https://bot.example.com/telegram',
secret_token: 'a-long-random-value-from-your-secret-store',
allowed_updates: ['message', 'callback_query', 'my_chat_member'],
max_connections: 40,
drop_pending_updates: false,
});
const info = await client.getWebhookInfo();
if (info.last_error_message) {
console.warn(info.last_error_date, info.last_error_message);
}
await client.deleteWebhook({ drop_pending_updates: true });-
urlmust behttps:on port 443, 80, 88 or 8443, the ports Telegram delivers webhooks to. Remove a webhook withdeleteWebhook()rather than an empty URL. -
secret_tokenis required here, though Telegram treats it as optional: without it, anyone who learns the URL can post forged updates. It must be 1-256 characters ofA-Z,a-z,0-9,_and-, and it is never repeated in an error. -
max_connectionsis 1-100 (Telegram defaults to 40). -
allowed_updatestakes the documented kinds (UPDATE_TYPES). An empty list means every kind exceptchat_member,message_reactionandmessage_reaction_count; leaving it out keeps the previous setting. - Uploading a self-signed
certificateneeds a multipart upload and is not supported.
await client.setMyCommands({
commands: [
{ command: 'status', description: 'Service status' },
{ command: 'flagged', description: 'Links flagged for review' },
],
scope: { type: 'chat_administrators', chat_id: -1001234567890 },
language_code: 'en',
});
const commands = await client.getMyCommands({
scope: { type: 'chat_administrators', chat_id: -1001234567890 },
language_code: 'en',
});
await client.deleteMyCommands({ scope: { type: 'all_group_chats' } });command is 1-32 lowercase letters, digits and underscores, without the
leading /; description is 1-256 characters; at most 100 commands.
scope is one of:
type |
Extra fields | Applies to |
|---|---|---|
default |
— | Everyone without a narrower scope |
all_private_chats |
— | Every private chat |
all_group_chats |
— | Every group and supergroup |
all_chat_administrators |
— | Administrators of every group and supergroup |
chat |
chat_id |
One chat |
chat_administrators |
chat_id |
Administrators of one group or supergroup |
chat_member |
chat_id, user_id
|
One member of one group or supergroup |
Telegram shows a user the list from the narrowest scope (and language) that has one set; deleting a list makes the next broader one show.
const me = await client.getMe();
console.log(me.username, me.can_join_groups);Takes no parameters. A simple way to confirm a configured bot token is live
and see what the bot is permitted to do (join groups, read all group
messages, use inline queries, …). me.username is what to pass to
Telegram.parseCommand.
Updates arrive as HTTPS POSTs to the webhook URL, one Update per request.
Long polling (getUpdates) is not implemented.
declare const request: Request;
declare const env: { TELEGRAM_WEBHOOK_SECRET: string };
const update = Telegram.verifyWebhookRequest({
headers: request.headers, // a Headers instance or a plain object
body: await request.text(), // the raw body: text(), never json()
secretToken: env.TELEGRAM_WEBHOOK_SECRET,
});It runs these checks, in order, and parses nothing until the secret matches:
-
secretTokenmust itself be a validsecret_token, or it throwsCONFIG_INVALID_WEBHOOK_SECRET. This keeps an unset secret (an empty string from a missing environment variable) from matching a request that has no header. - The
X-Telegram-Bot-Api-Secret-Tokenheader (looked up case-insensitively; exported asWEBHOOK_SECRET_HEADER) must be present (WEBHOOK_SECRET_MISSING) and equalsecretToken(WEBHOOK_SECRET_INVALID). The comparison usesconstantTimeEqualfrom@tundralibs/crypt, so its timing does not reveal how much of the secret matched. A header longer than 256 characters is refused without comparing. - The body must be JSON (
WEBHOOK_MALFORMED_BODY) shaped like anUpdate(WEBHOOK_INVALID_UPDATE).
Neither the configured secret nor the received header value appears in any
error. Answer the two secret errors with 401. For the two body errors,
log and answer 200: Telegram redelivers on any non-2xx status, and a body
that failed to parse once will fail every time.
Updates of kinds this package doesn't model (poll, message_reaction, …)
and fields it doesn't know parse without error; read them through a
Record<string, unknown> cast. A modeled kind whose required fields are
missing (a message without chat) does fail.
declare const update: import('@tundraconnect/telegram/schemas').UpdateSchema;
switch (Telegram.updateKind(update)) {
case 'message':
console.log(update.message?.text);
break;
case 'callback_query':
console.log(update.callback_query?.data);
break;
case 'my_chat_member':
// 'kicked' in a private chat: the user blocked the bot.
console.log(update.my_chat_member?.new_chat_member.status);
break;
default:
break; // another documented kind, or 'unknown'
}A callback query's message may be an inaccessible message (deleted, or
too old) with date: 0 and only chat and message_id; check
message.date === 0 before reading anything else. callback_query.data
comes from the user's client, so treat it as untrusted input.
Telegram.parseCommand('/today'); // { command: 'today', args: [], rawArgs: '' }
Telegram.parseCommand('/ORG@BrevilyBot acme corp', 'brevilybot');
// { command: 'org', args: ['acme', 'corp'], rawArgs: 'acme corp', botUsername: 'BrevilyBot' }
Telegram.parseCommand('/org@OtherBot acme', 'BrevilyBot'); // null
Telegram.parseCommand('hello'); // null- Pass the message itself when you have it. If its
entitiesarray is present, thebot_commandentity at offset 0 decides what the command is, and a message without one is not a command. Otherwise the text is parsed:/, then letters, digits and underscores, optionally@BotName, then whitespace or the end (/status-xis not a command). - The command is lowercased; users can type
/Status, butsetMyCommandsonly allows lowercase. -
botUsername(e.g.me.usernamefromgetMe(), with or without@) is compared case-insensitively. A command addressed to another bot returnsnull; one addressed to no bot is always accepted. -
argsis the rest of the text split on whitespace;rawArgsis the same text trimmed but unsplit. - It does not authorize. Check
message.from?.idormessage.chat.idbefore acting on a command.
See Errors for failure handling and Schemas for request/response validation.
- Algolia
- AzureBlob
- CloudflareDNS
- CloudflareEmail
- CloudflareSaaS
- CloudflareTurnstile
- CoinGecko
- Discord
- DodoPayments
- GCS
- GoogleAnalytics
- GoogleCertificateManager
- GoogleWebRisk
- Kalshi
- ntfy
- OpenExchange
- OpenWeatherMap
- PayPal
- Polymarket
- Razorpay
- Resend
- S3
- SendGrid
- Sentry
- Slack
- Stripe
- Telegram
- Twilio
- UpstashRedis
- URLhaus