Skip to content

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:

StepBlocked by
Get a bot token@GromCore does not exist
Attach the app to a buttonNo token to call setChatMenuButton with
Open the page from GROMNo Mini App runtime in any client
Read the launch contextNothing produces one
Include the SDKThe 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