@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.