-
-
Notifications
You must be signed in to change notification settings - Fork 0
10. Demo & Examples Deep Dive
Comprehensive guide to understanding the telegram-webapp-sdk demo application and bot backend examples. This chapter explains every component, every page, every function, and shows you exactly how everything works together.
- Architecture Overview
- Demo Application Structure
- Application Entry Point
- Routing System
- Pages Explained
- Components Explained
- Bot Backend Integration
- Complete Data Flow
- Running and Testing
- Troubleshooting
The telegram-webapp-sdk demo demonstrates a complete Telegram Mini App with both frontend (WebApp) and backend (Bot) components.
┌─────────────────────────────────────────────────────────────┐
│ Telegram Ecosystem │
│ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ Telegram User │ │ Your Server │ │
│ │ │ │ │ │
│ │ ┌──────────┐ │ HTTPS │ ┌────────────┐ │ │
│ │ │ WebApp │──┼────────►│ │ Bot API │ │ │
│ │ │ (Rust/ │ │ Data │ │ Handler │ │ │
│ │ │ WASM) │ │ │ │ (teloxide) │ │ │
│ │ └──────────┘ │ │ └────────────┘ │ │
│ │ │ │ │ │
│ │ Telegram App │ │ Rust Backend │ │
│ └────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Frontend (WebApp)
- Rust with WASM compilation
- telegram-webapp-sdk for Telegram integration
- web-sys for DOM manipulation
- wasm-bindgen for JavaScript interop
- Trunk for building and bundling
Backend (Bot)
- Rust with tokio async runtime
- teloxide for Telegram Bot API
- masterror for error handling
- serde for JSON serialization
- tracing for structured logging
demo/
├── src/
│ ├── main.rs # Application entry point
│ ├── router.rs # Client-side routing logic
│ ├── components.rs # Component module exports
│ ├── pages.rs # Page module exports
│ ├── components/
│ │ ├── page_layout.rs # Page layout wrapper
│ │ ├── display_data.rs # Key-value display component
│ │ ├── nav_link.rs # Navigation link component
│ │ ├── rgb.rs # Color swatch component
│ │ └── dev_menu.rs # Development menu controls
│ └── pages/
│ ├── index.rs # Home page with navigation
│ ├── burger_king.rs # Order demo with send_data()
│ ├── init_data.rs # User/chat information
│ ├── launch_params.rs # Platform and version info
│ └── theme_params.rs # Telegram color scheme
├── index.html # HTML entry point
├── style.css # Application styles
├── Cargo.toml # Rust dependencies
├── Trunk.toml # Build configuration
└── README.md # Documentation
Cargo.toml
[package]
name = "demo"
version = "0.2.6"
edition = "2024"
rust-version.workspace = true
[dependencies]
# Core WebAssembly bindings
wasm-bindgen = "0.2"
# Web APIs (Window, Document, HtmlElement, etc.)
web-sys = { version = "0.3", features = [
"Window", # window object
"Document", # document object
"HtmlElement", # HTML elements
"HtmlImageElement", # <img> elements
"History", # history.pushState
"Location", # window.location
"Event", # DOM events
"EventTarget", # addEventListener
"CustomEvent", # Custom events
"CustomEventInit", # Event initialization
"console", # console.log
"Text", # Text nodes
] }
# Telegram WebApp SDK with mock and macros
telegram-webapp-sdk = { path = "../", features = ["mock", "macros"] }
# Error handling
masterror = { workspace = true }
# Page registry system
inventory = { workspace = true }Key Features Enabled:
-
mock- Enables testing without Telegram -
macros- Providestelegram_app!,telegram_page!,telegram_button!macros
Purpose: Initialize the Telegram WebApp and start the router.
Complete Code Breakdown:
#![no_main]- Tells Rust not to use standard main function
- Required for WebAssembly applications
- Entry point is controlled by wasm-bindgen
pub mod components;
pub mod pages;
pub mod router;- Declare module structure
- Makes components and pages accessible
- Router handles navigation
use components::dev_menu::setup_dev_menu;
use telegram_webapp_sdk::{telegram_app, telegram_router};
use wasm_bindgen::prelude::*;- Import dev menu setup function
- Import SDK macros
- Import wasm-bindgen for WASM interop
telegram_app!(
pub fn main() -> Result<(), JsValue> {
setup_dev_menu();
telegram_router!();
Ok(())
}
);What telegram_app! does:
- Creates actual
#[wasm_bindgen(start)]function - Initializes Telegram WebApp context
- Parses launch parameters from URL
- Stores context in thread-local storage
- Calls your main function
What telegram_router! does:
- Collects all pages registered with
telegram_page!macro - Builds routing table from page paths
- Renders initial route based on current URL
- Sets up
popstatelistener for back/forward buttons
Initialization Flow:
Browser loads WASM
↓
telegram_app! expands to:
↓
#[wasm_bindgen(start)]
fn __telegram_app_start() {
// Parse tgWebAppData from URL
// Initialize context
// Call your main()
}
↓
setup_dev_menu()
↓
telegram_router!()
↓
Router renders current page
↓
Application ready
Purpose: Client-side routing without full page reloads.
Complete Implementation Explained:
use std::collections::HashMap;
use telegram_webapp_sdk::pages::Page;
use wasm_bindgen::prelude::*;
use web_sys::{Event, EventTarget, window};
type RenderFn = fn();Data Structures:
#[derive(Default)]
pub struct Router {
routes: HashMap<String, RenderFn>
}-
routesmaps path strings to render functions - Example:
"/burger-king"→render_burger_king_page
Building the Router:
pub fn from_pages(pages: impl Iterator<Item = &'static Page>) -> Self {
let mut router = Self::new();
for page in pages {
router = router.register(page.path, page.handler);
}
router
}How telegram_router! works:
-
inventory::iter::<Page>()collects all pages - Each
telegram_page!registers aPagestruct:Page { path: "/burger-king", handler: render_burger_king_page, }
- Router builds HashMap of all routes
Navigation Implementation:
pub fn navigate(path: &str) {
if let Some(w) = window()
&& let Ok(history) = w.history()
{
// Push new state
let _ = history.push_state_with_url(&JsValue::NULL, "", Some(path));
// Manually trigger popstate event
if let Ok(event) = web_sys::CustomEvent::new("popstate") {
let _ = w.dispatch_event(&event);
}
}
}Why manually dispatch popstate?
-
history.pushStatedoesn't trigger popstate automatically - We need to manually fire the event to render new route
- Browser back/forward buttons trigger popstate naturally
Event Listener Setup:
pub fn start(&self) {
self.render_current();
if let Some(w) = window() {
let closure = Closure::<dyn FnMut(_)>::new({
let router = self.routes.clone();
move |_event: Event| {
if let Some(path) = current_path()
&& let Some(page) = router.get(&path)
{
page(); // Call render function
}
}
});
let target: EventTarget = w.into();
target.add_event_listener_with_callback(
"popstate",
closure.as_ref().unchecked_ref()
).unwrap();
closure.forget(); // Keep listener alive forever
}
}Important: closure.forget()
- JavaScript needs the closure to stay alive
- Rust would normally drop it after function ends
-
forget()leaks the memory intentionally - This is safe for application-lifetime listeners
Purpose: Landing page with navigation to all features.
Key Code Sections:
telegram_page!(
"/",
pub fn render_index_page() {
clear_app_root();
let page = PageLayout::new("Telegram WebApp SDK Demo");
// ... build navigation
}
);What telegram_page! macro does:
// Expands to:
inventory::submit! {
&telegram_webapp_sdk::pages::Page {
path: "/",
handler: render_index_page,
}
}Logo Display:
let logo = telegram_image!(
document,
"https://telegram.org/img/t_logo.png",
class = "logo",
alt = "Telegram logo"
).unwrap();
page.append(logo.as_ref());telegram_image! macro expansion:
// Expands to:
{
let img = document
.create_element("img")?
.dyn_into::<HtmlImageElement>()?;
img.set_src("https://telegram.org/img/t_logo.png");
img.set_class_name("logo");
img.set_alt("Telegram logo");
Ok(img)
}Navigation Links:
page.append(&nav_link(
"Burger King Demo",
Some("Order burgers via Telegram"),
"/burger-king"
));Creates a clickable link that navigates to /burger-king route.
Purpose: Demonstrates sending data from WebApp to bot.
Complete Flow Explanation:
Step 1: Define Menu Item Structure
#[derive(Clone, Debug, PartialEq)]
struct MenuItem {
id: u32,
name: &'static str,
price_cents: u32
}
impl MenuItem {
fn payload(&self) -> String {
format!(
r#"{{"id":{},"name":"{}","price_cents":{}}}"#,
self.id, self.name, self.price_cents
)
}
}Why JSON string format?
-
send_data()sends string to bot - Bot expects JSON format
- Manual formatting avoids serde dependency
- Ensures exact format bot expects
Step 2: Render Menu Items
telegram_page!(
"/burger-king",
pub fn render_burger_king_page() {
let page = PageLayout::with_header("Burger King Demo", "Burger King Menu");
let items = [
MenuItem { id: 1, name: "Whopper", price_cents: 599 },
MenuItem { id: 2, name: "Cheeseburger", price_cents: 299 },
MenuItem { id: 3, name: "Chicken Nuggets", price_cents: 399 }
];
for item in &items {
match render_item(item) {
Ok(el) => page.append(&el),
Err(err) => logger::error(&format!("render_item failed: {:?}", err))
}
}
}
);Step 3: Create Order Button
fn render_item(item: &MenuItem) -> Result<Element, JsValue> {
let document = document()?;
let container = document.create_element("div")?;
container.set_class_name("menu-item");
// Item label with price
let label = document.create_element("span")?;
label.set_inner_html(&format!(
"{} - ${:.2}",
item.name,
item.price_cents as f64 / 100.0
));
container.append_child(&label)?;
// Order button
let button_el = telegram_button!(document, "Order", class = "order-button")?;
// Click handler
let item_clone = item.clone();
let click = Closure::<dyn FnMut()>::new(move || {
if let Some(app) = TelegramWebApp::instance() {
if let Err(err) = app.send_data(&item_clone.payload()) {
logger::error(&format!("send_data failed: {:?}", err));
}
}
});
button_el.set_onclick(Some(click.as_ref().unchecked_ref()));
click.forget();
container.append_child(&button_el)?;
Ok(container)
}What happens when user clicks "Order":
User clicks "Order"
↓
JavaScript onclick fires
↓
Rust Closure executes
↓
TelegramWebApp::instance()
↓
app.send_data(JSON payload)
↓
Calls Telegram.WebApp.sendData()
↓
Telegram sends data to bot
↓
Bot receives web_app_data message
↓
Bot handler processes order
telegram_button! macro:
// Expands to:
{
let btn = document
.create_element("button")?
.dyn_into::<HtmlElement>()?;
btn.set_inner_html("Order");
btn.set_class_name("order-button");
Ok(btn)
}Purpose: Display user and chat information from Telegram.
How Context Access Works:
use telegram_webapp_sdk::core::safe_context::get_context;
telegram_page!(
"/init-data",
pub fn render_init_data_page() {
let layout = PageLayout::new("Init Data");
let result = get_context(|ctx| {
let mut rows = vec![];
if let Some(user) = &ctx.init_data.user {
rows.push(DisplayDataRow {
title: "id".into(),
value: user.id.to_string()
});
rows.push(DisplayDataRow {
title: "username".into(),
value: user.username.clone().unwrap_or_default()
});
rows.push(DisplayDataRow {
title: "language".into(),
value: user.language_code.clone().unwrap_or_default()
});
}
Some(rows)
});
// Render rows or error message
match result {
Ok(Some(rows)) => {
match render_display_data("User", &rows) {
Ok(section) => layout.append(§ion),
Err(err) => web_sys::console::error_1(&err.into())
}
},
_ => {
// Show error fallback
}
}
}
);get_context implementation:
pub fn get_context<F, R>(f: F) -> Result<R, AppError>
where
F: FnOnce(&TelegramContext) -> R,
{
TELEGRAM_CONTEXT.with(|ctx| {
ctx.borrow()
.as_ref()
.ok_or_else(|| AppError::internal("Context not initialized"))
.map(|context| f(context))
})
}Thread-Local Context Storage:
thread_local! {
static TELEGRAM_CONTEXT: RefCell<Option<TelegramContext>> = RefCell::new(None);
}Why thread-local?
- WebAssembly is single-threaded
- Provides global access without static mut
- Safe interior mutability with RefCell
- Automatically initialized by
telegram_app!
InitData Structure:
pub struct InitData {
pub query_id: Option<String>,
pub user: Option<WebAppUser>,
pub receiver: Option<WebAppUser>,
pub chat: Option<WebAppChat>,
pub chat_type: Option<String>,
pub chat_instance: Option<String>,
pub start_param: Option<String>,
pub can_send_after: Option<u64>,
pub auth_date: u64,
pub hash: String,
}Purpose: Display platform and version information.
Key Code:
use telegram_webapp_sdk::core::context::get_launch_params;
telegram_page!(
"/launch-params",
pub fn render_launch_params_page() {
let page = PageLayout::new("Launch Parameters");
let lp = match get_launch_params() {
Ok(params) => params,
Err(err) => {
web_sys::console::error_1(&err);
return;
}
};
let rows = vec![
DisplayDataRow {
title: "tgWebAppPlatform".into(),
value: lp.tg_web_app_platform.unwrap_or_else(|| "unknown".into())
},
DisplayDataRow {
title: "tgWebAppVersion".into(),
value: lp.tg_web_app_version.unwrap_or_else(|| "unknown".into())
},
DisplayDataRow {
title: "tgWebAppStartParam".into(),
value: lp.tg_web_app_start_param.unwrap_or_else(|| "–".into())
},
// ... more fields
];
match render_display_data("Launch Parameters", &rows) {
Ok(section) => page.append(§ion),
Err(err) => web_sys::console::error_1(&err.into())
}
}
);LaunchParams Structure:
pub struct LaunchParams {
pub tg_web_app_platform: Option<String>, // "ios", "android", "web"
pub tg_web_app_version: Option<String>, // "7.0"
pub tg_web_app_start_param: Option<String>, // Deep link parameter
pub tg_web_app_show_settings: Option<bool>, // Show settings button
pub tg_web_app_bot_inline: Option<bool>, // Inline bot mode
}Platform Values:
-
"ios"- iPhone, iPad -
"android"- Android devices -
"macos"- macOS desktop app -
"tdesktop"- Windows/Linux desktop app -
"weba"- Web version (A) -
"webk"- Web version (K) -
"unigram"- Unigram client -
"unknown"- Unknown platform
Purpose: Display Telegram app color scheme.
Complete Implementation:
use telegram_webapp_sdk::core::safe_context::get_context;
telegram_page!(
"/theme-params",
pub fn render_theme_params_page() {
let page = PageLayout::new("Theme Parameters");
let rows: Vec<DisplayDataRow> = get_context(|ctx| {
ctx.theme_params
.to_map()
.into_iter()
.map(|(key, value)| DisplayDataRow {
title: key,
value
})
.collect()
})
.unwrap_or_else(|_| {
vec![DisplayDataRow {
title: "Error".into(),
value: "Failed to load theme params".into()
}]
});
match render_display_data("Theme Params", &rows) {
Ok(section) => page.append(§ion),
Err(err) => web_sys::console::error_1(&err.into())
}
}
);ThemeParams Structure:
pub struct ThemeParams {
pub bg_color: Option<String>,
pub text_color: Option<String>,
pub hint_color: Option<String>,
pub link_color: Option<String>,
pub button_color: Option<String>,
pub button_text_color: Option<String>,
pub secondary_bg_color: Option<String>,
pub header_bg_color: Option<String>,
pub accent_text_color: Option<String>,
pub section_bg_color: Option<String>,
pub section_header_text_color: Option<String>,
pub subtitle_text_color: Option<String>,
pub destructive_text_color: Option<String>,
}Using Theme Colors in CSS:
body {
background-color: var(--tg-theme-bg-color, #ffffff);
color: var(--tg-theme-text-color, #000000);
}
button {
background-color: var(--tg-theme-button-color, #0088cc);
color: var(--tg-theme-button-text-color, #ffffff);
}How Telegram injects CSS variables:
Telegram automatically injects CSS variables based on theme:
--tg-theme-bg-color--tg-theme-text-color--tg-theme-hint-color- etc.
These are available immediately, no SDK initialization required.
Purpose: Consistent page wrapper with title and content area.
Implementation:
use web_sys::{Document, Element, window};
pub struct PageLayout {
pub root: Element
}
impl PageLayout {
pub fn new(title: &str) -> Self {
let document = document();
// Set browser tab title
document.set_title(title);
// Get #app-root container
let root = document
.get_element_by_id("app-root")
.expect("Expected <div id='app-root'> to exist");
// Clear previous content
root.set_inner_html("");
Self { root }
}
pub fn with_header(title: &str, header: &str) -> Self {
let layout = Self::new(title);
// Create <h1> header
let h1 = document()
.create_element("h1")
.expect("failed to create <h1>");
h1.set_inner_html(header);
layout.root.append_child(&h1).unwrap();
layout
}
pub fn append(&self, element: &Element) {
let _ = self.root.append_child(element);
}
}
fn document() -> Document {
window().unwrap().document().unwrap()
}Usage Example:
let page = PageLayout::new("My Page");
page.append(&some_element);
page.append(&another_element);Purpose: Render key-value data rows with color swatches.
Key Features:
- Displays data as title-value pairs
- Detects
#RRGGBBcolors and shows swatches - Error handling with masterror
Implementation:
use masterror::{AppError, AppResult};
use web_sys::{Element, window};
use crate::components::rgb::RGB;
pub struct DisplayDataRow {
pub title: String,
pub value: String
}
pub fn render_display_data(header: &str, rows: &[DisplayDataRow]) -> AppResult<Element> {
let document = window()
.ok_or_else(|| AppError::internal("no window"))?
.document()
.ok_or_else(|| AppError::internal("no document"))?;
// Create <section class="display-data">
let section = document
.create_element("section")
.map_err(|_| AppError::internal("create section"))?;
section.set_class_name("display-data");
// Add header <h3>
let h3 = document.create_element("h3")
.map_err(|_| AppError::internal("create h3"))?;
h3.set_class_name("display-data-header");
h3.set_inner_html(header);
section.append_child(&h3)
.map_err(|_| AppError::internal("append h3"))?;
// Add rows
for row in rows {
let row_el = document.create_element("div")
.map_err(|_| AppError::internal("create row"))?;
row_el.set_class_name("display-data-row");
// Title span
let title_el = document.create_element("span")
.map_err(|_| AppError::internal("create title"))?;
title_el.set_class_name("display-data-title");
title_el.set_inner_html(&row.title);
// Value span
let value_el = document.create_element("span")
.map_err(|_| AppError::internal("create value"))?;
value_el.set_class_name("display-data-value");
// If value is color, render swatch
if row.value.starts_with('#') {
let swatch = RGB::render(&row.value)?;
value_el.append_child(&swatch)
.map_err(|_| AppError::internal("append swatch"))?;
} else {
value_el.set_text_content(Some(&row.value));
}
row_el.append_child(&title_el)
.map_err(|_| AppError::internal("append title"))?;
row_el.append_child(&value_el)
.map_err(|_| AppError::internal("append value"))?;
section.append_child(&row_el)
.map_err(|_| AppError::internal("append row"))?;
}
Ok(section)
}HTML Output:
<section class="display-data">
<h3 class="display-data-header">User</h3>
<div class="display-data-row">
<span class="display-data-title">id</span>
<span class="display-data-value">12345</span>
</div>
<div class="display-data-row">
<span class="display-data-title">bg_color</span>
<span class="display-data-value">
<span class="rgb">
<i class="rgb__icon" style="background-color: #ffffff"></i>
#ffffff
</span>
</span>
</div>
</section>Purpose: Render color swatch with hex value.
Implementation:
use masterror::{AppError, AppResult};
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, window};
pub struct RGB;
impl RGB {
pub fn render(color: &str) -> AppResult<HtmlElement> {
let doc = window()
.ok_or_else(|| AppError::internal("no window"))?
.document()
.ok_or_else(|| AppError::internal("no document"))?;
// <span class="rgb">
let span = doc
.create_element("span")
.map_err(|_| AppError::internal("create span"))?
.dyn_into::<HtmlElement>()
.map_err(|_| AppError::internal("span into HtmlElement"))?;
span.set_class_name("rgb");
// <i class="rgb__icon" style="background-color: #RRGGBB"></i>
let icon = doc
.create_element("i")
.map_err(|_| AppError::internal("create icon"))?
.dyn_into::<HtmlElement>()
.map_err(|_| AppError::internal("icon into HtmlElement"))?;
icon.set_class_name("rgb__icon");
icon.style()
.set_property("background-color", color)
.map_err(|_| AppError::internal("set color"))?;
span.append_child(&icon)
.map_err(|_| AppError::internal("append icon"))?;
// Text node: "#RRGGBB"
let text = doc.create_text_node(color);
span.append_child(&text)
.map_err(|_| AppError::internal("append text"))?;
Ok(span)
}
}CSS Styling:
.rgb {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rgb__icon {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.1);
}Visual Result:
[🔵] #5288C1
(Circle with background color + hex value)
Purpose: Clickable navigation link with title and subtitle.
Implementation:
use wasm_bindgen::{JsCast, prelude::Closure};
use web_sys::{Document, Element, HtmlElement, window};
pub fn nav_link(label: &str, subtitle: Option<&str>, href: &str) -> Element {
let document = document();
let link = document.create_element("div").unwrap();
link.set_class_name("nav-link");
// Title
let title = document.create_element("div").unwrap();
title.set_class_name("label");
title.set_inner_html(label);
link.append_child(&title).unwrap();
// Subtitle (optional)
if let Some(sub) = subtitle {
let subtitle_el = document.create_element("div").unwrap();
subtitle_el.set_class_name("subtitle");
subtitle_el.set_inner_html(sub);
link.append_child(&subtitle_el).unwrap();
}
// Click handler
let link_closure = {
let href = href.to_string();
Closure::<dyn FnMut()>::new(move || {
if let Some(window) = window() {
let _ = window.location().set_href(&href);
}
})
};
let html_elem: HtmlElement = link.clone().dyn_into().unwrap();
html_elem.set_onclick(Some(link_closure.as_ref().unchecked_ref()));
link_closure.forget(); // Intentional leak
link
}
fn document() -> Document {
window().unwrap().document().unwrap()
}Why window.location.set_href?
- Changes browser URL
- Triggers router's popstate listener
- Navigates to new route without page reload
Purpose: Development controls for testing SDK features.
Button Definitions:
type Handler = fn(&TelegramWebApp) -> Result<(), JsValue>;
const BUTTON_IDS: &[(&str, Handler)] = &[
("send-data", |tg| tg.send_data("Hello from Dev Menu!")),
("expand", |tg| tg.expand()),
("close", |tg| tg.close()),
("alert", |tg| tg.show_alert("This is a test alert")),
("main-button", |tg| {
tg.set_bottom_button_text(BottomButton::Main, "Clicked!")?;
tg.show_bottom_button(BottomButton::Main)?;
Ok(())
}),
("is-expanded", |tg| {
let expanded = tg.is_expanded();
if expanded {
tg.show_alert("Already expanded")?;
} else {
tg.expand()?;
}
Ok(())
}),
("add-to-home-screen", |tg| {
if let Ok(shown) = tg.add_to_home_screen() {
info(&format!("shown = {}", shown));
}
Ok(())
}),
("check-home-screen", |tg| {
tg.check_home_screen_status(|status| {
info(&format!("status: {}", status));
})?;
Ok(())
})
];Setup Function:
pub fn setup_dev_menu() {
let doc = match window().and_then(|w| w.document()) {
Some(doc) => doc,
None => return // Not in browser
};
for (id, handler) in BUTTON_IDS {
if let Some(elem) = doc
.get_element_by_id(id)
.and_then(|e| e.dyn_into::<HtmlElement>().ok())
{
let handler = *handler;
let cb = Closure::<dyn FnMut()>::new(move || {
if let Some(tg) = TelegramWebApp::instance()
&& let Err(err) = handler(&tg)
{
web_sys::console::error_1(&err);
}
});
elem.set_onclick(Some(cb.as_ref().unchecked_ref()));
cb.forget();
}
}
}HTML in index.html:
<footer id="dev-menu">
<button id="send-data">Send Data</button>
<button id="expand">Expand</button>
<button id="close">Close</button>
<button id="alert">Show Alert</button>
<button id="main-button">Main Button</button>
<button id="is-expanded">Is Expanded?</button>
<button id="add-to-home-screen">Add to Home</button>
<button id="check-home-screen">Check Home</button>
</footer>Purpose: Receive data from WebApp and respond to users.
Project Files:
examples/bots/rust_bot/
├── src/
│ ├── main.rs # Bot logic with handlers
│ └── lib.rs # OrderData structure
├── Cargo.toml # Dependencies
├── .env.example # Environment template
└── README.md # Documentation
[package]
name = "webapp-bot-example"
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
[dependencies]
teloxide = { version = "0.17", features = ["macros"] } # Telegram Bot API
tokio = { version = "1", features = ["rt-multi-thread", "macros"] } # Async runtime
dotenvy = "0.15" # Load .env files
serde = { version = "1", features = ["derive"] } # Serialization
serde_json = "1" # JSON parsing
tracing = "0.1" # Logging
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
masterror = "0.24" # Error handlingTELOXIDE_TOKEN=110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw
WEBAPP_URL=https://your-domain.com/demo/index.html
RUST_LOG=infoSecurity Note: Never commit .env file to Git!
Purpose: Define data structure matching WebApp payload.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct OrderData {
pub id: u32,
pub name: String,
pub price_cents: u32
}Must match WebApp JSON:
{"id": 1, "name": "Whopper", "price_cents": 599}Comprehensive Tests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_order_data_deserialize() {
let json = r#"{"id": 1, "name": "Whopper", "price_cents": 599}"#;
let order: OrderData = serde_json::from_str(json).expect("valid json");
assert_eq!(order.id, 1);
assert_eq!(order.name, "Whopper");
assert_eq!(order.price_cents, 599);
}
#[test]
fn test_order_data_serialize() {
let order = OrderData {
id: 2,
name: "Big King".to_string(),
price_cents: 499
};
let json = serde_json::to_string(&order).expect("serialize");
assert!(json.contains("\"id\":2"));
}
#[test]
fn test_order_data_missing_field() {
let json = r#"{"id": 1, "name": "Whopper"}"#;
let result: Result<OrderData, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_order_price_calculation() {
let order = OrderData {
id: 1,
name: "Test".to_string(),
price_cents: 1234
};
let price_dollars = order.price_cents as f64 / 100.0;
assert_eq!(price_dollars, 12.34);
}
#[test]
fn test_order_data_roundtrip() {
let original = OrderData {
id: 42,
name: "Chicken Royale".to_string(),
price_cents: 750
};
let json = serde_json::to_string(&original).expect("serialize");
let deserialized: OrderData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, deserialized);
}
}Initialization:
#[tokio::main]
async fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Load .env file
dotenvy::dotenv().ok();
// Get WebApp URL from environment
let webapp_url = std::env::var("WEBAPP_URL")
.unwrap_or_else(|_| "https://example.com/index.html".to_string());
tracing::info!("Starting WebApp bot with URL: {}", webapp_url);
// Create bot from TELOXIDE_TOKEN env var
let bot = Bot::from_env();
// Build handler tree
let handler = Update::filter_message()
.branch(
dptree::entry()
.filter_command::<Command>()
.endpoint(handle_command)
)
.branch(
dptree::filter(|msg: Message| msg.web_app_data().is_some())
.endpoint(handle_webapp_data)
);
// Start dispatcher
Dispatcher::builder(bot, handler)
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
}Command Definitions:
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase")]
enum Command {
#[command(description = "Display welcome message")]
Start,
#[command(description = "Show help information")]
Help
}Command Handler:
async fn handle_command(bot: Bot, msg: Message, cmd: Command) -> Result<(), AppError> {
let webapp_url = std::env::var("WEBAPP_URL")
.unwrap_or_else(|_| "https://example.com/index.html".to_string());
match cmd {
Command::Start => {
// Create inline keyboard with WebApp buttons
let keyboard = InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::web_app(
"Open Burger King Menu",
WebAppInfo {
url: format!("{}#/burger-king", webapp_url)
.parse()
.map_err(|e| {
AppError::new(AppErrorKind::Internal, "Invalid URL")
.with_context(e)
})?
}
)],
vec![InlineKeyboardButton::web_app(
"View Init Data",
WebAppInfo {
url: format!("{}#/init-data", webapp_url).parse()
.map_err(|e| {
AppError::new(AppErrorKind::Internal, "Invalid URL")
.with_context(e)
})?
}
)],
vec![InlineKeyboardButton::web_app(
"Theme Parameters",
WebAppInfo {
url: format!("{}#/theme-params", webapp_url).parse()
.map_err(|e| {
AppError::new(AppErrorKind::Internal, "Invalid URL")
.with_context(e)
})?
}
)],
]);
bot.send_message(
msg.chat.id,
"Welcome to Telegram WebApp SDK Demo!\n\nClick a button to open the WebApp:"
)
.reply_markup(keyboard)
.await
.map_err(|e| {
AppError::new(AppErrorKind::Service, "Failed to send message")
.with_context(e)
})?;
}
Command::Help => {
bot.send_message(
msg.chat.id,
"This bot demonstrates telegram-webapp-sdk.\n\n\
Commands:\n\
/start - Open WebApp menu\n\
/help - Show this message\n\n\
GitHub: https://github.com/RAprogramm/telegram-webapp-sdk"
)
.await
.map_err(|e| {
AppError::new(AppErrorKind::Service, "Failed to send message")
.with_context(e)
})?;
}
}
Ok(())
}WebApp Data Handler:
async fn handle_webapp_data(bot: Bot, msg: Message) -> Result<(), AppError> {
if let Some(web_app_data) = msg.web_app_data() {
// Parse JSON from WebApp
let order: OrderData = serde_json::from_str(&web_app_data.data)
.map_err(|e| {
AppError::new(AppErrorKind::BadRequest, "Invalid order data format")
.with_context(e)
})?;
let price_dollars = order.price_cents as f64 / 100.0;
// Format response message
let response = format!(
"Order Received!\n\n\
Item: {}\n\
Price: ${:.2}\n\
Order ID: #{}\n\n\
Your order is being processed...",
order.name, price_dollars, order.id
);
// Send confirmation
bot.send_message(msg.chat.id, response)
.await
.map_err(|e| {
AppError::new(AppErrorKind::Service, "Failed to send message")
.with_context(e)
})?;
// Log to server
tracing::info!(
"Order from user {}: {} (${:.2})",
msg.from.as_ref().map(|u| u.id.0).unwrap_or(0),
order.name,
price_dollars
);
}
Ok(())
}┌─────────────────────────────────────────────────────────────┐
│ 1. User Opens Bot │
│ User sends /start → Bot receives command │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. Bot Sends WebApp Button │
│ handle_command() → InlineKeyboardButton::web_app() │
│ URL: https://domain.com/demo/index.html#/burger-king │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. User Clicks Button │
│ Telegram opens WebApp in in-app browser │
│ Loads WASM bundle │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. WebApp Initializes │
│ telegram_app! macro expands │
│ Parses tgWebAppData from URL │
│ Stores context in thread-local │
│ Calls main() │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. Router Starts │
│ telegram_router!() collects all pages │
│ Builds routing table │
│ Reads current path: /burger-king │
│ Calls render_burger_king_page() │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 6. Page Renders │
│ Creates PageLayout │
│ Renders 3 menu items │
│ Each item has "Order" button │
│ User sees menu │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 7. User Selects Item │
│ User clicks "Order" on "Whopper" │
│ JavaScript onclick fires │
│ Rust Closure executes │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 8. WebApp Sends Data │
│ TelegramWebApp::instance() │
│ app.send_data('{"id":1,"name":"Whopper","price_cents":599}')│
│ Calls Telegram.WebApp.sendData() │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 9. Telegram Delivers to Bot │
│ Telegram sends web_app_data message to bot │
│ Message includes JSON payload │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 10. Bot Processes Order │
│ handle_webapp_data() receives message │
│ Parses JSON: OrderData { id: 1, name: "Whopper", ... } │
│ Formats confirmation message │
│ Sends message to user │
│ Logs order to server │
└────────────────────────────┬────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 11. User Receives Confirmation │
│ "Order Received! Item: Whopper - $5.99" │
│ User sees confirmation in chat │
└─────────────────────────────────────────────────────────────┘
WebApp Data Message:
{
"update_id": 123456789,
"message": {
"message_id": 12345,
"from": {
"id": 123456789,
"is_bot": false,
"first_name": "John",
"username": "johndoe"
},
"chat": {
"id": 123456789,
"first_name": "John",
"username": "johndoe",
"type": "private"
},
"date": 1699564800,
"web_app_data": {
"button_text": "Open Burger King Menu",
"data": "{\"id\":1,\"name\":\"Whopper\",\"price_cents\":599}"
}
}
}Key Field: web_app_data.data
- Contains JSON string sent by
sendData() - Bot parses this string to get order details
Complete step-by-step guide to running the demo locally with maximum detail.
Operating Systems:
- Linux (Ubuntu 20.04+, Debian 11+, Arch, Fedora)
- macOS (10.15+)
- Windows 10/11 with WSL2 (recommended) or native
Minimum Hardware:
- CPU: 2 cores
- RAM: 4 GB
- Disk: 2 GB free space
- Internet connection
What is Rust? Rust is the programming language used to build the demo. You need Rust 1.90.0 or later (MSRV for SDK 0.3.0).
Installation Steps:
Linux/macOS:
# Download and run rustup installer
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shInteractive prompts:
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
> 1
After installation completes:
# Add Rust to your PATH (current session)
source "$HOME/.cargo/env"
# Verify installation
rustc --versionExpected output:
rustc 1.90.0 (cccfff8cf 2025-10-12)
If version is less than 1.90.0, update:
rustup update stableWindows (native):
Download installer from: https://rustup.rs/
Run rustup-init.exe and follow prompts.
Windows (WSL2 - Recommended):
# Inside WSL2 Ubuntu terminal
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"Reference: Official Rust Installation Guide
What is wasm32-unknown-unknown? This is the compilation target for browser-based WebAssembly. Required for all Rust WASM applications.
Installation:
rustup target add wasm32-unknown-unknownExpected output:
info: downloading component 'rust-std' for 'wasm32-unknown-unknown'
info: installing component 'rust-std' for 'wasm32-unknown-unknown'
Verify installation:
rustup target list --installed | grep wasm32Expected output:
wasm32-unknown-unknown
What is Trunk? Trunk is the official recommended bundler for Yew and Rust WASM applications. It:
- Compiles Rust to WASM
- Bundles JavaScript and CSS
- Provides hot-reload development server
- Optimizes for production
Installation:
cargo install --locked trunkWhy --locked?
Uses exact dependency versions from Cargo.lock for reproducible builds.
Installation time: 5-10 minutes (compiles from source)
Output during installation:
Updating crates.io index
Downloaded trunk v0.21.9
Downloaded 1 crate (120.5 KB) in 0.52s
Compiling proc-macro2 v1.0.94
Compiling unicode-ident v1.0.14
...
Compiling trunk v0.21.9
Finished release [optimized] target(s) in 8m 32s
Installing ~/.cargo/bin/trunk
Installed package `trunk v0.21.9` (executable `trunk`)
Verify installation:
trunk --versionExpected output:
trunk 0.21.9
Reference: Trunk Official Website
Telegram requires HTTPS for WebApps. For local development, use a tunneling tool.
Option A: cloudflared (Recommended)
What is cloudflared? Free tunneling tool by Cloudflare. Creates public HTTPS URL pointing to your localhost.
Advantages:
- No account required for basic use
- Fast and reliable
- Free forever
- No request limits
Installation:
macOS:
brew install cloudflare/cloudflare/cloudflaredLinux (Debian/Ubuntu):
# Download latest release
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
# Install
sudo dpkg -i cloudflared-linux-amd64.deb
# Verify
cloudflared --versionLinux (Arch):
sudo pacman -S cloudflaredLinux (other distributions):
# Download binary
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
# Make executable
chmod +x cloudflared-linux-amd64
# Move to PATH
sudo mv cloudflared-linux-amd64 /usr/local/bin/cloudflared
# Verify
cloudflared --versionWindows: Download from: https://github.com/cloudflare/cloudflared/releases
Extract and add to PATH.
Reference: cloudflared Installation Guide
Option B: ngrok
What is ngrok? Popular tunneling service with free and paid tiers.
Advantages:
- Web interface to inspect requests
- Custom domains (paid)
- Request replay (paid)
Disadvantages:
- Requires account (free available)
- URL changes on each restart (free tier)
Installation:
macOS:
brew install ngrok/ngrok/ngrokLinux:
# Download and install
curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null \
&& echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
| sudo tee /etc/apt/sources.list.d/ngrok.list \
&& sudo apt update \
&& sudo apt install ngrokOr download binary:
# Download
wget https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz
# Extract
tar xvzf ngrok-v3-stable-linux-amd64.tgz
# Move to PATH
sudo mv ngrok /usr/local/bin/
# Verify
ngrok versionWindows: Download from: https://ngrok.com/download
Setup authtoken (required):
# Sign up at https://dashboard.ngrok.com/signup
# Get token from https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken YOUR_AUTHTOKENReference: ngrok Installation Guide
What is @BotFather? Official Telegram bot for creating and managing bots.
Step-by-Step:
-
Open Telegram on any device
-
Search for @BotFather or open: https://t.me/BotFather
-
Send
/newbotcommandYou: /newbot BotFather: Alright, a new bot. How are we going to call it? Please choose a name for your bot. -
Enter bot name (display name, can be anything)
You: My WebApp Demo Bot BotFather: Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot. -
Enter bot username (must be unique and end with "bot")
You: my_webapp_demo_bot BotFather: Done! Congratulations on your new bot. You will find it at t.me/my_webapp_demo_bot. Use this token to access the HTTP API: 110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw Keep your token secure and store it safely... -
Save your bot token - Copy and store securely
CRITICAL: Never commit this token to Git or share publicly!
Reference: Telegram Bot API Documentation
What are we cloning? The telegram-webapp-sdk repository containing the demo and SDK source code.
Clone with Git:
# Navigate to your projects directory
cd ~/Projects # or wherever you keep projects
# Clone repository
git clone https://github.com/RAprogramm/telegram-webapp-sdk.git
# Navigate to demo directory
cd telegram-webapp-sdk/demoExpected output:
Cloning into 'telegram-webapp-sdk'...
remote: Enumerating objects: 1234, done.
remote: Counting objects: 100% (1234/1234), done.
remote: Compressing objects: 100% (567/567), done.
remote: Total 1234 (delta 789), reused 1100 (delta 670)
Receiving objects: 100% (1234/1234), 3.45 MiB | 2.30 MiB/s, done.
Resolving deltas: 100% (789/789), done.
Verify directory structure:
ls -laExpected output:
total 28
drwxr-xr-x 4 user user 4096 Nov 4 10:00 .
drwxr-xr-x 16 user user 4096 Nov 4 10:00 ..
-rw-r--r-- 1 user user 560 Nov 4 10:00 Cargo.toml
drwxr-xr-x 2 user user 4096 Nov 4 10:00 dist
-rw-r--r-- 1 user user 1056 Nov 4 10:00 index.html
-rw-r--r-- 1 user user 6436 Nov 4 10:00 README.md
drwxr-xr-x 4 user user 4096 Nov 4 10:00 src
-rw-r--r-- 1 user user 2561 Nov 4 10:00 style.css
-rw-r--r-- 1 user user 30 Nov 4 10:00 Trunk.toml
What does trunk serve do?
- Compiles Rust to WASM
- Starts development server on http://localhost:8080
- Watches for file changes
- Auto-reloads browser on changes
Start development server:
trunk serveFirst run takes longer (5-15 minutes):
- Downloads all dependencies
- Compiles entire project
- Subsequent runs are much faster (<30 seconds)
Expected output (detailed):
2025-11-04T10:00:00.000Z INFO 📦 starting build
Trunk starts building process
2025-11-04T10:00:01.000Z INFO spawning asset pipelines
Processing index.html, style.css
2025-11-04T10:00:02.000Z INFO building telegram-webapp-sdk-demo
Compiling Rust to WASM
Updating crates.io index
Downloading dependency metadata
Downloading crates ...
Downloaded wasm-bindgen v0.2.104
Downloaded web-sys v0.3.81
Downloaded telegram-webapp-sdk v0.3.0
...
Downloading ~50-100 crates
Compiling proc-macro2 v1.0.94
Compiling unicode-ident v1.0.14
Compiling syn v2.0.123
...
Compiling dependencies (takes longest)
Compiling demo v0.2.6 (/path/to/demo)
Compiling your demo code
Finished `dev` profile [unoptimized + debuginfo] target(s) in 8m 32s
Compilation complete
2025-11-04T10:08:30.000Z INFO ✅ success
Build successful
2025-11-04T10:08:30.000Z INFO 📡 serving on http://127.0.0.1:8080
Server running - demo is ready!
What if compilation fails?
Common error: Missing wasm-bindgen-cli
error: failed to run `wasm-bindgen`
Solution:
cargo install wasm-bindgen-cli --version 0.2.104Match version to what Cargo.lock specifies.
Server is now running!
Open browser to http://localhost:8080 to see demo.
To stop server: Press Ctrl+C
Why HTTPS? Telegram Web Apps MUST be served over HTTPS for security. Localhost HTTP won't work in Telegram.
Open second terminal window/tab (keep trunk serve running in first)
Option A: Using cloudflared
cloudflared tunnel --url http://localhost:8080Expected output:
2025-11-04T10:10:00Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2025-11-04T10:10:01Z INF Requesting new quick Tunnel on trycloudflare.com...
2025-11-04T10:10:02Z INF +--------------------------------------------------------------------------------------------+
2025-11-04T10:10:02Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2025-11-04T10:10:02Z INF | https://random-words-1234.trycloudflare.com |
2025-11-04T10:10:02Z INF +--------------------------------------------------------------------------------------------+
2025-11-04T10:10:03Z INF Connection registered connIndex=0 connection=abc123... location=SJC
Your tunnel URL: https://random-words-1234.trycloudflare.com
Copy this URL - you'll need it for BotFather configuration.
Test tunnel is working:
Open in browser: https://random-words-1234.trycloudflare.com/index.html
You should see the demo app.
Keep this terminal open! Tunnel stays active while running.
Option B: Using ngrok
ngrok http 8080Expected output:
ngrok (Ctrl+C to quit)
Build better APIs with ngrok. Early access: ngrok.com/early-access
Session Status online
Account YourName (Plan: Free)
Version 3.18.4
Region United States (us)
Latency -
Web Interface http://127.0.0.1:4040
Forwarding https://abc123.ngrok-free.app -> http://localhost:8080
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Your tunnel URL: https://abc123.ngrok-free.app
Web Interface: Open http://127.0.0.1:4040 to see requests in real-time
Test tunnel:
Open in browser: https://abc123.ngrok-free.app/index.html
Keep this terminal open!
Important Notes:
-
URL changes on restart - When you stop cloudflared/ngrok and restart, you get a new random URL
-
Update BotFather every time - Each new URL must be configured in BotFather
-
For persistent URLs:
- cloudflared: Create named tunnel (requires account)
- ngrok: Upgrade to paid plan for custom domains
-
Security: These URLs are public - anyone with URL can access your local server
Now we connect the WebApp URL to your bot.
Step-by-Step:
-
Open @BotFather in Telegram
-
Send command:
/mybotsBotFather: Choose a bot from the list below: [My WebApp Demo Bot] -
Click your bot name or send its name
-
BotFather shows menu:
[Edit Bot] [Bot Settings] [Payments] [Transfer Ownership] [Delete Bot] -
Click or send:
Bot Settings -
Settings menu appears:
[Edit Name] [Edit Description] [Edit About] [Edit Botpic] [Edit Commands] [Delete Bot] [Menu Button] -
Click or send:
Menu Button -
Menu Button options:
Current menu button: default [Edit menu button URL] [Edit menu button text] [Reset menu button] -
Click or send:
Edit menu button URL -
BotFather asks for URL:
BotFather: Send me the URL of the Web App. -
Send your tunnel URL:
You: https://random-words-1234.trycloudflare.com/index.htmlCRITICAL: Include
/index.htmlat the end! -
BotFather confirms:
BotFather: Success! The menu button URL was updated.
Configuration complete!
Final step: Test everything works!
-
Open your bot in Telegram
- Search for your bot username
- Or open t.me/your_bot_username
-
Look at the bottom of the chat
- You should see a button with hamburger icon (≡)
- This is the menu button
-
Click the menu button
- WebApp opens in Telegram's in-app browser
- You should see: "Telegram WebApp SDK Demo"
-
Explore the demo:
- Click "Burger King Demo" - see menu items
- Click "Init Data" - see your user information
- Click "Launch Parameters" - see platform info
- Click "Theme Parameters" - see color scheme
If WebApp doesn't load:
- Check tunnel is still running
- Verify URL in BotFather includes
/index.html - Check
trunk serveis still running - Try opening tunnel URL directly in browser first
Success! WebApp is working in Telegram!
But buttons don't send data yet - we need the bot backend...
Now we set up the backend to receive data from WebApp.
# Open new terminal (keep trunk serve and tunnel running)
cd ~/Projects/telegram-webapp-sdk/examples/bots/rust_botVerify directory contents:
ls -laExpected output:
total 20
drwxr-xr-x 3 user user 4096 Nov 4 10:00 .
drwxr-xr-x 3 user user 4096 Nov 4 10:00 ..
-rw-r--r-- 1 user user 123 Nov 4 10:00 .env.example
-rw-r--r-- 1 user user 560 Nov 4 10:00 Cargo.toml
-rw-r--r-- 1 user user 3456 Nov 4 10:00 README.md
drwxr-xr-x 2 user user 4096 Nov 4 10:00 src
What is .env file? Configuration file storing sensitive information like bot tokens. Never commit to Git!
Create from template:
cp .env.example .envEdit with your favorite editor:
# Choose one:
nano .env # Simple terminal editor
vim .env # Vim
code .env # VS Code
gedit .env # GNOME Text Editor
kate .env # KDE KateEdit .env file content:
# Telegram Bot Token from @BotFather
# Replace with YOUR actual token!
TELOXIDE_TOKEN=110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw
# WebApp URL from cloudflared/ngrok tunnel
# Replace with YOUR actual tunnel URL!
# IMPORTANT: Include /index.html at the end!
WEBAPP_URL=https://random-words-1234.trycloudflare.com/index.html
# Logging level: trace, debug, info, warn, error
RUST_LOG=infoReplace these values:
-
TELOXIDE_TOKEN
- Get from @BotFather (when you created bot)
- Format:
NUMBER:ALPHANUMERIC_STRING - Example:
110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw
-
WEBAPP_URL
- Your cloudflared/ngrok tunnel URL
- Must include
/index.html - Example:
https://random-words-1234.trycloudflare.com/index.html
-
RUST_LOG (optional)
-
info- Normal logging (recommended) -
debug- Detailed logging -
trace- Very detailed logging -
warn- Only warnings and errors -
error- Only errors
-
Save file and exit editor.
Verify .env file:
cat .envShould show your configuration.
Security check:
# Ensure .env is in .gitignore
grep "\.env" .gitignoreShould output .env - this prevents accidental commits.
What happens during first run?
- Downloads all dependencies (~100 crates)
- Compiles bot and dependencies
- Connects to Telegram servers
- Starts listening for messages
Run bot:
cargo run --releaseWhy --release?
- Optimized build (faster runtime)
- Smaller binary size
- Longer compile time (worth it)
Expected output (detailed):
Compiling libc v0.2.168
Compiling cfg-if v1.0.0
Compiling proc-macro2 v1.0.94
...
Compiling dependencies (takes 5-10 minutes first time)
Compiling webapp-bot-example v0.1.0 (/path/to/rust_bot)
Compiling bot code
Finished `release` profile [optimized] target(s) in 7m 45s
Running `target/release/webapp-bot-example`
Compilation complete, bot starting
2025-11-04T10:15:00.000Z INFO webapp_bot_example: Starting WebApp bot with URL: https://random-words-1234.trycloudflare.com/index.html
Bot initialized with your WebApp URL
2025-11-04T10:15:01.000Z INFO teloxide::dispatching: Listening for updates
Bot is running! Listening for Telegram messages.
Bot is now online and ready!
Now test the full flow: WebApp → Bot
-
Open your bot in Telegram
-
Send command:
/start -
Bot responds with buttons:
Welcome to Telegram WebApp SDK Demo! Click a button to open the WebApp: [Open Burger King Menu] [View Init Data] [Theme Parameters] -
Click:
Open Burger King Menu -
WebApp opens showing menu:
Burger King Menu Whopper - $5.99 [Order] Cheeseburger - $2.99 [Order] Chicken Nuggets - $3.99 [Order] -
Click "Order" on any item (e.g., Whopper)
-
Bot immediately responds in chat:
Order Received! Item: Whopper Price: $5.99 Order ID: #1 Your order is being processed... -
Check bot terminal output:
2025-11-04T10:16:00.000Z INFO webapp_bot_example: Order from user 123456789: Whopper ($5.99)
Success! Complete integration working!
Data flow:
User clicks Order
↓
WebApp sends JSON
↓
Telegram delivers to bot
↓
Bot parses order
↓
Bot sends confirmation
↓
User sees message
Now you have everything running. Here's the recommended workflow:
Terminal 1: WebApp Development Server
cd ~/Projects/telegram-webapp-sdk/demo
trunk serve- Compiles Rust to WASM
- Auto-reloads on code changes
- Keep running always
Terminal 2: HTTPS Tunnel
cloudflared tunnel --url http://localhost:8080- Exposes localhost to internet
- Provides HTTPS URL for Telegram
- Keep running always
Terminal 3: Bot Backend
cd ~/Projects/telegram-webapp-sdk/examples/bots/rust_bot
cargo run --release- Receives WebApp data
- Processes orders
- Keep running always
Edit demo code:
# Open demo source file
code ~/Projects/telegram-webapp-sdk/demo/src/pages/burger_king.rsMake a change:
// Change price
MenuItem {
id: 1,
name: "Whopper",
price_cents: 699 // Changed from 599
}Save file
Trunk automatically:
- Detects file change
- Recompiles WASM
- Reloads browser
Check terminal 1:
2025-11-04T10:20:00.000Z INFO 📦 starting build
2025-11-04T10:20:15.000Z INFO ✅ success
2025-11-04T10:20:15.000Z INFO 📡 serving on http://127.0.0.1:8080
Test change:
- Refresh WebApp in Telegram
- Price should now show $6.99
Edit bot code:
code ~/Projects/telegram-webapp-sdk/examples/bots/rust_bot/src/main.rsMake a change:
// Change response message
let response = format!(
"🎉 Order Confirmed!\n\n\ // Changed emoji
Item: {}\n\
Price: ${:.2}\n\
Order ID: #{}\n\n\
Your order will arrive in 30 minutes!", // Added delivery time
order.name, price_dollars, order.id
);Save file
Restart bot:
- Press
Ctrl+Cin terminal 3 to stop bot - Run
cargo run --releaseto restart
Faster restarts with cargo watch:
# Install cargo-watch
cargo install cargo-watch
# Run bot with auto-restart
cargo watch -x 'run --release'Now bot restarts automatically on code changes!
View WebApp Console:
Since Telegram's in-app browser doesn't show console, use these methods:
Method 1: Desktop Telegram with DevTools
- Use Telegram Desktop (not mobile)
- Right-click in WebApp
- Select "Inspect Element"
- Console tab shows logs
Method 2: Test in Regular Browser First
# Open in browser
open http://localhost:8080
# Or
firefox http://localhost:8080Check browser console (F12) for errors.
Method 3: Use telegram-webapp-sdk Logger
use telegram_webapp_sdk::logger;
logger::info("Button clicked");
logger::error(&format!("Error: {:?}", err));Logs appear in browser console.
View Bot Logs:
Bot logs appear in terminal 3:
2025-11-04T10:16:00.000Z INFO webapp_bot_example: Starting...
2025-11-04T10:16:01.000Z INFO teloxide::dispatching: Listening for updates
2025-11-04T10:16:30.000Z INFO webapp_bot_example: Order from user 123456789: Whopper ($5.99)
Increase log detail:
Edit .env:
RUST_LOG=debug # More detailed
RUST_LOG=trace # Very detailedRestart bot to apply.
Update dependencies:
# In demo directory
cd ~/Projects/telegram-webapp-sdk/demo
cargo update
# In bot directory
cd ~/Projects/telegram-webapp-sdk/examples/bots/rust_bot
cargo updateClean build (if issues):
# Remove build artifacts
cargo clean
# Rebuild from scratch
trunk build --releaseCheck for errors without running:
cargo checkFaster than full build, finds compilation errors.
When done developing:
Stop WebApp Server (Terminal 1):
Press: Ctrl+C
Stop Tunnel (Terminal 2):
Press: Ctrl+C
Stop Bot (Terminal 3):
Press: Ctrl+C
Restart later:
Just run the same commands again:
# Terminal 1
trunk serve
# Terminal 2
cloudflared tunnel --url http://localhost:8080
# Terminal 3
cargo run --releaseNote: Tunnel URL changes each time, update BotFather if needed.
1. Build Optimized WASM
cd demo
trunk build --release --public-url "https://your-domain.com/demo/"2. Deploy dist/ Contents
ls -lh dist/
# index.html
# demo-*.wasm
# demo-*.js
# style-*.css
# Upload to web server
rsync -av dist/ user@server:/var/www/html/demo/3. Update Bot Environment
# Update WEBAPP_URL in .env
WEBAPP_URL=https://your-domain.com/demo/index.html
# Restart bot
cargo run --release4. Update BotFather
Set menu button URL to: https://your-domain.com/demo/index.html
Enable Mock Feature:
cd demo
# Mock is already enabled in Cargo.tomlAdd Mock Initialization:
// In src/main.rs
use telegram_webapp_sdk::mock::{init_mock, MockConfig, MockUser};
telegram_app!(
pub fn main() -> Result<(), JsValue> {
// Initialize mock environment
#[cfg(feature = "mock")]
init_mock(MockConfig {
user: Some(MockUser {
id: 12345,
first_name: "Test User".into(),
last_name: Some("Developer".into()),
username: Some("testdev".into()),
language_code: Some("en".into()),
is_premium: Some(true),
..Default::default()
}),
platform: "web",
version: "7.0",
..Default::default()
});
setup_dev_menu();
telegram_router!();
Ok(())
}
);Test Locally:
trunk serve --openNow you can test without Telegram or tunneling!
Cause: Telegram WebApp SDK script not loaded.
Solution:
Ensure index.html has:
<head>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- Other scripts below -->
</head>Error:
it looks like the Rust project used to create this wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:
rust Wasm file schema version: 0.2.95
this binary schema version: 0.2.104
Solution:
# Find version in Cargo.lock
grep -A1 'name = "wasm-bindgen"' Cargo.lock | grep version
# Install matching CLI
cargo install wasm-bindgen-cli --version 0.2.95 --force
# Or update dependencies
cargo update
trunk build --releasePossible Causes:
-
Not HTTPS - Telegram requires secure connection
- Solution: Use cloudflared/ngrok or deploy to HTTPS server
-
Wrong URL in BotFather - URL doesn't match deployment
- Solution: Update menu button URL in BotFather
-
CORS Issues - Server blocking Telegram's requests
- Solution: Add CORS headers to server:
Access-Control-Allow-Origin: https://web.telegram.org
- Solution: Add CORS headers to server:
-
Build Path Mismatch -
--public-urldoesn't match server path- Solution: Rebuild with correct URL:
trunk build --release --public-url "https://domain.com/correct/path/"
- Solution: Rebuild with correct URL:
Possible Causes:
-
Bot Not Running - Server stopped
- Solution: Check bot process and restart
-
Wrong Token - Invalid TELOXIDE_TOKEN
- Solution: Verify token from @BotFather
-
Parsing Error - JSON format mismatch
- Solution: Check OrderData structure matches WebApp payload
- Add logging to see actual JSON:
tracing::info!("Received data: {}", web_app_data.data);
-
Handler Not Registered - Update dispatcher missing web_app_data filter
- Solution: Ensure dispatcher has:
.branch( dptree::filter(|msg: Message| msg.web_app_data().is_some()) .endpoint(handle_webapp_data) )
- Solution: Ensure dispatcher has:
Cause: Buttons not connected to handlers.
Solution:
Ensure setup_dev_menu() is called in main:
telegram_app!(
pub fn main() -> Result<(), JsValue> {
setup_dev_menu(); // Must be before router
telegram_router!();
Ok(())
}
);Optimization Steps:
-
Enable Release Optimizations in
Cargo.toml:[profile.release] opt-level = "z" lto = true codegen-units = 1 strip = true panic = "abort"
-
Install wasm-opt:
# macOS brew install binaryen # Ubuntu/Debian sudo apt-get install binaryen
-
Use wasm-opt in Build:
trunk build --release wasm-opt -Oz -o dist/demo_bg.wasm dist/demo_bg.wasm
-
Remove Unused Dependencies:
cargo install cargo-bloat cargo bloat --release --target wasm32-unknown-unknown
Cause: Free tunneling services generate random URLs.
Solutions:
- cloudflared: Use named tunnel (requires account)
- ngrok: Use authtoken for persistent URLs
- Production: Deploy to permanent HTTPS domain
Best Practice for Development:
Create shell script to update BotFather automatically:
#!/bin/bash
# update_url.sh
TUNNEL_URL=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url')
echo "Tunnel URL: $TUNNEL_URL"
echo "Update this in BotFather manually"
echo "Or use Telegram Bot API to set menu button"This chapter provided a complete deep dive into the telegram-webapp-sdk demo and examples:
- Architecture: Understood frontend WebApp and backend Bot separation
- Demo Structure: Explored complete project layout and file organization
-
Entry Point: Learned how
telegram_app!initializes the SDK - Routing: Understood client-side routing without page reloads
- Pages: Analyzed each demo page in detail with code explanations
- Components: Studied reusable components and their implementations
- Bot Backend: Configured teloxide bot to receive WebApp data
- Data Flow: Traced complete order flow from click to confirmation
- Testing: Set up local development with tunneling
- Troubleshooting: Solved common issues with detailed solutions
Key Takeaways:
- WebApp is frontend-only, bot backend is required for data processing
-
telegram_app!macro handles SDK initialization automatically -
telegram_page!macro registers routes with inventory system -
send_data()sends JSON to bot, which parses it on backend - Mock feature enables local testing without Telegram
- HTTPS is required for production deployment
- teloxide provides clean async API for bot development
Next Steps:
Apply these concepts to build your own Telegram Mini App:
- Clone demo as starting point
- Modify pages for your use case
- Define custom data structures
- Implement bot handlers
- Deploy to production
- Telegram WebApp API Documentation
- telegram-webapp-sdk Repository
- teloxide Documentation
- Trunk Documentation
- Rust WASM Book