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