Appearance
Quickstart
Three pieces: a page you host, a button that opens it, and a backend endpoint that verifies the launch context. The third one is not optional.
Not implemented
Mini Apps do not run yet — there is no client runtime and no SDK to include. See implementation status.
Nothing below has been executed against a live server. It is written as a real walkthrough so the shape can be reviewed now, but every step depends on something that does not exist yet: no token can be issued, so no gateway call can be authenticated, and there is no client that would open the page.
What you cannot do yet
Being specific about the blockers, because "coming soon" hides which ones are yours to solve:
| Step | Blocked by |
|---|---|
| Get a bot token | @GromCore does not exist |
| Attach the app to a button | No token to call setChatMenuButton with |
| Open the page from GROM | No Mini App runtime in any client |
| Read the launch context | Nothing produces one |
| Include the SDK | The TZ does not name a script to include — see JavaScript SDK |
Hosting the page and writing the verification is real work you can do today. Everything else waits.
1. The page
A Mini App is a normal page. There is no build step, no manifest, no registration beyond telling the platform its URL.
html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Order</title>
<body style="background: var(--bg, #fff); color: var(--fg, #000); font: 16px system-ui; margin: 0; padding: 16px">
<h1>Order</h1>
<p id="who">Checking…</p>
<script>
// The launch context arrives in the URL *fragment*, never the query string.
// Fragments are not sent to servers, so your backend cannot read this —
// the page has to hand it over deliberately.
const initData = new URLSearchParams(location.hash.slice(1)).get('tgWebAppData') || ''
fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData }),
})
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('rejected'))))
.then((s) => { document.getElementById('who').textContent = `Hello, ${s.first_name}` })
.catch(() => { document.getElementById('who').textContent = 'Could not verify this session.' })
</script>
</body>Two things in that snippet matter more than they look.
The context is read from location.hash, not location.search — that is a hard requirement in the TZ, so the signed blob stays out of server access logs and Referer headers. The cost is that your server never sees it by accident, which is the right default.
And the page displays nothing until the backend answers. It does not parse the user out of initData and render a name from it. The string is attacker-controlled until your server proves otherwise; anyone can open your URL by hand with any user payload they like.
The one rule
Never verify initData in the browser, and never authorize on a value the page parsed for itself. Verification needs your bot token as the HMAC key, and putting that token in a browser hands over the entire bot. Validating initData covers this properly.
2. Verify on your backend
The endpoint the page posts to, in outline. Full treatment — sorted key order, constant-time comparison, replay windows, and the same check in PHP and Node — is on the initData page.
python
import os, hmac, hashlib
from urllib.parse import parse_qsl
from flask import Flask, request, jsonify
import json, time
app = Flask(__name__)
TOKEN = os.environ["GROM_TOKEN"]
@app.post("/api/session")
def session():
init_data = (request.get_json(silent=True) or {}).get("initData", "")
pairs = dict(parse_qsl(init_data, strict_parsing=True))
received = pairs.pop("hash", "")
check_string = "\n".join(f"{k}={pairs[k]}" for k in sorted(pairs))
secret = hmac.new(b"WebAppData", TOKEN.encode(), hashlib.sha256).digest()
expected = hmac.new(secret, check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
return jsonify(error="bad signature"), 403
if time.time() - int(pairs.get("auth_date", 0)) > 3600:
return jsonify(error="stale"), 403
user = json.loads(pairs["user"])
# Issue your own session here. Do not re-verify initData on every request.
return jsonify(first_name=user["first_name"])Verify once, then issue your own session cookie or token. Re-running the HMAC on every request works, but it keeps a long-lived credential in the page and gives you no way to revoke a session.
3. Attach it to a button
Two ways, and they behave differently.
A menu button puts the app next to the chat's input field, where it stays:
bash
curl https://api.casinoplaneta.info/bot$GROM_TOKEN/setChatMenuButton \
-H 'Content-Type: application/json' \
-d '{"menu_button": {"type": "web_app", "text": "Order", "web_app": {"url": "https://example.com/app"}}}'An inline keyboard button attaches the app to one specific message, which is what you want when the app needs the context of that message:
bash
curl https://api.casinoplaneta.info/bot$GROM_TOKEN/sendMessage \
-H 'Content-Type: application/json' \
-d '{
"chat_id": 1337,
"text": "Ready to order?",
"reply_markup": {"inline_keyboard": [[
{"text": "Open", "web_app": {"url": "https://example.com/app"}}
]]}
}'Both return 404 today. The URL must be https:// in either case — see WebAppInfo. Query parameters you add are preserved and are the ordinary way to pass non-secret context in; secrets do not belong there, because the URL is visible to the user.
Local development
The platform will only open https:// URLs, which rules out http://localhost directly. The usual answer is a tunnel — cloudflared, ngrok, tailscale funnel — pointed at your dev server, with the tunnel hostname registered as the app URL.
That is the theory. In practice there is nothing to point at a tunnel yet, so local development today means building the page against a hand-written initData fixture and testing the verification path directly.
That fixture is worth writing carefully, because it is the only part of this you can genuinely test now:
python
# fixture.py — sign a launch context with a throwaway token, then assert your
# endpoint accepts it and, more importantly, rejects it with one byte changed.
import hmac, hashlib, json, time
from urllib.parse import quote
TOKEN = "4210:TEST"
# Raw values, before URL encoding. The check string is built from these.
raw = {
"auth_date": str(int(time.time())),
"user": json.dumps({"id": 1, "first_name": "Test"}, separators=(",", ":")),
}
check_string = "\n".join(f"{k}={raw[k]}" for k in sorted(raw))
secret = hmac.new(b"WebAppData", TOKEN.encode(), hashlib.sha256).digest()
raw["hash"] = hmac.new(secret, check_string.encode(), hashlib.sha256).hexdigest()
print("&".join(f"{k}={quote(v, safe='')}" for k, v in raw.items()))Write the negative tests first: a tampered user, a missing hash, an auth_date from last week, an empty string. A verifier that accepts all four looks exactly like one that works, right up until it does not.
Next
- Validating initData — the security page, and the one that matters
- Events — driving the client from your page
- Design — theme params, viewport, safe area