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 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:
{
"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>"
}
}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):
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 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}}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:
{
"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:
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):
app_id=3f2a1c9e-…
auth_date=1727827200
user={"id":"u_9f3c…","username":"ama"}
hash=c32fceed1e932f5b75b22240f205ed076a496ee1c24c3f5ed72b1244a7cff8c3user.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:
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)
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:
secret_key = c67048a125a268d6e23e466769a0980ea66f00e0da6cb412d4b8fd90a539bcdd
data_check_string:
app_id=test-app
auth_date=1700000000
user={"id":"u_abc","username":"test"}
hash = c32fceed1e932f5b75b22240f205ed076a496ee1c24c3f5ed72b1244a7cff8c3Copy-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.
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 fieldsconst 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;
}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:
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):
{
"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.