<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vibe Seeker</title>
    <description>The latest articles on DEV Community by Vibe Seeker (@vibe_seeker).</description>
    <link>https://dev.to/vibe_seeker</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071576%2F00bb58ba-08ba-460e-a928-c38ae2c81ca2.png</url>
      <title>DEV Community: Vibe Seeker</title>
      <link>https://dev.to/vibe_seeker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vibe_seeker"/>
    <language>en</language>
    <item>
      <title>One Inline Button for Everyone: Parameters, Authentication, and Callbacks in a Telegram Mini App</title>
      <dc:creator>Vibe Seeker</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:02:36 +0000</pubDate>
      <link>https://dev.to/vibe_seeker/one-inline-button-for-everyone-parameters-authentication-and-callbacks-in-a-telegram-mini-app-9ef</link>
      <guid>https://dev.to/vibe_seeker/one-inline-button-for-everyone-parameters-authentication-and-callbacks-in-a-telegram-mini-app-9ef</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The code examples in this series are written in C#/.NET 10 (since that's what our production uses), but this article is about Telegram mechanics, not the language. The exact same architecture can be built on Node.js, Python, or Go without changes. That easily transferable mechanic is exactly why this article was written.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What is this article about?&lt;/strong&gt; This is the second part of a &lt;a href="https://dev.to/vibe_seeker/series/43096"&gt;series&lt;/a&gt; on how we vibe-coded GoosleeBot — a bridge from Telegram to Google Meet, Zoom, and other video conferencing services. This part covers the least-documented area of Telegram development: &lt;strong&gt;the session mechanism that ties together a chat, a mini app, and an external browser into a single application&lt;/strong&gt;. Here's what's inside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how to pass parameters from an inline bot into a mini app when the chat button is shared among all participants;&lt;/li&gt;
&lt;li&gt;how to authenticate a mini app user in a single request — with a ready-made skeleton for initData validation;&lt;/li&gt;
&lt;li&gt;how to preserve state where Telegram variables don't work at all — in an external browser;&lt;/li&gt;
&lt;li&gt;how to push changes from the chat into the mini app instantly (spoiler: WebSocket in telegram-webview works fine);&lt;/li&gt;
&lt;li&gt;how to edit chat messages with an async queue without getting banned by the Bot API;&lt;/li&gt;
&lt;li&gt;and more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Full Series Outline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/vibe_seeker/nuances-of-developing-a-business-app-on-telegram-mini-apps-what-i-learned-about-the-platform-3pa3"&gt;&lt;strong&gt;Part 1&lt;/strong&gt;&lt;/a&gt; — the product story and an honest verdict on Telegram as a platform: agent memory rules, the "where am I?" function, tunnels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 (you are here)&lt;/strong&gt; — the session mechanism: parameters, authentication, callbacks, editing without getting banned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3&lt;/strong&gt; — UX hacks: a bot that "rings" like a real phone, building an onboarding funnel in a single JSON file, and why you must pre-calculate URLs before the user clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Problem: Shared Button, Dumb Link, and Half the Flow Is Outside Telegram Entirely
&lt;/h2&gt;

&lt;p&gt;Our main scenario looks innocent enough: a chat has a bot message with a "Join the call" button. But that button has three unpleasant properties that no tutorial mentions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: one button for everyone.&lt;/strong&gt; The chat message is shared — all participants see it. Anyone can tap it, and for each person the mini app must open with their own context: who you are, which call you're in, what you're allowed to do. And the button can do exactly one thing — open a URL. The same URL for everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: authentication and callbacks are required.&lt;/strong&gt; Opening a page isn't enough — you need to know who opened it (without taking their word for it), and then deliver the results of their actions back: into the chat message, to other participants, into their mini app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, and most insidious: part of the flow leaves for an external browser.&lt;/strong&gt; OAuth authorization with Google or Zoom, by the providers' own rules, happens in the system browser — and the moment the user goes there, &lt;strong&gt;all mini app variables cease to exist&lt;/strong&gt;. No &lt;code&gt;Telegram.WebApp&lt;/code&gt;, no initData, nothing. Of all the Telegram context, the only thing that survives is exactly what you put into the URL yourself.&lt;/p&gt;

&lt;p&gt;Three problems — one solution: server-side sessions. And an important framing note right away: this isn't a trick for passing parameters. It's a full-fledged equivalent of a classic web session — a server-side object with a lifetime and a typed value store, on which all server logic rests. If your product has even one scenario of "leave the mini app and come back" — the session mechanism is mandatory, no exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Session as a Server-Side Object
&lt;/h2&gt;

&lt;p&gt;The skeleton is dead simple (reminder: this is C#, but it's just a dictionary with a TTL — available in any language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// Guid without dashes — goes into the URL&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;SessionType&lt;/span&gt; &lt;span class="n"&gt;SessionType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// RequestCall | MiniAppRoute | AccessToken | ...&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;SessionLifeTime&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsExpired&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;UtcNow&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;CreatedDate&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SessionLifeTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// typed scenario data&lt;/span&gt;
    &lt;span class="nc"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="n"&gt;SetData&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The store is a plain &lt;code&gt;ConcurrentDictionary&amp;lt;string, Session&amp;gt;&lt;/code&gt; in process memory; a background worker sweeps out expired entries. The key is a random Guid: it's unpredictable, so it acts as a secret on its own.&lt;/p&gt;

&lt;p&gt;The key thing here is &lt;code&gt;GetData&amp;lt;T&amp;gt;()&lt;/code&gt;. Each session type has its own data class, and that data isn't "for passing around" — it's &lt;strong&gt;the working state that server logic depends on&lt;/strong&gt;. For example, an inline-call session holds: who created the call, the list of users who pressed buttons, the chosen provider, the call status, the inline message ID in the chat, and the last rendered text of that message. The server reads and mutates this state at every step of the scenario — exactly like a classic web session; the only difference is that the key arrives not in a cookie (remember the rule from &lt;a href="https://dev.to/vibe_seeker/nuances-of-developing-a-business-app-on-telegram-mini-apps-what-i-learned-about-the-platform-3pa3"&gt;Part 1&lt;/a&gt;: cookies in the webview are unreliable) but in the URL and tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Chain: From a Chat Button to a Personal Page
&lt;/h2&gt;

&lt;p&gt;Now the actual solution — a chain of three sessions. It sounds heavyweight; in practice it's three dictionary entries and two redirects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[chat]     RequestCall session ─── shared, one per call
               │  key goes into the button: t.me/MyBot/app?startapp={key}
               ▼
[mini app] router page: collects Telegram.WebApp.initData → POST /init
               │  server validates the signature, finds the RequestCall by key
               ▼
[server]   AccessToken session ── personal, one per user,
               │                 holds a reference to the shared OriginalSession
               ▼
[page]     redirect to the target page with the personal token in the URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Creation.&lt;/strong&gt; When a user invokes the bot in a chat, the server creates a &lt;code&gt;RequestCall&lt;/code&gt; session — shared for that call. The key goes into the buttons. For callback buttons we encode it directly in the payload (&lt;code&gt;{key}@@@{command}&lt;/code&gt;); for the mini app button — in the &lt;code&gt;startapp&lt;/code&gt; parameter of the deep link. This is the only channel: Telegram won't let a button pass anything other than that string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The router.&lt;/strong&gt; The button doesn't open the target page — it opens a lightweight router page. Its JS collects &lt;code&gt;initData&lt;/code&gt; and sends it to the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Telegram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WebApp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/miniapp/init&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;initData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// signed string — for verification&lt;/span&gt;
    &lt;span class="na"&gt;startParam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initDataUnsafe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// RequestCall session key&lt;/span&gt;
    &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// remember "where am I?" from Part 1&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirectUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redirectUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Validation and exchange.&lt;/strong&gt; The server validates the initData signature (next section), finds the shared session by the key from &lt;code&gt;start_param&lt;/code&gt;, and issues a &lt;strong&gt;personal&lt;/strong&gt; &lt;code&gt;AccessToken&lt;/code&gt; session: "user X in the context of call Y." Inside it — a reference to the shared one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;accessSession&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SessionType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lifeTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;accessSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AccessTokenSessionData&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;UserId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OriginalSession&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requestCallSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// shared call context — one for all&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;RedirectUrlFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accessSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// token goes into the target page URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One shared call session — many personal tokens layered on top of it. Any request from the target page carries a personal token, and the server knows both "who" and "in which call" in a single lookup. The "one button for everyone" problem is solved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. External browser.&lt;/strong&gt; And now the whole point of all this. When a user returns from OAuth with Google and lands on our callback URL — they arrive &lt;strong&gt;in a regular browser, without a single Telegram variable&lt;/strong&gt;. But the token was placed in the URL in advance. The server retrieves the AccessToken session from it → then the OriginalSession from that → and continues the scenario as if nobody went anywhere. The state survived the transition chat → mini app → external browser → back, because it never left the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication: Don't Trust &lt;code&gt;initDataUnsafe&lt;/code&gt; at Face Value
&lt;/h2&gt;

&lt;p&gt;Telegram puts user data into the mini app two ways: &lt;code&gt;initDataUnsafe&lt;/code&gt; (a convenient parsed object) and &lt;code&gt;initData&lt;/code&gt; (a raw string with a cryptographic signature). The word "Unsafe" in the name isn't coy: anyone can fabricate that data by sending you any user_id they like. Using it for UI — fine. For server-side decisions — only after verifying the signature of the raw string.&lt;/p&gt;

&lt;p&gt;The validation algorithm is described in the Telegram docs; here's its complete skeleton — one static method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;initData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;botToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// initData is a query string&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;receivedHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"hash"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;dataCheckString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// all fields except hash,&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"hash"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// sorted by key&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;secretKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;HMACSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"WebAppData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;botToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;HMACSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dataCheckString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedHash&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// constant time!&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UtcNow&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;FromUnix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"auth_date"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MaxAge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// and freshness: we use 1 hour&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that an agent in vibe-coding mode consistently misses — check these by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Field sorting is ordinal&lt;/strong&gt;, not culture-dependent — otherwise the signature "sometimes doesn't match."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash comparison in constant time&lt;/strong&gt; (&lt;code&gt;FixedTimeEquals&lt;/code&gt;), not &lt;code&gt;==&lt;/code&gt; — protection against timing attacks on signature brute-forcing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking &lt;code&gt;auth_date&lt;/code&gt;.&lt;/strong&gt; A signature without an expiry is a permanent pass: a once-intercepted initData string would work for years.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After validation, the &lt;code&gt;user_id&lt;/code&gt; from initData can be treated as proven — Telegram has signed it. Notice what this gives you: &lt;strong&gt;registration, login, and password recovery in your application don't exist as tasks.&lt;/strong&gt; This is one of the main reasons to build business applications on Telegram at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honesty Box: All of This Lives in Process Memory
&lt;/h2&gt;

&lt;p&gt;Yes, our sessions are in-memory, &lt;code&gt;ConcurrentDictionary&lt;/code&gt;, no Redis. This is a deliberate tradeoff, and here are its boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When this is fine:&lt;/strong&gt; a single application instance; sessions are short-lived (minutes to hours) and inherently recoverable — in the worst case the user just taps the button again; business-critical state (calls, users, settings) lives in PostgreSQL anyway, the session only orchestrates the scenario.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it breaks:&lt;/strong&gt; a restart or deployment wipes live scenarios (for us that means "the button stopped responding, please invoke the menu again"); a second instance behind a load balancer won't see another instance's sessions — hello sticky sessions or an external store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The takeaway for vibe-coders: start with an in-memory dictionary — zero infrastructure and all the mechanics in this article work as-is. But define a session store interface from day one, so that Redis, when you need it, is a one-class swap, not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callbacks: Signal via WebSocket, Data via Request
&lt;/h2&gt;

&lt;p&gt;One last arrow remains: changes need to flow back. A participant chose a provider in their mini app — your page needs to update the status; someone joined the meeting — the chat message should reflect that.&lt;/p&gt;

&lt;p&gt;I already spoiled this in &lt;a href="https://dev.to/vibe_seeker/nuances-of-developing-a-business-app-on-telegram-mini-apps-what-i-learned-about-the-platform-3pa3"&gt;Part 1&lt;/a&gt;: SignalR (WebSocket) inside telegram-webview works natively on all platforms. The scheme:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the page opens, the client joins a group keyed by the &lt;strong&gt;shared&lt;/strong&gt; call session: all participants in the same call are in the same room.&lt;/li&gt;
&lt;li&gt;When server state changes, the server sends the group a bare &lt;code&gt;StateChanged&lt;/code&gt; event — &lt;strong&gt;with no data&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The client, upon receiving the signal, fetches the current state itself via a regular HTTP request with its personal token.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server: state changed → poke the group (with debounce, see below)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callSessionKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"StateChanged"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// client: the signal is not data — it's an invitation to ask&lt;/span&gt;
&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;StateChanged&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;refreshState&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;  &lt;span class="c1"&gt;// GET with own token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is the signal empty? Three reasons: no need to think about permissions in the push channel (each client fetches state with its own token — the server decides what it can see); a lost signal breaks nothing (the next &lt;code&gt;refreshState&lt;/code&gt; will catch up); and events can be &lt;strong&gt;debounced&lt;/strong&gt; — during a burst of changes, the group gets one nudge every N milliseconds instead of a queue of stale snapshots. Plus a cheap safety net: an infrequent background poll in case the WebSocket has actually died.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus Tip: Edit Messages Through a Queue — Otherwise You Get Banned
&lt;/h2&gt;

&lt;p&gt;Callbacks reach not only mini apps but also that very inline message in the chat: "Done. Connected: Anna, Dmitry." And here vibe-coders hit a widespread pitfall: naive code calls &lt;code&gt;editMessageText&lt;/code&gt; on every state change. Two participants press buttons simultaneously — two edits; a burst of events — a burst of edits. Bot API has two reactions to this: error &lt;code&gt;400: message is not modified&lt;/code&gt; (text matches what's already there) and flood limits up to temporary bot blocking — for a product living in other people's chats, that's death.&lt;/p&gt;

&lt;p&gt;The solution: don't edit the message from business logic at all. Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;logic:   "session X state changed" → mark X in queue (deduplication by key)
worker:  takes key → builds text and keyboard FROM CURRENT session state
         → compares with the last sent text (stored in the session!)
         → match: silently skip  |  changed: editMessageText, remember the text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties this achieves: &lt;strong&gt;deduplication&lt;/strong&gt; — ten changes per second produce one edit, because marks collapse by session key and the text is built from the final state; &lt;strong&gt;idempotency&lt;/strong&gt; — comparing against &lt;code&gt;LastInlineMessageText&lt;/code&gt; from the session guarantees we never send Telegram what it already has (note: this is yet another job for server-side session state); &lt;strong&gt;async&lt;/strong&gt; — business logic doesn't wait on the Telegram API and doesn't crash from its errors.&lt;/p&gt;

&lt;p&gt;Agent memory rule: &lt;strong&gt;the chat message is a projection of session state, updated by a background worker. Business logic never touches messages.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The mechanics in this article are a reusable skeleton for any business mini app with a "shared button" and an "exit to the outside": a server-side session with typed state; personal tokens layered over a shared context; the initData signature as a replacement for an entire login system; empty WebSocket signals instead of data in push; and a projection queue for chat messages. Not a single one of these elements is tied to C# — this is a protocol for working with the platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 3 will be about tricks:&lt;/strong&gt; how a bot "calls" a user so that the phone behaves like an actual incoming call (spoiler: we delete and resend the message — and why that works). Why the familiar web pattern "click → server generates link → redirect" is impossible in Telegram and how to live with the fact that the final URL must be in the button before the click. And the script-chat: an onboarding funnel entirely in a single JSON file — with a typing effect, slides, and per-step analytics.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Try the bridge yourself: &lt;a href="https://t.me/GoosleeBot" rel="noopener noreferrer"&gt;@GoosleeBot&lt;/a&gt; · &lt;a href="https://gooslibot.com" rel="noopener noreferrer"&gt;gooslibot.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>api</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>Nuances of Developing a Business App on Telegram Mini Apps: What I Learned About the Platform</title>
      <dc:creator>Vibe Seeker</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:26:55 +0000</pubDate>
      <link>https://dev.to/vibe_seeker/nuances-of-developing-a-business-app-on-telegram-mini-apps-what-i-learned-about-the-platform-3pa3</link>
      <guid>https://dev.to/vibe_seeker/nuances-of-developing-a-business-app-on-telegram-mini-apps-what-i-learned-about-the-platform-3pa3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The code examples in this series are written in C#/.NET 10 (since that's what our production uses), but this article is about Telegram mechanics, not the language. The exact same architecture can be built on Node.js, Python, or Go without changes. That easily transferable mechanic is exactly why this article was written.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What is this article about?&lt;/strong&gt; We used "vibe-coding" to build a bridge from Telegram to Google Meet, Zoom, and other video conferencing services. Along the way, we stepped on every possible rake you can hit when developing a business application as a Telegram Mini App. This article contains &lt;strong&gt;four practical hacks that you will absolutely need when writing any business app on this platform&lt;/strong&gt;. Here are some of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a ready-to-use block of rules for your agent's memory — Telegram web engine constraints plus catalog requirements (copy as is into CLAUDE.md);&lt;/li&gt;
&lt;li&gt;a "where am I?" function — platform and mode detection, without which every page turns into a guessing game;&lt;/li&gt;
&lt;li&gt;a tunnel from day one — a trick that turns deploy-for-every-tiny-change into lightning-fast iterations;&lt;/li&gt;
&lt;li&gt;how to manage state between bot calls, miniapp, and an external browser;&lt;/li&gt;
&lt;li&gt;a bot that "rings" like a phone;&lt;/li&gt;
&lt;li&gt;and more;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Full Series Outline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (You are here)&lt;/strong&gt; — The product's story and an honest verdict on Telegram as a platform for business apps: what's cheap, what's painful, and what you must tell your AI agent before writing the first line of code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/vibe_seeker/one-inline-button-for-everyone-parameters-authentication-and-callbacks-in-a-telegram-mini-app-9ef"&gt;Part 2&lt;/a&gt;&lt;/strong&gt; — The session mechanism: how to pass parameters from an inline bot to a Mini App and read them in an external browser; authentication, feedback loops, and how to avoid bans when editing messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3&lt;/strong&gt; — UX hacks: a bot that "rings" like a real phone, building an onboarding funnel in a single JSON file, and why you must pre-calculate URLs before the user clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It All Started
&lt;/h2&gt;

&lt;p&gt;Calls in Telegram work great — but strictly as calls: voice, video, hang up. Meetings are a completely different genre, and it's practically non-existent in Telegram. Meanwhile, ecosystems have grown around Google Meet and Zoom: AI summaries, transcriptions, recordings, calendar integrations, and CRM syncs. When a conversation turns to business, you usually want a proper meeting with all these tools.&lt;/p&gt;

&lt;p&gt;But the text conversation itself lives in Telegram: that's where your team group is, or the client who finds it "more convenient." Every time a chat matures to a "let's jump on a call" stage, a ritual begins: open Google Meet or Zoom, create a meeting, copy the link, return to the chat, paste it, and explain where to click.&lt;/p&gt;

&lt;p&gt;Our idea was brazenly simple: let the bot create the link. Right in the chat, with a single button, using the service available to both me and my counterpart. That's how GoosleeBot was born — a bridge from Telegram to the world of video meetings: Google Meet, Zoom, Microsoft Teams, Jitsi, FaceTime, and others.&lt;/p&gt;

&lt;p&gt;What followed was pure vibe-coding: AI agents, prompts, and iterations. The numbers speak for themselves: the first MVP (Google Meet, Zoom, and Jitsi) was ready &lt;strong&gt;in two days&lt;/strong&gt; and proved the idea worked. Then came &lt;strong&gt;four weeks&lt;/strong&gt; of polishing. Here is the crucial part: the main time sink wasn't writing code — AI writes code fast. The time was eaten up by the sheer volume of scenarios we had to cover and manually test: two participants vs. a group, the first call vs. the hundredth, whether the creator has a specific provider connected, iOS vs. Desktop, a private chat vs. an external group. Every combination had to be clicked through in the real Telegram app.&lt;/p&gt;

&lt;p&gt;That's why this article isn't a "look how great we are" showcase. It's a list of things I would &lt;strong&gt;hardcode into my AI agent's memory before writing the first line of code&lt;/strong&gt;. Because the main lesson is this: Telegram is an excellent platform for productivity apps, but its web engine is not a standard browser, and the Bot API is not a standard REST backend. If your agent doesn't know this, it will confidently generate beautiful code that simply won't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Looks to the User
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhkgpm52gw7qzlpq40hi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhkgpm52gw7qzlpq40hi.jpg" alt="the call starts right in the conversation" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6kvenvya18a83vj2q3v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6kvenvya18a83vj2q3v.jpg" alt="provider selection" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyka3contypurywcvfi22.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyka3contypurywcvfi22.jpg" alt="meeting created, live statuses" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfak85shzg5o5bl1qzct.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfak85shzg5o5bl1qzct.jpg" alt="the bot is calling" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scenario: in any chat, you trigger the bot and press a button — the bot creates a room with an available provider and places a "Join" button in the chat. If the other person doesn't respond, the bot can "call" them: sending repeated call messages, mimicking a real incoming call (how this is implemented is covered in part three of the series — it's a simple, clever trick).&lt;/p&gt;

&lt;p&gt;Neither participant installs anything new. That's the whole premise: &lt;strong&gt;distribution through chat&lt;/strong&gt; is a Telegram feature that no "traditional" platform offers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules to Put in Your Agent's Memory Before Starting
&lt;/h2&gt;

&lt;p&gt;This is the core section of the article. If you vibe-code for Telegram, copy this section directly into your agent's CLAUDE.md or system prompt as is. Every single rule was paid for with hours of debugging "why does this behave differently on iPhone."&lt;/p&gt;

&lt;h3&gt;
  
  
  Block A. Telegram's Web Engine Is Not a Browser
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never use &lt;code&gt;100vh&lt;/code&gt;.&lt;/strong&gt; Use only &lt;code&gt;viewportStableHeight&lt;/code&gt; / the CSS variable &lt;code&gt;var(--tg-viewport-height)&lt;/code&gt; and subscribe to the &lt;code&gt;viewportChanged&lt;/code&gt; event. What breaks: on iOS, the bottom of the page slides under the bottom panel; on Android, the keyboard compresses the viewport and breaks the layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;window.open&lt;/code&gt; or &lt;code&gt;target="\_blank"&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;Telegram.WebApp.openLink()&lt;/code&gt; for external links and &lt;code&gt;openTelegramLink()&lt;/code&gt; for internal ones. What breaks: on some clients, clicking simply does nothing without any error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;alert&lt;/code&gt;, &lt;code&gt;confirm&lt;/code&gt;, or &lt;code&gt;prompt&lt;/code&gt;.&lt;/strong&gt; Use only &lt;code&gt;showAlert&lt;/code&gt;, &lt;code&gt;showConfirm&lt;/code&gt;, or &lt;code&gt;showPopup&lt;/code&gt;. What breaks: the same silent failure instead of a modal dialog, happening unpredictably for certain users, which is even harder to debug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on cookies for authentication in miniapps.&lt;/strong&gt; The Webview loses them unpredictably. Authenticate using an explicitly passed token (in headers or query parameters). What breaks: the user gets logged out between sessions, leaving you searching for a non-existent server bug for a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt; is a cache, not a database.&lt;/strong&gt; Store everything important on the server (or in &lt;code&gt;CloudStorage&lt;/code&gt;). What breaks: user state suddenly disappears, and you won't even realize it happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Back" navigation must go through Telegram's &lt;code&gt;BackButton&lt;/code&gt;,&lt;/strong&gt; not the browser history. What breaks: the system back button on Android closes the entire miniapp instead of going back one step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first thing on every page — figure out where you are:&lt;/strong&gt; platform and mode (miniapp or standard browser). CSS and logic branch from here. Function provided below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything the user can click must be ready before the click.&lt;/strong&gt; The standard web flow "click → server processes → builds URL → redirect" doesn't work in Telegram: there are no redirects. The target URL must already be inside the button when it renders — so all redirect logic must be pre-calculated (detailed breakdown in Part 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on at least three platforms:&lt;/strong&gt; iOS, Android, Desktop. These are three distinct webviews with different rendering and behavior. "It works on my desktop" counts as zero out of three.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Block B. Catalog Requirements — From Day One, Not "We'll Polish Later"
&lt;/h3&gt;

&lt;p&gt;App catalogs (both Telegram's official directory and third-party miniapp showcases) have strict requirements. Adapting a finished app for them later is the most expensive work in a project. So configure your agent's memory strictly from the start:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multi-language support from the very first screen.&lt;/strong&gt; Not a single hardcoded string in the markup — route everything through locale files. The user's language code comes automatically via &lt;code&gt;initDataUnsafe.user.language\_code&lt;/code&gt;. Adding a second language to a codebase with hardcoded UI text means rewriting the application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Theming only via &lt;code&gt;themeParams&lt;/code&gt;.&lt;/strong&gt; All colors must come from &lt;code&gt;--tg-theme-\*&lt;/code&gt; CSS variables. Dark and light themes are mandatory, along with subscribing to &lt;code&gt;themeChanged&lt;/code&gt;. Never hardcode colors. What breaks: white text on a white background for half your users — and rejection from catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalogs test languages, both themes, and all platforms.&lt;/strong&gt; These are mandatory release requirements, not optional polish. The agent must consider them part of the Definition of Done for every page.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The "Where Am I?" Function — An Essential First Building Block
&lt;/h2&gt;

&lt;p&gt;In our app, the exact same page opens on iOS, Android, desktop — and sometimes in a regular browser when a user copies a link. CSS and behavior differ in all four cases, so this helper was the very first piece of code we wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;whereAmI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Telegram&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;WebApp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inMiniApp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;tg&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initData&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// empty initData = regular browser&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// ios | android | tdesktop | macos | weba | ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inMiniApp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// then — add class to body and branch behavior&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`plt-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inMiniApp&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in-tg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in-browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where this matters in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS.&lt;/strong&gt; Header/footer spacing, iOS safe areas, keyboard behavior on Android — everything attaches to &lt;code&gt;plt-\*&lt;/code&gt; CSS classes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects.&lt;/strong&gt; After Google OAuth authentication, a mobile user should return to Telegram via a &lt;code&gt;tg://&lt;/code&gt; deep link, while a desktop user receives a standard &lt;code&gt;https&lt;/code&gt; redirect. Get this wrong, and you end up with either a frozen browser or a user kicked out of the miniapp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback for non-Telegram environments.&lt;/strong&gt; If &lt;code&gt;initData&lt;/code&gt; is empty, it's not a miniapp — so instead of the app, we show an "open via bot" landing page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small detail? Sure. But without it, every page turns into a guessing game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Tip: A Tunnel From Day One
&lt;/h2&gt;

&lt;p&gt;Telegram doesn't work with "localhost": both the bot's webhook and the miniapp URL must be public HTTPS endpoints. That's why your first tool on a project — before the database, before CI — should be a tunneling service: ngrok, cloudflared, Visual Studio dev tunnels, or anything that exposes your local machine to a persistent public URL.&lt;/p&gt;

&lt;p&gt;This creates a smooth dev workflow: the bot and miniapp point to the tunnel URL, you edit code locally with hot reload, and test changes immediately in real Telegram on a physical phone. Without a tunnel, every iteration requires a full deployment; with a tunnel, it takes seconds. Remember the endless scenarios that took four weeks? Without a tunnel, that would have taken months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict: Telegram as a Platform for Productivity Apps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is cheap — absurdly cheap:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distribution.&lt;/strong&gt; The product spreads simply by sharing a button in a chat. Zero installations, zero landing pages to launch: the other person sees the button — and instantly becomes a user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-the-box authentication.&lt;/strong&gt; Telegram cryptographically signs user data and hands it to the miniapp. No sign-ups, no passwords, no "Log in with Google" needed — you only need to verify the signature correctly (which is covered in detail in &lt;a href="https://dev.to/vibe_seeker/one-inline-button-for-everyone-parameters-authentication-and-callbacks-in-a-telegram-mini-app-9ef"&gt;Part 2&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline mechanics.&lt;/strong&gt; The bot works in any chat, even where it isn't added as a member. That's the essence of the "bridge": the product lives directly inside existing conversations instead of waiting for users to visit a separate app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time features work — and you should use them.&lt;/strong&gt; We were skeptical whether SignalR (WebSockets) would run smoothly inside Telegram's webview. It did: a full real-time hub with groups runs reliably in production across all platforms. Call status updates land in the miniapp instantly without page reloads. We keep polling fallback as insurance, not as the primary channel. If your agent suggests "let's just poll every second," don't fall for it — WebSocket connections work fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What is painful:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client fragmentation.&lt;/strong&gt; iOS, Android, Desktop, web — four separate Telegram implementations with different webviews. Hence Rule #9 and the "where am I?" function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A web engine with quirks.&lt;/strong&gt; Everything in Block A above paints its full picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalog guidelines.&lt;/strong&gt; Multi-language support and themes aren't optional. Anyone who discovers this at the end of development ends up rewriting their UI layout.&lt;/li&gt;
&lt;li&gt;On a minor note: receiving updates from Telegram toggles with a flag between webhook and polling in our code — super handy for debugging, and that's pretty much all you need to know about that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bottom line: if your product is about communication, coordination, or "getting things done without leaving the chat," Telegram gives you a massive head start over traditional platforms. The cost is webview discipline. And that discipline can easily be delegated: just hand your agent the rules from this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next in the Series
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/vibe_seeker/one-inline-button-for-everyone-parameters-authentication-and-callbacks-in-a-telegram-mini-app-9ef"&gt;Part 2 — the subtle mechanics.&lt;/a&gt;&lt;/strong&gt; An inline chat button is shared by all participants and simply opens a URL. How do you pass specific call parameters into the miniapp? How do you verify who clicked it without getting spoofed? How do you send responses back from the miniapp into the chat? Plus a sneaky trap: part of the workflow inevitably leads to an external browser (e.g. Google OAuth) — where miniapp variables don't exist at all, leaving only what you explicitly passed in the URL. I'll share our session mechanism that solves all of this at once — it's not just a parameter-passing hack, but a true server-side session abstraction: a typed key-value store powering the entire call logic. Bonus: how to edit chat messages via an async queue without getting rate-limited by the Bot API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 3 — the tricks.&lt;/strong&gt; How to make a bot "call" — complete with repeated ring notifications like a real incoming call (spoiler: we delete and re-send the message). Why links must be prepared ahead of time — and this isn't just about pre-generating URLs: in standard web, you click, the server processes the request, generates a link, and redirects — in Telegram, this flow physically doesn't work since there are no redirects, so all redirect workload must be pre-calculated before the click. How to onboard users using a simple JSON file instead of a complex onboarding library: a scripted chat that types, shows slides, and sends funnel metrics to analytics.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Try the bridge yourself:&lt;/em&gt; &lt;a href="https://t.me/GoosleeBot" rel="noopener noreferrer"&gt;&lt;em&gt;@GoosleeBot&lt;/em&gt;&lt;/a&gt; &lt;em&gt;·&lt;/em&gt; &lt;a href="https://gooslibot.com" rel="noopener noreferrer"&gt;&lt;em&gt;gooslibot.com&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>api</category>
      <category>vibecoding</category>
    </item>
  </channel>
</rss>
