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.

Water Trackerbot

/start

Water tracker

 

Simulation, not a screenshot. The frame runs the example widget from this page in your browser, with a stand-in for Telegram and for the bot and with pauses added so the steps can be seen. Nothing is sent to Telegram.
  1. The widget asks. On a tap it calls TelegramWidget.request("add").
  2. The bot gets an update. A widget_query arrives with who tapped, the message and the data.
  3. The bot changes the state. editMessageWidgetData sets chat_data to {"n":1} and the widget redraws.
  4. 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:

FeatureProductionTest server
A widget in the private chat with the bot, with requests and the walletYesYes
Forwarding a widget messageNoYes
Widgets in groups and supergroupsNoYes
Widgets in inline mode, sent via the botNoYes

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:

sendWidgetSends a message with an HTML widget.
sendSharedWidgetSends an existing shared widget again, by its identifier.
widget_query, shared_widget_queryNew update types: a request from a widget, and a request from a forwarded shared widget.
answerWidgetQueryThe bot's answer to a request. It has to arrive within 15 seconds.
editMessageWidgetDataChanges the state of the widget in one message.
editSharedWidgetDataChanges the shared state of a shared widget, everywhere it is shown.
InputWidgetMessageContent, InputSharedWidgetMessageContentWidgets 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):

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):

  1. The bot sends the widget with allow_queries set to true. Without it, every request from that widget is refused.
  2. 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.
  3. The bot receives a widget_query update with the query id, the user in from, the chat and message_id, and the data. The query does not carry the widget's state, so the bot keeps the real state on its side and treats data as untrusted input.
  4. To change what the widget shows, the bot calls editMessageWidgetData. An open widget gets the chatDataChanged event and redraws. Edits are silent: no "edited" mark and no notification.
  5. The bot calls answerWidgetQuery for every query within 15 seconds, also when something failed. The optional data, 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">&nbsp;</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.

What we checked. On 11 November 2026 we ran both files, exactly as printed on this page, with a test bot on Telegram's production servers. 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:

HTML32,768 bytes of UTF-8
HTML after compression4,096 bytes, zlib at level 9
Bubble width200 px up to the bubble's maximum, about 320 to 430 px on phones
Bubble height50 to 600 px. Taller content is clipped: nothing scrolls inside a widget
chat_data, public_data1,024 bytes each
Request data, widget to bot1 to 64 bytes
Answer data, bot to widgetUp to 1,024 bytes
Time to answer a query15 seconds
Requests5 per 5 seconds per user and bot, and one in flight per widget
EditseditMessageWidgetData counts as one sent message against the bot's sending limits. A shared widget takes 1 edit per second
CaptionUp to 1,024 characters
localStorage4 KB per message, kept on that device only
Memory and CPU8 MB, and 300 ms of JavaScript per second. One task over 500 ms stops the widget
Document2,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_LARGEThe HTML is over 32,768 bytes, or over 4,096 bytes compressed. Under 32 KB, it is the compressed limit.
INPUT_MEDIA_WIDGET_HTML_INVALIDThe HTML is not valid UTF-8 or contains a NUL character.
INPUT_MEDIA_WIDGET_DATA_TOO_LARGEA state is over 1,024 bytes, at sending or in an edit.
WIDGET_RESULT_TOO_LARGEThe answer's data is over 1,024 bytes.
query is too oldThe answer came after 15 seconds.
message is not modifiedThe edit changed nothing. Treat it as done, and still answer the query.
TIMEOUT, in the widgetThe bot did not answer within 15 seconds.
NOT_ALLOWED, in the widgetThe widget was sent without allow_queries, or the call was not made from a tap.
BUSY, RATE_LIMITED, in the widgetA 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):

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).

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.

  1. The widget checks TelegramWidget.wallet. It is null where the user has no wallet in this app, for example an older app or a country where the wallet is unavailable.
  2. On a tap it calls request(data, { wallet: true }). The bot's widget_query then has a type of data with the user's wallet_card: its address and balance.
  3. The bot answers with a transaction in TON Connect's sendTransaction format: recipients, and amounts in nanotons.
  4. 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.
  5. The bot gets a second query with a type of sent_transaction and 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 keyboardHTML widgetMini App
Where it showsButtons under a messageInside the message bubbleA web view opened from a button, the menu or a link
Your codeNone: a JSON list of buttonsUp to 32 KB of HTML inside the message, fixed once sentA web page on your server, loaded at launch
NetworkNot applicableNoneThe web
Talking to the botcallback_query with 1 to 64 bytes of callback_dataTelegramWidget.request with 1 to 64 bytes, delivered as widget_queryYour own backend, with signed initData
Text inputNoNo: taps, drags and holds onlyYes, keyboard included
StateThe message text and its buttons, edited by the botChat state and shared state written by the bot, plus localStorage per messageYour backend and the Mini App storage APIs

What Telegram has not documented

Sources

Everything on this page comes from these documents, read on 11 November 2026:

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.