Telegram widgets (Bot API 10.4): interactive cards inside chat messages, with a working example
On November 9, Telegram shipped Bot API 10.4 with a new kind of message: widgets. A bot can now send a small interactive card that lives inside a normal chat bubble. It has buttons, it updates in place, and the buyer nev
On November 9, Telegram shipped Bot API 10.4 with a new kind of message: widgets. A bot can now send a small interactive card that lives inside a normal chat bubble. It has buttons, it updates in place, and the buyer never leaves the chat.
I spent the last few days building three of these cards for shop bots (an order card, a rating card and a subscription card), so here is what I learned: how widgets work, the limits that matter, and a complete example you can run.
Widgets are not Mini Apps
Mini Apps open a full web page in a WebView. Widgets are different:
- A widget is one self-contained HTML document with inline CSS and JavaScript.
- Telegram runs the script in a small sandboxed engine (QuickJS) and draws the result natively in the chat's style. It is not a WebView.
- It stays visible in the conversation as the user scrolls.
- It cannot load anything from the internet.
fetch,WebSocketand friends throw.
So think "smart message", not "app".
The limits that shape your design
| Limit | Value |
|---|---|
| HTML size | 32,768 bytes, and at most 4,096 bytes after zlib compression (level 9) |
Per-message state (chat_data) |
1,024 bytes, set only by the bot |
| Data a widget can send per tap | 1 to 64 bytes |
| Bot answer per tap | up to 1,024 bytes, within 15 seconds |
| Taps per user | 5 per 5 seconds, across all your widgets |
| Where it works today | private chats with the bot (groups, inline mode and forwards are test-server only) |
Two consequences:
-
The bot owns the state. The widget cannot change its own
chat_data. It asks the bot (TelegramWidget.request(...)), the bot answers, and the bot updates the message witheditMessageWidgetData. The widget hearschatDataChangedand redraws. - Nothing secret goes in the HTML or the state. Anyone who can see the message can read both. If a card needs to show something private, such as a license key, return it in the answer to the tap: that answer goes only to the widget that asked.
A complete example: a 1-5 rating card
The widget (rating.html)
<style>
body { margin: 0; font: 15px system-ui; color: var(--tg-theme-text-color); }
.row { display: flex; gap: 6px; padding: 12px; }
button { flex: 1; border: 0; border-radius: 8px; padding: 10px; font-size: 16px;
background: var(--tg-theme-secondary-bg-color); color: var(--tg-theme-button-color); }
button.on { background: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); }
</style>
<div class="row" id="row"></div>
<script>
const W = window.TelegramWidget;
const row = document.getElementById('row');
function current() { try { return JSON.parse(W.chatData).stars || 0; } catch (e) { return 0; } }
function draw() {
row.innerHTML = '';
for (let i = 1; i <= 5; i++) {
const b = document.createElement('button');
b.textContent = String(i);
if (i <= current()) b.className = 'on';
// request() must be called synchronously inside the tap handler
b.addEventListener('click', () => { W.request('rate:' + i).catch(() => {}); });
row.appendChild(b);
}
}
W.onEvent('chatDataChanged', draw);
draw(); // draw the first frame right away from the current state
</script>
The --tg-theme-* CSS variables come from the user's Telegram theme, so the card looks right in light and dark mode without extra work.
The bot (Node 18+, no library needed)
const API = `https://api.telegram.org/bot${process.env.BOT_TOKEN}`;
const html = require('fs').readFileSync('rating.html', 'utf8');
async function call(method, body) {
const r = await fetch(`${API}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) throw new Error(`${method}: ${j.description}`);
return j.result;
}
// send the card
async function sendRatingCard(chatId) {
return call('sendWidget', {
chat_id: chatId,
html,
chat_data: JSON.stringify({ stars: 0 }),
allow_queries: true, // without this, taps never reach the bot
caption: 'How was your order? Tap a number to rate it.',
});
}
// in your webhook handler
async function onUpdate(update) {
const q = update.widget_query;
if (!q) return;
const m = /^rate:([1-5])$/.exec(q.data || '');
if (!m || q.type?.type !== 'data') {
return call('answerWidgetQuery', { widget_query_id: q.id });
}
const stars = Number(m[1]);
// save (q.from.id, q.chat.id, q.message_id, stars) in your database here
await call('answerWidgetQuery', { widget_query_id: q.id, data: 'ok' });
await call('editMessageWidgetData', {
chat_id: q.chat.id,
message_id: q.message_id,
chat_data: JSON.stringify({ stars }),
});
}
Note the shape of widget_query: chat and message_id sit at the top level of the query (there is no message object), and type.type is "data" for a normal tap.
Things that bit me
-
Your bot library probably drops the update. Older libraries only dispatch update types they know, so
widget_querydisappears silently. Handle it before the library's dispatcher, or call the API directly as above. -
allowed_updates. If you ever set an explicitallowed_updateslist on your webhook, addwidget_queryto it. With the default (empty) list you receive it. -
Check the compressed size in CI.
python3 -c "import zlib,sys;print(len(zlib.compress(open(sys.argv[1],'rb').read(),9)))" rating.htmlmust print 4096 or less, orsendWidgetfails withINPUT_MEDIA_WIDGET_TOO_LARGE. -
Escape everything you render. Product names and user names end up in
innerHTMLif you are not careful. Escape& < > "or build nodes withtextContent. - Answer every query, even the ones you reject, and do it fast. Save first, answer, then edit the message.
- Always send a caption. It is the readable fallback, and the guide says widgets have no accessibility semantics yet.
-
No forms.
input,selectandtextareaare removed. Use buttons. -
Payments inside a widget use Telegram's TON wallet flow (
wallet.sendTransaction). Telegram Stars are not mentioned in the widget docs, so keep your normal invoice flow for Stars.
What this means for shop bots
For anyone selling in Telegram, the obvious wins are cards that replace a stream of status messages:
- an order card that goes from paid to delivered in one message, with a "Show key" button that returns the key only to the buyer who tapped it;
- a rating card after delivery, where a 1-2 star rating opens a support ticket instead of a public complaint;
- a subscription card for paid channels with days left and a Renew button.
I'm building exactly these for TeleCrow, a no-code builder for Telegram shop and paid-channel bots, and they are in testing on our own bots right now. If you would rather write your own, the example above is everything you need to start.
Official references: Widgets guide and the Bot API changelog.
Disclosure: I work on TeleCrow.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.