Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

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, WebSocket and 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:

  1. 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 with editMessageWidgetData. The widget hears chatDataChanged and redraws.
  2. 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_query disappears silently. Handle it before the library's dispatcher, or call the API directly as above.
  • allowed_updates. If you ever set an explicit allowed_updates list on your webhook, add widget_query to 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.html must print 4096 or less, or sendWidget fails with INPUT_MEDIA_WIDGET_TOO_LARGE.
  • Escape everything you render. Product names and user names end up in innerHTML if you are not careful. Escape & < > " or build nodes with textContent.
  • 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, select and textarea are 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.