@yaebal/mini-app
the Telegram Mini Apps server protocol, no UI framework attached: HMAC
(ctx.miniApp.validate) and Ed25519 third-party
(ctx.miniApp.validateThirdParty) initData validation, a typed initData parser and test signer, an Authorization: tma header helper for
your mini app's own backend, answerWebAppQuery, web_app_data helpers, and
a WebAppInfo/direct-link url generator.
install
pnpm add @yaebal/mini-appvalidating initData (HMAC)
your mini app's frontend sends Telegram.WebApp.initData to your backend (a bot
command, or the mini app's own http endpoint) — validate it before trusting anything in it. result.ok narrows: on true, result.data is a typed InitData (user, chat, start_param, auth_date as a Date, …); on false, result.reason is one of "missing_hash" | "bad_hash" | "missing_signature" | "bad_signature" | "expired" | "malformed" — a hash that matched, but the data underneath wasn't structurally
valid, is a rejection, never a thrown exception.
import { miniApp } from "@yaebal/mini-app";
bot.install(miniApp({ botToken: process.env.BOT_TOKEN! }));
bot.command("check", async (ctx) => {
const initData = ctx.message?.text?.split(" ").slice(1).join(" ") ?? "";
const result = await ctx.miniApp.validate(initData);
await ctx.reply(result.ok ? `hi ${result.data.user?.first_name}!` : `rejected: ${result.reason}`);
});initData has no built-in expiry, so validate/validateInitData default to rejecting anything older than 24h (maxAge: 86400) —
otherwise a leaked-but-genuinely-signed initData would stay valid forever (replay).
Override per call, or set a plugin-wide default. maxAge: 0 is a real (zero-tolerance)
threshold, not a way to disable the check — pass false for that.
await ctx.miniApp.validate(initData, { maxAge: 3600 }); // 1h
await ctx.miniApp.validate(initData, { maxAge: false }); // disable entirely — not recommendedoutside a bot handler — most of the time, since initData arrives at your mini app's
own backend, not a bot update — use the standalone validateInitData, independent of
any bot or ctx.
import { validateInitData } from "@yaebal/mini-app";
const result = await validateInitData(initData, process.env.BOT_TOKEN!, { maxAge: 3600 });validating without a bot token (Ed25519, third-party)
since Bot API 7.2, initData also carries a signature — an Ed25519
signature over the same fields, checkable against telegram's public key. no bot token
needed, so any third party (an analytics service, a partner backend) can confirm a payload is
genuine, not just the bot owner. ctx.miniApp.validateThirdParty derives botId from the plugin's botToken for you; pass { test: true } (or set it as a plugin default) to validate against telegram's test-environment key instead of
production. Both take the same maxAge/now options as validate, and return the same result shape. isValid/isValidThirdParty (and their standalone isValidInitData/isValidInitDataThirdParty counterparts) are boolean convenience wrappers for call sites that don't need the reason or the
parsed data.
import { validateInitDataThirdParty } from "@yaebal/mini-app";
// botId is the numeric part of a bot token (before the ":") — telegram signs `${botId}:WebAppData\n…`
const result = await validateInitDataThirdParty(initData, botId);
// bound to the plugin's botToken — derives botId for you
const boundResult = await ctx.miniApp.validateThirdParty(initData);validating in your mini app's own http backend
mini apps almost always send initData to their own server, not a bot update — the
convention (matching telegram's docs and every major TMA library) is an Authorization: tma <initData> header. validateAuthHeader is
framework-agnostic: pass whatever string your server gave you for the header. initDataFromAuthHeader(header) is the lower-level piece if you just want the raw initData string out.
import { validateAuthHeader } from "@yaebal/mini-app";
// any fetch-based server (hono, elysia, next.js, sveltekit, a bare Request handler, …)
export default {
async fetch(req: Request) {
const result = await validateAuthHeader(req.headers.get("authorization"), process.env.BOT_TOKEN!);
if (!result.ok) return new Response("unauthorized", { status: 401 });
return new Response(`hi ${result.data.user?.first_name}`);
},
};parsing without validating
parseInitData(initData) parses the same fields without checking anything — only
trust the result once validate()/validateThirdParty() has confirmed it
(it's what they call internally). hash is typed optional: it's present on every initData telegram actually sends, but validateInitDataThirdParty never
needs it, so parsing must not fail on a payload trimmed to just the third-party-relevant fields.
import { parseInitData } from "@yaebal/mini-app";
const data = parseInitData(initData); // { user?, receiver?, chat?, chat_type?, start_param?, auth_date, hash?, signature?, ... }signing initData for tests
signInitData(fields, botToken) builds a valid initData string the way
telegram does — for tests and local development, so you're not hand-rolling telegram's HMAC
signing scheme in every consumer's test suite. auth_date defaults to now; ctx.miniApp.sign(fields) is the bound form using the plugin's botToken.
import { signInitData, validateInitData } from "@yaebal/mini-app";
const initData = await signInitData({ user: { id: 1, first_name: "Linia" } }, BOT_TOKEN);
await validateInitData(initData, BOT_TOKEN); // { ok: true, data: { user: { id: 1, ... }, ... } }answering a mini app query
once the mini app calls Telegram.WebApp.switchInlineQuery(), telegram hands it a query_id (present in initData.query_id) — answer it with answerWebAppQuery to send a message on the user's behalf to the chat the query came from.
bot.command("share", async (ctx) => {
await ctx.miniApp.answerQuery(queryId, {
type: "article",
id: "1",
title: "shared from the mini app",
input_message_content: { message_text: "check this out!" },
});
});web_app_data
when a mini app calls Telegram.WebApp.sendData(), the bot receives it as message.web_app_data — already on ctx.message, no plugin needed to read
it. parseWebAppData JSON-parses the payload. telegram warns this field is
client-controlled — validate the shape of T as you would any other untrusted input.
import { parseWebAppData } from "@yaebal/mini-app";
bot.on("message:web_app_data", async (ctx) => {
const payload = parseWebAppData<{ action: string }>(ctx.message.web_app_data.data);
await ctx.reply(`got: ${payload.action}`);
});building web app urls & links
webAppUrl/webAppInfo build the https url for a web_app keyboard button (validates https, merges extra query params for deep-linking a
screen inside your mini app). miniAppLink builds a direct link to share
outside the bot — botUsername/appName/startParam are all
validated against telegram's charsets, so a typo'd username fails at link-build time, not when a
user taps a broken link. attachMenuLink builds a link that opens the mini app from the attachment
menu instead — launchable from any chat, not just a conversation with the bot. both round-trip startParam back as initData.start_param when the mini app opens.
import { webAppUrl, miniAppLink, attachMenuLink } from "@yaebal/mini-app";
import { InlineKeyboard } from "@yaebal/keyboard";
// { url } for a web_app keyboard button, with an extra query param for deep-linking inside the app
await ctx.reply("open the shop", {
reply_markup: new InlineKeyboard().webApp(
"open",
webAppUrl("https://example.com/app", { params: { screen: "shop" } }),
),
});
// a shareable t.me direct link — round-trips as initData.start_param
miniAppLink({ botUsername: "yaebal_bot", appName: "shop", startParam: "ref_42" });
// "https://t.me/yaebal_bot/shop?startapp=ref_42"
// opens from the attachment menu instead — launchable from any chat, not just with the bot
attachMenuLink({ botUsername: "yaebal_bot", startParam: "ref_42" });
// "https://t.me/yaebal_bot?startattach=ref_42"api
| export | signature | description |
|---|---|---|
miniApp | (options: MiniAppOptions) => Plugin<Context, { miniApp: MiniAppControl }> | installs ctx.miniApp |
validateInitData | (initData, botToken, options?: ValidateInitDataOptions) => Promise<InitDataValidationResult> | standalone HMAC hash + freshness check |
isValidInitData | (initData, botToken, options?) => Promise<boolean> | boolean convenience over validateInitData |
validateInitDataThirdParty | (initData, botId, options?: ValidateInitDataThirdPartyOptions) => Promise<InitDataValidationResult> | standalone Ed25519 signature + freshness check, no bot token |
isValidInitDataThirdParty | (initData, botId, options?) => Promise<boolean> | boolean convenience |
parseInitData | (initData: string) => InitData | typed parse, no checks |
signInitData | (fields: SignableInitDataFields, botToken: string) => Promise<string> | sign fields into a valid initData string (tests/dev) |
getBotTokenSecretKey | (botToken: string) => Promise<Uint8Array> | the cached HMAC secret key validateInitData derives from a token |
initDataFromAuthHeader | (header: string | null | undefined) => string | undefined | extract initData from an Authorization: tma … header |
validateAuthHeader | (header, botToken, options?: ValidateInitDataOptions) => Promise<InitDataValidationResult> | validateInitData, reading initData from the header |
parseWebAppData | <T = unknown>(data: string) => T | JSON-parse a web_app_data.data payload |
webAppUrl | (baseUrl: string, options?: WebAppUrlOptions) => string | validated (https-only) url, with merged query params |
webAppInfo | (baseUrl: string, options?: WebAppUrlOptions) => WebAppInfo | { url: webAppUrl(baseUrl, options) } for a web_app keyboard button |
miniAppLink | (options: MiniAppLinkOptions) => string | t.me direct-link builder |
attachMenuLink | (options: AttachMenuLinkOptions) => string | t.me attachment-menu link builder |
TELEGRAM_ED25519_PUBLIC_KEYS | { production: string; test: string } | telegram's Ed25519 public keys (hex), for callers verifying signatures themselves |
MiniAppControl interface (ctx.miniApp)
| method | returns | description |
|---|---|---|
validate(initData, options?) | Promise<InitDataValidationResult> | HMAC hash + freshness check against the installed bot token |
isValid(initData, options?) | Promise<boolean> | boolean convenience over validate |
validateThirdParty(initData, options?) | Promise<InitDataValidationResult> | Ed25519 signature check, botId derived from the bot token |
isValidThirdParty(initData, options?) | Promise<boolean> | boolean convenience over validateThirdParty |
parse(initData) | InitData | parse without checking anything |
sign(fields) | Promise<string> | sign fields into a valid initData string (tests/dev) |
answerQuery(webAppQueryId, result) | Promise<SentWebAppMessage> | wraps answerWebAppQuery |
testing
drive ctx.miniApp with @yaebal/test as
usual — use ctx.miniApp.sign(fields) (or the standalone signInitData) to
build valid initData for your own fixtures instead of hand-rolling telegram's HMAC
scheme.
import { Composer, type Context } from "@yaebal/core";
import { createTestEnv } from "@yaebal/test";
import { miniApp } from "@yaebal/mini-app";
const bot = new Composer<Context>()
.install(miniApp({ botToken: "test-token" }))
.command("check", async (ctx) => ctx.reply(String((await ctx.miniApp.validate("hash=bad")).ok)));
const env = createTestEnv(bot);
await env.createUser().sendCommand("check"); // "false" — no valid hashcrypto.subtle (HMAC-SHA256 and Ed25519), not node:crypto, so every validate*/ctx.miniApp.* call works the same on node, bun, deno, and edge
runtimes.hash and signature — but that exclusion rule is Ed25519
(validateInitDataThirdParty) territory only. For the HMAC path, telegram's spec
excludes just hash; signature is an ordinary field covered by the
hash. Since Bot API 7.2, real initData always carries a signature, so
0.1.0/0.1.1 reject every genuine payload from a current telegram client as bad_hash. See the package README for the full list.external domains
since july 20, 2026, telegram blocks mini app methods on external origins opened through
links inside the app by default. keep method calls on the original mini app domain.
this browser-side restriction does not change server-side initData validation.
botfather's mini app offers an opt-out that leaves trust in linked sites to the bot developer.
see the bot api 10.2 changelog.