Guide

Building a mini app

A mini app is any HTTPS web page that runs inside a SprinTelex chat. The client opens it in a hardened viewer, hands it a signed identity payload, and exposes a small JS bridge (window.SprinTelex) for native-feel UI. This guide takes you from registration to a validated session.

1. Register the app

Apps belong to a bot. Call createApp with your developer credentials (the dashboard's Apps tab does this for you):

curl
curl https://api.sprintelex.com/createApp \
  -H "Authorization: Bearer <your-access-token>" \
  -H "Content-Type: application/json" \
  -d '{"bot_id": "<bot-id>", "name": "My App", "url": "https://app.example.com/"}'

name is 1 to 64 characters. url must be a valid HTTPS URL of at most 2048 characters; its domain becomes the app's verified domain. The response carries the app record and an app_secret:

response
{
  "ok": true,
  "result": {
    "app": {
      "id": "3f2a1c9e-…",
      "bot_id": "<bot-id>",
      "name": "My App",
      "url": "https://app.example.com/",
      "domain": "app.example.com",
      "version": "1.0",
      "verified": false,
      "created_at": "2026-10-02T…"
    },
    "app_secret": "<64 hex characters>"
  }
}
The app_secret is shown once, at creation. Store it as SPRINTELEX_APP_SECRET on your server right away. It can never be re-read afterwards, only regenerated with regenerateAppSecret (which invalidates every previously signed payload).

2. Verify your domain

Prove you own the domain by hosting a plain text file at https://app.example.com/.well-known/sprintelex-app.txt whose content includes your app id (the platform reads the first 200 characters):

sprintelex-app.txt
3f2a1c9e-…

Then call verifyAppDomain with app_id. The platform fetches the file server side (10 second timeout, SSRF guarded) and marks the app verified:

curl
curl https://api.sprintelex.com/verifyAppDomain \
  -H "Authorization: Bearer <your-access-token>" \
  -H "Content-Type: application/json" \
  -d '{"app_id": "3f2a1c9e-…"}'
{"ok": true, "result": {"verified": true}}
Only verified apps can launch: opening an unverified app fails with 403 app domain is not verified. If you change the app URL later with updateApp, verification resets to false and you must verify the new domain again.

3. Launch it from a button

The simplest launch context is an inline button. In sendMessage, add a web_app button to the inline keyboard. Each button carries exactly one of callback_data, url, or web_app:

sendMessage
{
  "chat_id": "<chat-id>",
  "text": "Try the new app:",
  "reply_markup": {
    "inline_keyboard": [
      [{ "text": "Open app", "web_app": { "url": "https://app.example.com/" } }]
    ]
  }
}

The web_app URL must be HTTPS and at most 2048 characters. When the user taps the button, the client opens the embedded viewer and asks the server for signed launch data, bound to that bot: a bot can never open another bot's app inside its own chat.

4. Read initData in your page

The bridge is injected before your page loads. Read the signed payload from window.SprinTelex.initData and POST it to your server with your first API call:

your page (JavaScript)
SprinTelex.ready();

fetch("https://app.example.com/api/session", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ initData: Sprintelex.initData }),
});

A raw payload looks like this (line breaks added for readability):

initData
app_id=3f2a1c9e-…
auth_date=1727827200
user={"id":"u_9f3c…","username":"ama"}
hash=c32fceed1e932f5b75b22240f205ed076a496ee1c24c3f5ed72b1244a7cff8c3

user.id is a per-bot scoped id, not the user's real account id. initDataUnsafe gives you the same fields parsed (handy for rendering, never for trust).

5. Validate initData on your server

Never trust the client. Your server must recompute the signature with your app_secret on every request that acts on a user:

algorithm
secret_key = HMAC_SHA256(key="SprinTelexAppData", msg=app_secret_bytes)
data_check_string = fields minus "hash", sorted by key, "key=value", LF-joined
expected = hex(HMAC_SHA256(secret_key, data_check_string))
valid = constant_time_compare(expected, received_hash)
The number one gotcha is swapping the first HMAC's key and message. The key is the literal string SprinTelexAppData and the message is your secret's raw bytes (hex-decoded, not the hex string). Swapped, every signature fails and the error gives you no hint why.

Worked test vector. Given app_secret = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:

test vector
secret_key = c67048a125a268d6e23e466769a0980ea66f00e0da6cb412d4b8fd90a539bcdd

data_check_string:
app_id=test-app
auth_date=1700000000
user={"id":"u_abc","username":"test"}

hash = c32fceed1e932f5b75b22240f205ed076a496ee1c24c3f5ed72b1244a7cff8c3

Copy-paste validators. Note the vector's auth_date is in the past, so the freshness check below would reject it: use it to verify your HMAC chain, then test freshness separately with a live payload.

Python
import hmac, hashlib, time

def validate_init_data(init_data: str, app_secret_hex: str,
                       max_age_seconds: int = 1800) -> dict:
    fields: dict[str, str] = {}
    received_hash = None
    for line in init_data.strip().split("\n"):
        key, _, value = line.partition("=")
        if key == "hash":
            received_hash = value
        else:
            fields[key] = value
    if not received_hash:
        raise ValueError("initData is missing its hash")
    data_check_string = "\n".join(
        f"{k}={fields[k]}" for k in sorted(fields))
    # Gotcha: key is the literal string, message is the decoded secret bytes.
    secret_key = hmac.new(b"SprinTelexAppData",
                          bytes.fromhex(app_secret_hex),
                          hashlib.sha256).digest()
    expected = hmac.new(secret_key, data_check_string.encode("utf-8"),
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received_hash):
        raise ValueError("initData signature mismatch")
    auth_date = int(fields.get("auth_date", "0"))
    if time.time() - auth_date > max_age_seconds:
        raise ValueError("initData expired")
    return fields
Node.js
const crypto = require("crypto");

function validateInitData(initData, appSecretHex, maxAgeSeconds = 1800) {
  const fields = {};
  let receivedHash = null;
  for (const line of initData.trim().split("\n")) {
    const ix = line.indexOf("=");
    const key = line.slice(0, ix);
    const value = line.slice(ix + 1);
    if (key === "hash") receivedHash = value;
    else fields[key] = value;
  }
  if (!receivedHash) throw new Error("initData is missing its hash");
  const dataCheckString = Object.keys(fields).sort()
    .map((k) => k + "=" + fields[k]).join("\n");
  // Gotcha: key is the literal string, message is the decoded secret bytes.
  const secretKey = crypto.createHmac("sha256", "SprinTelexAppData")
    .update(Buffer.from(appSecretHex, "hex")).digest();
  const expected = crypto.createHmac("sha256", secretKey)
    .update(dataCheckString, "utf8").digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedHash)))
    throw new Error("initData signature mismatch");
  const authDate = parseInt(fields.auth_date || "0", 10);
  if (Date.now() / 1000 - authDate > maxAgeSeconds)
    throw new Error("initData expired");
  return fields;
}
initData carries no nonce, so the auth_date freshness check is your only replay defense. Enforce 30 minutes in normal operation and never accept anything older than 24 hours.

6. Send data back with sendData

When the user is done, post a small payload back to the bot with SprinTelex.sendData(data). The payload may be a string or an object (objects are JSON-encoded) and must be at most 4KB. The app closes on success:

your page (JavaScript)
SprinTelex.sendData({ choice: "accra", seats: 2 });

Your bot receives it as an app_data update, in the thread the app was opened from (the DM thread, or the group thread):

app_data update
{
  "update_id": 42,
  "app_data": {
    "app_id": "3f2a1c9e-…",
    "data": "{\"choice\":\"accra\",\"seats\":2}",
    "from": { "id": "u_9f3c…", "username": "ama", "display_name": "Ama" },
    "chat": { "id": "<chat-id>", "type": "private" }
  }
}
data always arrives as a string. The send is authenticated as the user who opened the app, and it is refused when the app is unverified or does not belong to the bot.