Telegram HTML widgets
Bot API 10.4, released on 9 November 2026, lets a bot put a small HTML app inside a message. This guide covers what a widget is, what works in production today, the limits, and a minimal example of the whole request loop.
A Telegram HTML widget is a small interactive block that a bot sends inside a message with sendWidget. It is one HTML document with inline CSS and JavaScript, at most 32,768 bytes and 4,096 after zlib compression, that Telegram apps run on the user's device in a sandbox with no network access. In production today it works only in the private chat with the bot and cannot be forwarded. Groups, inline mode and forwards run on Telegram's test server only.
Checked against Telegram's documentation on 11 November 2026: the Bot API 10.4 changelog, the Bot API reference and the widgets guide.
Tap +1 glass and watch the four steps.
/start
Water tracker
- The widget asks. On a tap it calls
TelegramWidget.request("add"). - The bot gets an update. A
widget_queryarrives with who tapped, the message and the data. - The bot changes the state.
editMessageWidgetDatasetschat_datato{"n":1}and the widget redraws. - The bot answers.
answerWidgetQuery, within 15 seconds, reaches only the widget that asked.
What works today, and what does not
Widgets are rolling out in steps. Telegram's availability table splits the feature between production and the test server:
| Feature | Production | Test server |
|---|---|---|
| A widget in the private chat with the bot, with requests and the wallet | Yes | Yes |
| Forwarding a widget message | No | Yes |
| Widgets in groups and supergroups | No | Yes |
| Widgets in inline mode, sent via the bot | No | Yes |
- Private chats only. In production a bot can send a widget only to a private chat with a user, and the message is protected automatically, so it cannot be forwarded (sendWidget).
- No copies.
copyMessageandcopyMessagesrefuse widget messages (changelog). - No channels. A bot cannot post a widget in a channel, directly or through inline mode. On the test server users can forward one there.
- Older apps see the caption. An app without widget support shows the message as unsupported media with its caption, so always send a caption (Where widgets live).
- The test server is separate. It needs its own user account and its own bot,
and requests go to
https://api.telegram.org/bot<token>/test/sendWidget(how to log in).
Pavel Durov's announcement of 10 November says "Any Telegram message can now become an app", with payments and viral mechanics built in. That describes the whole design. Production today has the first step: a widget in the one-to-one chat between a bot and a user who started it, with requests and wallet payments. The sharing side (forwards, groups, inline mode) runs on the test server, and Telegram's documentation gives no date for it.
What Bot API 10.4 added
Telegram published Bot API 10.4 on 9 November 2026 (changelog, @BotNews post). The widget part is five methods, two update types and a few objects:
sendWidget | Sends a message with an HTML widget. |
sendSharedWidget | Sends an existing shared widget again, by its identifier. |
widget_query, shared_widget_query | New update types: a request from a widget, and a request from a forwarded shared widget. |
answerWidgetQuery | The bot's answer to a request. It has to arrive within 15 seconds. |
editMessageWidgetData | Changes the state of the widget in one message. |
editSharedWidgetData | Changes the shared state of a shared widget, everywhere it is shown. |
InputWidgetMessageContent, InputSharedWidgetMessageContent | Widgets as inline query results. Not available in production yet. |
The same release added the TON wallet objects that widget payments use, such as
TonWalletCard. The widget's own JavaScript object, TelegramWidget, is
documented in the widgets guide.
How a widget talks to its bot
A widget is fixed code plus changeable state. The HTML is set when the widget is sent and never changes: no method replaces it. What changes is the state, a short text that only the bot writes (The widget's state):
- Chat state,
chat_data: up to 1,024 bytes tied to one message and seen only in that chat. The widget reads it asTelegramWidget.chatData. - Shared state,
public_data: up to 1,024 bytes seen wherever the widget is shown. The widget reads it asTelegramWidget.publicData.
The widget cannot change its own state and has no network access. Its one way out is a request to the bot, which works like a callback button inside the widget (Requests to the bot):
- The bot sends the widget with
allow_queriesset to true. Without it, every request from that widget is refused. - On a tap the widget calls
TelegramWidget.request(data)with 1 to 64 bytes. The call has to be made inside the tap's handler, synchronously: one tap unlocks one call. - The bot receives a
widget_queryupdate with the queryid, the user infrom, thechatandmessage_id, and thedata. The query does not carry the widget's state, so the bot keeps the real state on its side and treatsdataas untrusted input. - To change what the widget shows, the bot calls
editMessageWidgetData. An open widget gets thechatDataChangedevent and redraws. Edits are silent: no "edited" mark and no notification. - The bot calls
answerWidgetQueryfor every query within 15 seconds, also when something failed. The optionaldata, up to 1,024 bytes, reaches only the widget that asked. An answer never changes the state and shows nothing in the app, and it may come before or after the edit.
In the private chat with the bot a widget may also ask without a tap while it is on screen: once when it appears, then at most once every 5 seconds. Everywhere else a request needs a tap.
A minimal example
Two files: a widget that counts glasses of water, and a bot that keeps the count. They follow Telegram's documentation as of 11 November 2026 and use plain HTTPS calls, so they do not depend on a bot library having shipped Bot API 10.4.
1. Send a static widget with curl
The quickest check that your bot and your app handle widgets. You need a bot token and the chat ID of a user who has started the bot:
curl -s "https://api.telegram.org/bot$BOT_TOKEN/sendWidget" \ -d chat_id=123456789 \ --data-urlencode "html=<h3>Booking confirmed</h3><p>Table for two, Friday at 20:00</p>" \ --data-urlencode "caption=Booking confirmed"
No CSS and no script: Telegram's default styles make a bare widget look like an ordinary message.
2. The widget: widget.html
<style>
.n{font-size:2.4em;font-weight:700;line-height:1.2}
.hint{color:var(--tg-theme-hint-color);font-size:.87em}
button{display:block;width:100%;margin:8px 0 2px}
</style>
<div>Glasses of water today</div>
<div class="n" id="n">0</div>
<button id="add">+1 glass</button>
<div class="hint" id="msg"> </div>
<script>
const W = window.TelegramWidget;
const $ = (id) => document.getElementById(id);
function draw(json) {
let state = null;
try { state = JSON.parse(json); } catch (e) {}
$('n').textContent = (state && state.n) || 0;
}
function say(text) { $('msg').textContent = text || '\u00a0'; }
$('add').addEventListener('click', () => {
if (!W) return;
say('Sending...');
W.request('add').then(
(answer) => { say(''); if (answer.result) draw(answer.result); },
(error) => say('Not sent: ' + error.code)
);
});
if (W) {
W.onEvent('chatDataChanged', () => draw(W.chatData));
draw(W.chatData);
}
</script>
This widget is 940 bytes of HTML and 563 bytes after zlib at level 9, against limits of 32,768 and 4,096.
3. The bot: bot.py
# bot.py - Python 3.8+, standard library only
import json
import os
import urllib.error
import urllib.request
API = "https://api.telegram.org/bot" + os.environ["BOT_TOKEN"] + "/"
WIDGET = open("widget.html", encoding="utf-8").read()
counts = {} # (chat_id, message_id) -> glasses; a real bot uses a database
def call(method, **params):
request = urllib.request.Request(
API + method, json.dumps(params).encode(),
{"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=70) as response:
return json.load(response)
except urllib.error.HTTPError as error: # Telegram's errors are JSON too
result = json.load(error)
print(method, "failed:", result.get("description"))
return result
offset = 0
while True:
reply = call("getUpdates", offset=offset, timeout=50,
allowed_updates=["message", "widget_query"])
if not reply.get("ok"):
raise SystemExit(1)
for update in reply["result"]:
offset = update["update_id"] + 1
message = update.get("message")
query = update.get("widget_query")
if message and (message.get("text") or "").startswith("/start"):
call("sendWidget", chat_id=message["chat"]["id"], html=WIDGET,
chat_data=json.dumps({"n": 0}), allow_queries=True,
caption="Water tracker")
elif query:
key = (query["chat"]["id"], query["message_id"])
n = counts.get(key, 0)
if query["data"] == "add":
edit = call("editMessageWidgetData", chat_id=key[0],
message_id=key[1],
chat_data=json.dumps({"n": n + 1}))
if edit.get("ok"): # commit only after the edit went through
n = counts[key] = n + 1
# answer every query within 15 seconds, also when nothing changed
call("answerWidgetQuery", widget_query_id=query["id"],
data=json.dumps({"n": n}))
The count lives in memory, so a restart forgets it. A real bot keeps it in a database, keyed by chat and message.
4. Run it
read -rs BOT_TOKEN && export BOT_TOKEN # paste the token, press Enter python3 bot.py
Then open the chat with your bot in Telegram and send /start. Each tap on the button
goes through the five steps above. If the bot has a webhook set, remove it first with
deleteWebhook: getUpdates does not work while a webhook is set.
sendWidget was accepted and the message came back protected, a tap on the button
reached the bot as a widget_query, and editMessageWidgetData and
answerWidgetQuery both succeeded, the answer 0.1 seconds after the query. In
the Telegram desktop app the count went from 0 to 1. Telegram Web (version A) showed "This message is not
supported on the web version of Telegram" in place of the widget. The widget's size is counted
with Python's zlib, which Telegram's guide says counts exactly as the server does.Limits
The numbers that decide what fits, from the limits table of Telegram's guide and the Bot API reference:
| HTML | 32,768 bytes of UTF-8 |
| HTML after compression | 4,096 bytes, zlib at level 9 |
| Bubble width | 200 px up to the bubble's maximum, about 320 to 430 px on phones |
| Bubble height | 50 to 600 px. Taller content is clipped: nothing scrolls inside a widget |
chat_data, public_data | 1,024 bytes each |
Request data, widget to bot | 1 to 64 bytes |
Answer data, bot to widget | Up to 1,024 bytes |
| Time to answer a query | 15 seconds |
| Requests | 5 per 5 seconds per user and bot, and one in flight per widget |
| Edits | editMessageWidgetData counts as one sent message against the bot's sending limits. A shared widget takes 1 edit per second |
| Caption | Up to 1,024 characters |
localStorage | 4 KB per message, kept on that device only |
| Memory and CPU | 8 MB, and 300 ms of JavaScript per second. One task over 500 ms stops the widget |
| Document | 2,000 nodes and 400 CSS rules |
The compressed size is usually the limit you hit first. Telegram's guide gives this command and says
Python's zlib counts exactly as the server does, while gzip -9 counts about 12 bytes
more and other zlib builds can be a few dozen bytes off:
python3 -c 'import sys,zlib; print(len(zlib.compress(sys.stdin.buffer.read(), 9)))' < widget.html
Errors you will meet
INPUT_MEDIA_WIDGET_TOO_LARGE | The HTML is over 32,768 bytes, or over 4,096 bytes compressed. Under 32 KB, it is the compressed limit. |
INPUT_MEDIA_WIDGET_HTML_INVALID | The HTML is not valid UTF-8 or contains a NUL character. |
INPUT_MEDIA_WIDGET_DATA_TOO_LARGE | A state is over 1,024 bytes, at sending or in an edit. |
WIDGET_RESULT_TOO_LARGE | The answer's data is over 1,024 bytes. |
query is too old | The answer came after 15 seconds. |
message is not modified | The edit changed nothing. Treat it as done, and still answer the query. |
TIMEOUT, in the widget | The bot did not answer within 15 seconds. |
NOT_ALLOWED, in the widget | The widget was sent without allow_queries, or the call was not made from a tap. |
BUSY, RATE_LIMITED, in the widget | A request is already in flight, or the user passed 5 requests in 5 seconds. |
For the rest of the Bot API's errors, see the error code reference.
What a widget can and cannot do
Telegram calls the contract between widgets and its apps the widget profile. Every app removes what is outside it before drawing, without an error, so a missing feature is a bug you will not see (What widgets support):
- One HTML document. Inline
<style>and<script>, images only asdata:URLs or inline SVG. External scripts, stylesheets, fonts and images are never loaded. - JavaScript runs, in a sandbox. Scripts run in QuickJS, a small JavaScript
engine, never in a browser or a WebView. ES2023, Promises, timers,
requestAnimationFrame, the DOM, canvas 2D andlocalStorageare in. - No network.
fetch,XMLHttpRequest,WebSocketand every other network API throwNetworkDisabledError. Data comes in through the state and the bot's answers. - No typing. Inputs, selects and text areas are removed, and
<button>is the only form control. A widget gets taps, pointer events, horizontal drags and holds, and no keyboard. - No scrolling inside. Vertical drags always scroll the chat, and content taller than the bubble is clipped.
- The chat's colors. The palette arrives as CSS variables such as
--tg-theme-text-colorand--tg-theme-button-color, and a<button>you do not style is drawn as the app's own button. - No media, no frames.
audio,videoandiframeare out, and so are web fonts: system font families only. - No accessibility semantics yet.
aria-*attributes are kept but do nothing in this version, so what matters goes in the caption too. - The bot learns only from requests. It sees who tapped and the data the widget sent. A widget that may not send requests does not know who is looking at it (Privacy).
To try a layout, a desktop browser 320 to 430 px wide gives a first look, with a stand-in for
TelegramWidget; a browser draws everything and enforces none of the limits. Telegram
also publishes the sandbox its apps use, widget-sandbox on GitHub, with a dev app that loads an HTML
file into a bubble-sized frame.
Shared widgets
Pass is_shared to sendWidget and Telegram creates a shared widget: one
object on the server, like a poll, that any number of messages can show. The returned message
carries its identifier in widget.id (Static and shared widgets).
sendSharedWidgetsends the same widget again bywidget_id, with that message's ownchat_dataand caption.editSharedWidgetDatachanges the shared state once, for every message that shows the widget. A shared widget takes at most one edit per second.- An open widget picks up a new shared state only if it called
TelegramWidget.watchPublicData(). The app then polls at the widget's refresh period: 30 seconds by default, 5 to 3,600 seconds throughcache_time. Otherwise the new state arrives when the message is opened or scrolled into view. - The code, the size and the request flag of a shared widget never change. A new layout means a new widget.
POST https://api.telegram.org/bot<token>/sendWidget
{"chat_id": 111111111, "html": "...", "public_data": "{\"left\":12}",
"is_shared": true, "caption": "Seats left: 12"}
POST https://api.telegram.org/bot<token>/sendSharedWidget
{"chat_id": 222222222, "widget_id": "<widget.id from the first call>",
"caption": "Seats left"}
POST https://api.telegram.org/bot<token>/editSharedWidgetData
{"widget_id": "<widget.id>", "public_data": "{\"left\":11}", "cache_time": 10}
The production note on sendSharedWidget is the same as on sendWidget,
private chats only. So one case is already covered: the same live widget in many private chats,
updated with one call. The other half of the design depends on forwards: a forwarded shared
widget keeps following the shared state and sends its taps to the bot as
shared_widget_query updates. Forwards run on the test server only for now.
Payments: the TON wallet
A widget can ask the user to pay or to sign with the wallet built into Telegram. The formats are TON Connect's, without a connection, a manifest or a bridge: the bot that sent the message is the one asking, and the app's own wallet window says so (Payments with the wallet). Telegram's availability table lists the wallet as working in production, in the private chat with the bot.
- The widget checks
TelegramWidget.wallet. It isnullwhere the user has no wallet in this app, for example an older app or a country where the wallet is unavailable. - On a tap it calls
request(data, { wallet: true }). The bot'swidget_querythen has atypeofdatawith the user'swallet_card: its address and balance. - The bot answers with a transaction in TON Connect's
sendTransactionformat: recipients, and amounts in nanotons. - The widget passes it to
wallet.sendTransaction(transaction, data)within 10 seconds of the answer. The app shows the bot, the amounts and the destinations, and the user confirms. - The bot gets a second query with a
typeofsent_transactionand the message hash. Telegram's guide says to check the transaction on the chain, not by the hash alone.
// in the widget, inside the handler of a tap
TelegramWidget.request('pay:42', { wallet: true })
.then((answer) => TelegramWidget.wallet.sendTransaction(
JSON.parse(answer.result), 'paid:42'))
.then((answer) => show(answer.result), (error) => show(error.code));
wallet.signData asks for a signature the same way, and the bot receives a
sign_data query. A request the user declines rejects with REJECTED.
After a TIMEOUT the payment may still have gone out, so the widget should ask the
bot about the order instead of paying again.
Stars are not part of this. The widget documentation describes the TON wallet flow only, and does not mention Telegram Stars or invoices. Telegram's Bot Developer Terms, section 6.2, still require digital goods and services to be sold for Stars. The documents do not say how the two fit together, so read the terms before you price digital goods in TON.
Widgets, Mini Apps or an inline keyboard?
Telegram's guide is direct about it: a widget is not a small Mini App. A Mini App is a web page you host, opened on demand. A widget is part of the message, visible as the chat scrolls, with no network access (Widgets and Mini Apps). An inline keyboard is the older and simpler tool: buttons under a message.
| Inline keyboard | HTML widget | Mini App | |
|---|---|---|---|
| Where it shows | Buttons under a message | Inside the message bubble | A web view opened from a button, the menu or a link |
| Your code | None: a JSON list of buttons | Up to 32 KB of HTML inside the message, fixed once sent | A web page on your server, loaded at launch |
| Network | Not applicable | None | The web |
| Talking to the bot | callback_query with 1 to 64 bytes of callback_data | TelegramWidget.request with 1 to 64 bytes, delivered as widget_query | Your own backend, with signed initData |
| Text input | No | No: taps, drags and holds only | Yes, keyboard included |
| State | The message text and its buttons, edited by the bot | Chat state and shared state written by the bot, plus localStorage per message | Your backend and the Mini App storage APIs |
- An inline keyboard when a few buttons under a message do the job. It works wherever your bot can post, and you can build one in the inline keyboard builder.
- A widget when the message itself should show live state that the user can glance at and tap: an order's progress, a counter, a poll, a small game. The price is 4 KB compressed, no typing, no network and, for now, the private chat only.
- A Mini App for anything bigger: forms, typing, long lists, network calls,
your own backend. A widget can open one with
openTelegramLink.
What Telegram has not documented
- App versions. The guide names the platforms (iOS, Android, macOS, Telegram Desktop and the web) and says unsupported apps show the caption. It lists no minimum versions. On 11 November 2026 our test widget drew in the Telegram desktop app and not in Telegram Web A.
- Dates. "Rolling out in steps" is all it says about groups, inline mode and forwards reaching production.
- Stars. Nothing on Telegram Stars or invoices inside a widget, and nothing on how wallet payments relate to section 6.2 of the Bot Developer Terms.
- Costs. No fee specific to widgets is mentioned.
sendWidgethas the same optional paid broadcast as the other send methods, 0.1 Stars per message above the free sending limits.
Sources
Everything on this page comes from these documents, read on 11 November 2026:
- Bot API changelog, Bot API 10.4, 9 November 2026
- Bot API reference:
sendWidget,sendSharedWidget,answerWidgetQuery,editMessageWidgetData,editSharedWidgetData,Widget,WidgetQuery,SharedWidgetQuery,WidgetQueryType - Telegram Widgets, Telegram's guide to designing and implementing widgets
- the @BotNews post, 9 November 2026, and Pavel Durov's post, 10 November 2026
- TelegramMessenger/widget-sandbox, the sandbox Telegram's apps run widgets in
- Telegram Bot Platform Developer Terms of Service, section 6.2
tgkit is an independent project and is not affiliated with Telegram. If the rollout changes, the availability table at the top of this page is the first thing that goes out of date.
Telegram widget FAQ
What is a Telegram HTML widget?
An interactive block that a bot sends inside a message with the sendWidget method, added in Bot API 10.4 on 9 November 2026. It is a single HTML document with inline CSS and JavaScript that Telegram apps run in a sandbox on the user's device, inside the message bubble.
Can I send a widget to a group or a channel?
Not in production yet. Today a bot can send widgets only to private chats with users. Groups, inline mode and forwards work on Telegram's test server only, and a bot cannot post a widget in a channel at all.
Can a Telegram widget be forwarded?
Not in production. Telegram's reference says sent widgets automatically have protected content and cannot be forwarded, and copyMessage refuses widget messages. Forwarding works on the test server.
Does JavaScript run inside a Telegram widget?
Yes. Inline scripts run in QuickJS, a small JavaScript engine, with ES2023, Promises, timers, the DOM, canvas 2D and localStorage. There is no network access: fetch, XMLHttpRequest and WebSocket throw NetworkDisabledError, and external scripts are never loaded.
How big can a Telegram widget be?
At most 32,768 bytes of HTML, and at most 4,096 bytes after the server compresses it with zlib at level 9. The bubble is 200 to 600 pixels wide and 50 to 600 pixels tall, and nothing scrolls inside it.
How does a widget send data to the bot?
It calls TelegramWidget.request with 1 to 64 bytes of data from the handler of a tap. The bot receives a widget_query update and has to call answerWidgetQuery within 15 seconds. The widget must have been sent with allow_queries set to true.
Can I change a widget after it is sent?
You can change its state and its caption, not its code. editMessageWidgetData changes the state in one message, editSharedWidgetData changes the shared state of a shared widget, and no method replaces the HTML.
Can a widget take payments in Telegram Stars?
The widget documentation describes one payment flow: TON, through the wallet built into Telegram, with wallet.sendTransaction. It does not mention Telegram Stars or invoices. Telegram's Bot Developer Terms still require digital goods and services to be sold for Stars, so read section 6.2 before selling digital goods from a widget.
What is the difference between a widget and a Mini App?
A Mini App is a web page on your server, opened on demand in a web view, with network access and a keyboard. A widget is part of the message, limited to 32 KB of HTML fixed at sending, with no network access and no text input.
Do widgets work in every Telegram app?
Telegram's guide says the same widget looks the same on iOS, Android, macOS, Telegram Desktop and the web. Apps that do not support widgets show the message as unsupported media with its caption. The documentation does not list minimum app versions.
Is this the same as the Telegram widgets for websites or the home screen?
No. The older Telegram Widgets (login, share button, post and discussion embeds) put Telegram features on a web page, and the home screen widgets of the mobile apps show your chats on the phone's home screen. HTML widgets, new in Bot API 10.4, run inside messages in the Telegram apps.
What happens if my bot does not answer a widget request?
The widget's request fails with TIMEOUT after 15 seconds, which is also how long the query id stays valid. Telegram's guide says to answer every query, also when something failed.