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.
What is this article about? This is the second part of a series 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: the session mechanism that ties together a chat, a mini app, and an external browser into a single application. Here's what's inside:
- how to pass parameters from an inline bot into a mini app when the chat button is shared among all participants;
- how to authenticate a mini app user in a single request — with a ready-made skeleton for initData validation;
- how to preserve state where Telegram variables don't work at all — in an external browser;
- how to push changes from the chat into the mini app instantly (spoiler: WebSocket in telegram-webview works fine);
- how to edit chat messages with an async queue without getting banned by the Bot API;
- and more.
The Full Series Outline:
- Part 1 — the product story and an honest verdict on Telegram as a platform: agent memory rules, the "where am I?" function, tunnels.
- Part 2 (you are here) — the session mechanism: parameters, authentication, callbacks, editing without getting banned.
- Part 3 — 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.
The Problem: Shared Button, Dumb Link, and Half the Flow Is Outside Telegram Entirely
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:
First: one button for everyone. 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.
Second: authentication and callbacks are required. 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.
Third, and most insidious: part of the flow leaves for an external browser. OAuth authorization with Google or Zoom, by the providers' own rules, happens in the system browser — and the moment the user goes there, all mini app variables cease to exist. No Telegram.WebApp, no initData, nothing. Of all the Telegram context, the only thing that survives is exactly what you put into the URL yourself.
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.
The Session as a Server-Side Object
The skeleton is dead simple (reminder: this is C#, but it's just a dictionary with a TTL — available in any language):
public sealed class Session
{
public string Key { get; init; } // Guid without dashes — goes into the URL
public SessionType SessionType { get; set; } // RequestCall | MiniAppRoute | AccessToken | ...
public TimeSpan SessionLifeTime { get; set; }
public bool IsExpired => UtcNow - CreatedDate > SessionLifeTime;
public T? GetData<T>() where T : class { ... } // typed scenario data
public void SetData<T>(T? value) where T : class { ... }
}
The store is a plain ConcurrentDictionary<string, Session> 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.
The key thing here is GetData<T>(). Each session type has its own data class, and that data isn't "for passing around" — it's the working state that server logic depends on. 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 Part 1: cookies in the webview are unreliable) but in the URL and tokens.
The Chain: From a Chat Button to a Personal Page
Now the actual solution — a chain of three sessions. It sounds heavyweight; in practice it's three dictionary entries and two redirects:
[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
Step by step:
1. Creation. When a user invokes the bot in a chat, the server creates a RequestCall session — shared for that call. The key goes into the buttons. For callback buttons we encode it directly in the payload ({key}@@@{command}); for the mini app button — in the startapp parameter of the deep link. This is the only channel: Telegram won't let a button pass anything other than that string.
2. The router. The button doesn't open the target page — it opens a lightweight router page. Its JS collects initData and sends it to the server:
const tg = window.Telegram.WebApp;
const resp = await fetch('/miniapp/init', {
method: 'POST',
body: JSON.stringify({
initData: tg.initData, // signed string — for verification
startParam: tg.initDataUnsafe.start_param, // RequestCall session key
platform: tg.platform, // remember "where am I?" from Part 1
}),
});
const { redirectUrl } = await resp.json();
location.replace(redirectUrl);
3. Validation and exchange. The server validates the initData signature (next section), finds the shared session by the key from start_param, and issues a personal AccessToken session: "user X in the context of call Y." Inside it — a reference to the shared one:
var accessSession = SessionService.Create(SessionType.AccessToken, lifeTime);
accessSession.SetData(new AccessTokenSessionData {
UserId = user.Id,
OriginalSession = requestCallSession, // shared call context — one for all
});
return RedirectUrlFor(page, accessSession.Key); // token goes into the target page URL
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.
4. External browser. And now the whole point of all this. When a user returns from OAuth with Google and lands on our callback URL — they arrive in a regular browser, without a single Telegram variable. 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.
Authentication: Don't Trust initDataUnsafe at Face Value
Telegram puts user data into the mini app two ways: initDataUnsafe (a convenient parsed object) and initData (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.
The validation algorithm is described in the Telegram docs; here's its complete skeleton — one static method:
public static bool Validate(string initData, string botToken)
{
var parsed = ParseQuery(initData); // initData is a query string
var receivedHash = parsed["hash"];
var dataCheckString = string.Join('\n', // all fields except hash,
parsed.Where(kv => kv.Key != "hash") // sorted by key
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => $"{kv.Key}={kv.Value}"));
var secretKey = HMACSHA256(key: "WebAppData", message: botToken);
var expected = HMACSHA256(key: secretKey, message: dataCheckString);
if (!FixedTimeEquals(expected, receivedHash)) return false; // constant time!
return UtcNow - FromUnix(parsed["auth_date"]) < MaxAge; // and freshness: we use 1 hour
}
Three details that an agent in vibe-coding mode consistently misses — check these by hand:
- Field sorting is ordinal, not culture-dependent — otherwise the signature "sometimes doesn't match."
-
Hash comparison in constant time (
FixedTimeEquals), not==— protection against timing attacks on signature brute-forcing. -
Checking
auth_date. A signature without an expiry is a permanent pass: a once-intercepted initData string would work for years.
After validation, the user_id from initData can be treated as proven — Telegram has signed it. Notice what this gives you: registration, login, and password recovery in your application don't exist as tasks. This is one of the main reasons to build business applications on Telegram at all.
Honesty Box: All of This Lives in Process Memory
Yes, our sessions are in-memory, ConcurrentDictionary, no Redis. This is a deliberate tradeoff, and here are its boundaries:
- When this is fine: 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.
- When it breaks: 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.
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.
Callbacks: Signal via WebSocket, Data via Request
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.
I already spoiled this in Part 1: SignalR (WebSocket) inside telegram-webview works natively on all platforms. The scheme:
- When the page opens, the client joins a group keyed by the shared call session: all participants in the same call are in the same room.
- When server state changes, the server sends the group a bare
StateChangedevent — with no data. - The client, upon receiving the signal, fetches the current state itself via a regular HTTP request with its personal token.
// server: state changed → poke the group (with debounce, see below)
await hub.Clients.Group(callSessionKey).SendAsync("StateChanged");
// client: the signal is not data — it's an invitation to ask
connection.on('StateChanged', () => refreshState()); // GET with own token
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 refreshState will catch up); and events can be debounced — 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.
Bonus Tip: Edit Messages Through a Queue — Otherwise You Get Banned
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 editMessageText 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 400: message is not modified (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.
The solution: don't edit the message from business logic at all. Instead:
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
Three properties this achieves: deduplication — ten changes per second produce one edit, because marks collapse by session key and the text is built from the final state; idempotency — comparing against LastInlineMessageText from the session guarantees we never send Telegram what it already has (note: this is yet another job for server-side session state); async — business logic doesn't wait on the Telegram API and doesn't crash from its errors.
Agent memory rule: the chat message is a projection of session state, updated by a background worker. Business logic never touches messages.
Summary
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.
Part 3 will be about tricks: 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.
Try the bridge yourself: @GoosleeBot · gooslibot.com
Top comments (0)