Appearance
JavaScript SDK
This page is shorter than you want it to be, and the reason is the point of it.
Not implemented
Mini Apps do not run yet — there is no client runtime and no SDK to include. See implementation status.
There is no script to include
Not "not yet published" — not specified. The TZ describes the Mini App bridge in terms of events, and never names the JavaScript that would wrap them. Undecided, concretely:
| Question | State |
|---|---|
| Script URL to include | Not specified |
| Global namespace | Partly specified — see below |
| npm package | Not mentioned |
Version negotiation (isVersionAtLeast or equivalent) | Not mentioned |
| Outbound transport from page to client | Not specified |
Any snippet on the internet that shows a GROM Mini App SDK being loaded from a URL is describing something that does not exist. Treat this page as a description of the contract an SDK would sit on top of, not of a library.
What is specified
One direction of the bridge, precisely. The client delivers events into the page by calling:
js
window.Telegram.WebView.receiveEvent(name, params)The Telegram namespace is written that way in the specification, presumably for compatibility with Mini Apps ported from Telegram. Whether a Grom alias will exist beside it is not stated. Both possibilities are live, so do not build a hard dependency on either spelling.
The outbound half — how your page sends web_app_* events back — is not written down. The TZ says the container is a WebView on mobile (flutter_inappwebview) and a sandboxed iframe on web, which implies a native message handler in the first case and postMessage in the second, but implication is not specification. This is the single largest gap in the Mini App section, because it is the half every SDK function goes through.
Writing against it anyway
The event names in both directions are stable enough to build on — they are enumerated in the TZ and reproduced on the events page. The transport is not. So put a boundary between them:
js
// bridge.js — one file to rewrite when the transport is specified.
const handlers = new Map()
// Inbound: the one half the TZ actually fixes.
window.Telegram = window.Telegram || {}
window.Telegram.WebView = window.Telegram.WebView || {}
window.Telegram.WebView.receiveEvent = (name, params) => {
for (const fn of handlers.get(name) || []) {
try {
fn(params)
} catch (err) {
console.error(`handler for ${name} threw`, err)
}
}
}
export function on(name, fn) {
handlers.set(name, [...(handlers.get(name) || []), fn])
return () => off(name, fn)
}
export function off(name, fn) {
handlers.set(name, (handlers.get(name) || []).filter((f) => f !== fn))
}
// Outbound: UNSPECIFIED. This is a placeholder, not a working call.
export function send(name, params) {
throw new Error(`outbound transport not specified: ${name}`)
}Everything else in your app calls on() and send(). When the transport lands, one file changes.
One handler must not break the others — hence the try/catch in the dispatch loop. A bridge that lets a single listener's exception kill the rest produces failures that look like the platform dropping events.
The surface an SDK would expose
The tables below map the Telegram Web Apps object model onto the events GROM specifies. GROM tracks that model closely, so this is the likely shape — but only the middle column is sourced from the TZ. Nothing here is a GROM API you can call.
State
| Member | Backed by | In the TZ |
|---|---|---|
initData | Launch fragment — verify it server-side | Yes |
initDataUnsafe | Same, parsed client-side | Concept yes, name no |
themeParams | theme_changed | Yes |
colorScheme | Derived from theme_params | Implied |
viewportHeight, viewportStableHeight, isExpanded | viewport_changed | Yes |
safeAreaInset, contentSafeAreaInset | safe_area_changed, content_safe_area_changed | Yes |
isFullscreen | fullscreen_changed | Yes |
version, platform | Version negotiation | No |
initDataUnsafe means what it says
Telegram's name for the client-parsed launch context is a warning label, and it should be read as one. It is fine for rendering a name before your backend answers. It is not an identity, and nothing may be authorized on it.
Authorization uses the server-verified result only. Validating initData explains what an attacker does with an app that gets this wrong: impersonate any user, by editing a URL.
Lifecycle and layout
| Member | Sends | Answered by |
|---|---|---|
ready() | web_app_ready | — |
expand() | web_app_expand | viewport_changed |
close() | web_app_close | — |
requestFullscreen() / exitFullscreen() | web_app_request_fullscreen / web_app_exit_fullscreen | fullscreen_changed, fullscreen_failed |
lockOrientation() | web_app_toggle_orientation_lock | — |
enableClosingConfirmation() | web_app_setup_closing_behavior | — |
setHeaderColor(), setBackgroundColor(), setBottomBarColor() | web_app_set_header_color, web_app_set_background_color, web_app_set_bottom_bar_color | — |
Controls
| Member | Sends | Answered by |
|---|---|---|
MainButton | web_app_setup_main_button | main_button_pressed |
SecondaryButton | web_app_setup_secondary_button | secondary_button_pressed |
BackButton | web_app_setup_back_button | back_button_pressed |
SettingsButton | web_app_setup_settings_button | settings_button_pressed |
HapticFeedback | web_app_trigger_haptic_feedback | — |
Dialogs and links
| Member | Sends | Answered by |
|---|---|---|
showPopup(), showAlert(), showConfirm() | web_app_open_popup | popup_closed |
openLink() | web_app_open_link | — |
openTelegramLink() | web_app_open_tg_link | — |
openInvoice() | web_app_open_invoice | invoice_closed |
showScanQrPopup() / closeScanQrPopup() | web_app_open_scan_qr_popup / web_app_close_scan_qr_popup | qr_text_received, scan_qr_popup_closed |
readTextFromClipboard() | web_app_read_text_from_clipboard | clipboard_text_received |
The tg in web_app_open_tg_link is in the TZ verbatim. Whether GROM keeps that spelling or renames it is not stated — another reason to route event names through one module rather than scattering string literals.
Permissions and device
| Member | Sends | Answered by |
|---|---|---|
requestWriteAccess() | web_app_request_write_access | write_access_requested |
requestContact() | web_app_request_phone | phone_requested |
setEmojiStatus() | web_app_set_emoji_status | emoji_status_set, emoji_status_failed |
BiometricManager | web_app_biometry_* | biometry_info_received, biometry_token_updated, biometry_auth_requested |
DeviceStorage | web_app_device_storage_* | device_storage_* |
SecureStorage | web_app_secure_storage_* | secure_storage_* |
| (location) | web_app_request_location, web_app_check_location | location_requested, location_checked |
| (sensors) | web_app_start_* / web_app_stop_* | *_started, *_changed, *_failed |
Storage and clipboard calls carry a req_id and can overlap. Match responses on req_id; do not assume the next event answers your last call.
Returning data
| Member | Sends | Notes |
|---|---|---|
sendData() | web_app_data_send | Reply-keyboard launches only. 4096 bytes, accepted once, then the app is closed |
switchInlineQuery() | web_app_switch_inline_query | Inline-mode launches |
shareToStory() | web_app_share_to_story | — |
downloadFile() | web_app_request_file_download | file_download_requested |
| (prepared message) | web_app_send_prepared_message | prepared_message_sent, prepared_message_failed. Pairs with savePreparedInlineMessage |
| (custom method) | web_app_invoke_custom_method | custom_method_invoked. Marked P3 |
For inline-button and menu-button launches the way back into the chat is not a bridge event at all — your bot spends the query_id through answerWebAppQuery, server-side.
What to do today
Build the page and the verification. Both are real work that does not depend on the platform: the quickstart has a page shape and a backend endpoint, and validating initData has the part you cannot afford to get wrong.
Keep every bridge call behind an adapter, feature-detect by attempting and handling UNSUPPORTED, and do not ship a build that assumes a global exists. When the runtime lands, the difference between a week of work and an afternoon is whether the event names were scattered through your components or collected in one file.