@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

terminal
pnpm add @yaebal/mini-app

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

bot.ts
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.

bot.ts
await ctx.miniApp.validate(initData, { maxAge: 3600 }); // 1h
await ctx.miniApp.validate(initData, { maxAge: false }); // disable entirely — not recommended

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

server.ts
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.

server.ts
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.

server.ts
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.

parse.ts
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.

mini-app.test.ts
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.ts
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.

bot.ts
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.

links.ts
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

exportsignaturedescription
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) => InitDatatyped 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 | undefinedextract initData from an Authorization: tma … header
validateAuthHeader(header, botToken, options?: ValidateInitDataOptions) => Promise<InitDataValidationResult>validateInitData, reading initData from the header
parseWebAppData<T = unknown>(data: string) => TJSON-parse a web_app_data.data payload
webAppUrl(baseUrl: string, options?: WebAppUrlOptions) => stringvalidated (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) => stringt.me direct-link builder
attachMenuLink(options: AttachMenuLinkOptions) => stringt.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)

methodreturnsdescription
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)InitDataparse 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.

mini-app.test.ts
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 hash
runs everywhere. hashing and signature verification go through crypto.subtle (HMAC-SHA256 and Ed25519), not node:crypto, so every validate*/ctx.miniApp.* call works the same on node, bun, deno, and edge runtimes.
on 0.1.0 or 0.1.1? those versions compute the HMAC hash excluding both 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.