@yaebal/mini-app
v0.1.2
Published
yaebal mini-app — the Telegram Mini Apps server protocol: HMAC + Ed25519 (third-party) initData validation, a typed parser and test signer, an Authorization: tma header helper, answerWebAppQuery, and a WebAppInfo/deep-link url generator.
Maintainers
Readme
@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:
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}`);
});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 — treated as a rejection, never a thrown exception).
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 default for the whole plugin:
await ctx.miniApp.validate(initData, { maxAge: 3600 }); // 1h
await ctx.miniApp.validate(initData, { maxAge: false }); // disable entirely — not recommendedmaxAge: 0 is a real (zero-tolerance) threshold, not a way to disable the check — pass false for that.
outside a bot handler (most of the time — you're validating in 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 initData 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:
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);ctx.miniApp.validateThirdParty(initData) is the bound form — it derives botId from the plugin's
botToken for you, so you don't have to split it out yourself. Pass { test: true } (or set it as
a plugin default) to validate against telegram's test-environment key instead of production —
useful when your mini app runs against a test bot. Both take the same maxAge/now options as
validate, and return the same InitDataValidationResult.
bot.install(miniApp({ botToken: process.env.BOT_TOKEN!, test: true }));
await ctx.miniApp.validateThirdParty(initData); // checked against the test key by default nowisValid(initData, options?) / isValidThirdParty(initData, options?) — and their standalone
isValidInitData / isValidInitDataThirdParty counterparts — are boolean convenience wrappers for
call sites that don't need the reason or the parsed data.
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:
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}`);
},
};// express / any (req, res) framework
app.use((req, res, next) => {
validateAuthHeader(req.headers.authorization, process.env.BOT_TOKEN!).then((result) => {
if (!result.ok) return res.status(401).end();
req.miniAppUser = result.data.user;
next();
});
});initDataFromAuthHeader(header) is the lower-level piece if you just want the raw initData
string out of the header (e.g. to pass into validateInitDataThirdParty yourself) — both are
framework-agnostic: pass whatever string your server gave you for the header.
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:
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, ... }, ... } }auth_date defaults to now; ctx.miniApp.sign(fields) is the bound form using the plugin's
botToken.
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:
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}`);
});telegram warns this field is client-controlled — validate the shape of T as you would any other
untrusted input.
building web app urls & links
webAppInfo/webAppUrl build the { url } used by web_app keyboard buttons (validates
https, merges extra query params for deep-linking a screen inside your mini app):
import { webAppUrl } from "@yaebal/mini-app";
import { InlineKeyboard } from "@yaebal/keyboard";
await ctx.reply("open the shop", {
reply_markup: new InlineKeyboard().webApp(
"open",
webAppUrl("https://example.com/app", { params: { screen: "shop" } }),
),
});(webAppInfo builds the same thing as a { url } object, for apis that want a WebAppInfo
directly — e.g. bot.api.setChatMenuButton.)
miniAppLink builds a
direct link to share outside the
bot (t.me/<bot>/<appName>?startapp=..., or t.me/<bot>?startapp=... for the bot's main mini app
when appName is omitted). botUsername/appName/startParam are all validated against
telegram's charsets — a typo'd username fails at link-build time, not when a user taps a broken link:
import { miniAppLink } from "@yaebal/mini-app";
miniAppLink({ botUsername: "yaebal_bot", appName: "shop", startParam: "ref_42" });
// "https://t.me/yaebal_bot/shop?startapp=ref_42"attachMenuLink builds a direct link that opens the mini app from the
attachment menu
instead — launchable from any chat, not just a conversation with the bot:
import { attachMenuLink } from "@yaebal/mini-app";
attachMenuLink({ botUsername: "yaebal_bot", startParam: "ref_42" });
// "https://t.me/yaebal_bot?startattach=ref_42"both round-trip their startParam back as initData.start_param when the mini app opens.
api
miniApp(options)— installsctx.miniAppon the bot:validate,isValid,validateThirdParty,isValidThirdParty,parse,sign,answerQuery.validateInitData(initData, botToken, options?)— standalone HMAC hash + freshness check.isValidInitData(initData, botToken, options?)— boolean convenience overvalidateInitData.validateInitDataThirdParty(initData, botId, options?)— standalone Ed25519 signature + freshness check, no bot token.isValidInitDataThirdParty(initData, botId, options?)— boolean convenience.parseInitData(initData)— typed parse, no checks.signInitData(fields, botToken)— sign fields into a validinitDatastring (tests/dev).getBotTokenSecretKey(botToken)— the cached HMAC secret keyvalidateInitDataderives from a token, exposed for callers building their own signing/validation on top.initDataFromAuthHeader(header)/validateAuthHeader(header, botToken, options?)— read/validateinitDatafrom anAuthorization: tma <initData>header.parseWebAppData<T>(data)— JSON-parse aweb_app_data.datapayload.webAppUrl(baseUrl, options?)/webAppInfo(baseUrl, options?)— https url /WebAppInfobuilder forweb_appkeyboard buttons.miniAppLink(options)—t.medirect link builder.attachMenuLink(options)—t.meattachment-menu link builder.TELEGRAM_ED25519_PUBLIC_KEYS— telegram'sproduction/testEd25519 public keys (hex), for callers who verify signatures themselves instead of going throughvalidateInitDataThirdParty.
testing
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 hashuse ctx.miniApp.sign(fields) (or the standalone signInitData) to build valid initData for
your own fixtures instead of hand-rolling telegram's HMAC scheme — see signing initData for
tests above.
breaking changes from 0.0.x
- fixed:
validateInitData's HMAC data-check-string wrongly excludedsignaturealongsidehashsince 0.1.0 — that exclusion is Ed25519 (validateInitDataThirdParty) territory only.signatureis an ordinary field in the HMAC data-check-string, covered by the hash like any other; excluding it made the computed hash diverge from telegram's for every genuine Bot API 7.2+ payload (which always carries asignature), so 0.1.0 and 0.1.1 reject all of them asbad_hash. If you're on either, this is worth upgrading for. See telegram's Validating data received via the Mini App vs. Validating data for Third-Party Use for the two (different) exclusion rules. validateInitData's default behavior changed: it now rejectsinitDataolder than 24h by default (maxAge: 86400), where 0.0.x never checked freshness unless you passedmaxAgeyourself. Pass{ maxAge: false }to keep the old (not recommended) behavior.InitData.hashis now typedstring | undefined(see parsing without validating).
part of yaebal — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
