Key takeaways

  • A Telegram Mini App is your own HTTPS web page that loads telegram-web-app.js and opens inside Telegram from your bot's menu button, profile, or a t.me link.
  • Your server must validate initData with an HMAC-SHA256 key derived from the bot token and the string "WebAppData" before it trusts the user, and initDataUnsafe is for display only.
  • Digital goods and services sold inside a Mini App must be paid in Telegram Stars (currency XTR), and you deliver them only after the successful_payment update arrives.

How to make a Telegram Mini App

To make a Telegram Mini App, create a bot with @BotFather, build a web page that loads Telegram’s telegram-web-app.js script, host it on an HTTPS URL, and set that URL as the bot’s menu button or Main Mini App. The page reads the user’s theme and launch data from window.Telegram.WebApp, and your server checks that launch data (initData) with an HMAC-SHA256 signature derived from your bot token before it trusts who the user is. Everything else is ordinary web development.

This tutorial builds a working daily check-in app in plain HTML, JavaScript, and Node.js: the user opens it from the bot, sees their name and points, and taps the bottom button once a day for 10 points. It is small, but it has every part a real Mini App needs: theming, the main button, haptics, server-side validation, and server-side state. Every Telegram API name comes from Telegram’s own docs, mainly the Mini Apps documentation, checked on October 22, 2026 (the newest change listed there is Bot API 10.1 from June 11, 2026).

How a Mini App works: who does what

A Mini App is your own website that Telegram opens in a web view connected to your bot. Telegram adds two things: a JavaScript bridge to the Telegram app, and signed launch data about the user.

PartWhere it runsIts job
BotTelegramOwns the token, the menu button, and the Mini App settings you make in @BotFather
Front endTelegram’s web view on the user’s deviceYour HTML, CSS, and JavaScript, in plain code or any framework
telegram-web-app.jsInside your pageExposes window.Telegram.WebApp: initData, themeParams, MainButton, HapticFeedback, openLink, openInvoice, and more
Your serverYour HTTPS hostValidates initData, stores users and balances, creates invoices
Bot tokenYour server onlyTalks to the Bot API and is the base of the validation key

One rule shapes everything else. Telegram’s docs warn that initDataUnsafe should not be trusted, and that you should use initData on your server only after validating it. Anyone can fake the browser object, so it is for showing a name, never for deciding who gets points.

Step 1: create the bot with BotFather

  1. Open @BotFather in Telegram and send /newbot. BotFather also has its own Mini App with the same functions, if you prefer buttons to commands.
  2. Choose a display name, then a username. Telegram’s bot features page says usernames are 5 to 32 characters, may only use Latin letters, numbers, and underscores, must end in “bot”, and cannot be changed later.
  3. Copy the token BotFather sends, in the form 110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw (Telegram’s sample). Anyone with it can control your bot, and section 4.5 of Telegram’s Bot Platform Developer Terms prohibits making such credentials public. If it leaks, /token generates a new one.

Keep the token in an environment variable on your server, such as BOT_TOKEN, never in your HTML, JavaScript bundle, or Git repository. Make a second bot for testing, too, as Telegram suggests, so you never test on real users.

Step 2: build the front end with telegram-web-app.js

Create public/index.html. Telegram’s docs say to place telegram-web-app.js in the <head> before any other scripts; the ?64 suffix is the version shown in the docs on the day we checked.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="https://telegram.org/js/telegram-web-app.js?64"></script>
  <style>
    body {
      margin: 0;
      padding: 24px 16px;
      font-family: system-ui, sans-serif;
      background: var(--tg-theme-bg-color, #ffffff);
      color: var(--tg-theme-text-color, #000000);
    }
    .hint { color: var(--tg-theme-hint-color, #707579); }
    .points { font-size: 48px; font-weight: 700; color: var(--tg-theme-accent-text-color, #2481cc); }
  </style>
</head>
<body>
  <h1 id="hello">Loading...</h1>
  <p class="points" id="points">0</p>
  <p class="hint" id="status">Claim 10 points once a day.</p>
  <script>
    const tg = window.Telegram.WebApp;
    tg.ready();
    tg.expand();

    async function api(path) {
      const res = await fetch(path, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ initData: tg.initData }),
      });
      return { ok: res.ok, status: res.status, data: await res.json() };
    }

    function show(data) {
      document.getElementById('points').textContent = data.points;
    }

    api('/api/session').then(({ ok, data }) => {
      if (!ok) {
        document.getElementById('hello').textContent = 'Open this app from Telegram';
        return;
      }
      document.getElementById('hello').textContent = 'Hi, ' + data.name;
      show(data);
      tg.MainButton.setText('Claim daily points').show();
    });

    tg.MainButton.onClick(async () => {
      tg.MainButton.showProgress();
      const { ok, status, data } = await api('/api/claim');
      tg.MainButton.hideProgress();
      if (ok) {
        show(data);
        tg.HapticFeedback.notificationOccurred('success');
        document.getElementById('status').textContent = 'Claimed. Come back tomorrow.';
      } else if (status === 409) {
        tg.HapticFeedback.notificationOccurred('warning');
        tg.showAlert('You already claimed today.');
      } else {
        tg.HapticFeedback.notificationOccurred('error');
      }
    });
  </script>
</body>
</html>

What each part does:

  • tg.ready() tells Telegram the app can be shown and hides the loading placeholder. Telegram recommends calling it as early as possible, once the essential interface is loaded.
  • tg.expand() asks for the full available height when Telegram opens the app at part height. A Main Mini App and a direct link already open at full height by default.
  • Theme colors. Each themeParams color is also a CSS variable, such as --tg-theme-bg-color, so the page matches the user’s day or night theme; the value after the comma is the fallback in a normal browser. If you draw with JavaScript, read tg.themeParams and listen for themeChanged.
  • tg.MainButton is the native button at the bottom of the Telegram interface. Its methods return the button, so they chain, and Telegram recommends showProgress() when the action may take a while.
  • tg.HapticFeedback.notificationOccurred() takes success, warning, or error. For taps in games, use impactOccurred() with a style such as light or heavy.
  • tg.initData, the raw signed string, goes to the server on every call. The page never sends a user ID of its own.

Step 3: validate initData on the server in Node.js

Telegram documents the check in Validating data received via the Mini App. In short:

  1. Parse initData as a query string and take out hash.
  2. Sort the remaining fields alphabetically and join them as key=value lines separated by a line feed. This is the data-check-string.
  3. Compute the secret key: HMAC-SHA256 of the bot token, with the constant string WebAppData as the key.
  4. Compute HMAC-SHA256 of the data-check-string with that secret key, in hex, and compare it with hash.
  5. Check auth_date to reject old data.

In Node.js, with only the built-in crypto module, save this as validate-init-data.js:

const crypto = require('node:crypto');

function validateInitData(initData, botToken, maxAgeSeconds = 3600) {
  if (typeof initData !== 'string' || initData === '') return null;
  const params = new URLSearchParams(initData);
  const hash = params.get('hash');
  if (!hash || !/^[0-9a-f]{64}$/.test(hash)) return null;
  params.delete('hash');

  const dataCheckString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([key, value]) => `${key}=${value}`)
    .join('\n');

  const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
  const expected = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest();
  if (!crypto.timingSafeEqual(expected, Buffer.from(hash, 'hex'))) return null;

  const authDate = Number(params.get('auth_date'));
  const age = Math.floor(Date.now() / 1000) - authDate;
  if (!Number.isInteger(authDate) || age > maxAgeSeconds) return null;

  let user = null;
  try {
    user = JSON.parse(params.get('user'));
  } catch {
    return null;
  }
  if (!user || typeof user.id !== 'number') return null;
  return { user, authDate, startParam: params.get('start_param') };
}

module.exports = { validateInitData };

The details that trip people up:

  • Key order in the first HMAC. WebAppData is the key and the bot token is the message; swapping them is the most common bug. Use the result as raw bytes, not hex.
  • Only hash comes out. Every other field stays in the data-check-string, including signature. Telegram added that field with Bot API 8.0 so third parties can check the data with Telegram’s Ed25519 public key without knowing your token; your own HMAC check treats it like any other field.
  • Constant-time comparison. crypto.timingSafeEqual does not leak timing, and Node’s docs say it throws when the two inputs have different byte lengths. The regular expression makes sure hash is exactly 64 hex characters first. What is HMAC? explains why a plain === is not enough.
  • auth_date is the time the app was opened. One hour is the default above. If users keep your app open longer, validate once at launch and issue your own short-lived session token, rather than accepting days-old launch data.
  • Telegram user IDs are big. Telegram says user.id can have more than 32 significant bits (at most 52), so store it as a 64-bit integer, such as BIGINT, never a 32-bit INT.

Now the server. Install Express with npm install express and save this as server.js:

const express = require('express');
const { validateInitData } = require('./validate-init-data');

const BOT_TOKEN = process.env.BOT_TOKEN;
if (!BOT_TOKEN) throw new Error('Set BOT_TOKEN');

const app = express();
app.use(express.json({ limit: '10kb' }));
app.use(express.static('public'));

const points = new Map();
const lastClaim = new Map();

function telegramUser(req, res, next) {
  const result = validateInitData(req.body?.initData, BOT_TOKEN);
  if (!result) return res.status(401).json({ error: 'invalid init data' });
  req.tgUser = result.user;
  next();
}

app.post('/api/session', telegramUser, (req, res) => {
  const id = req.tgUser.id;
  res.json({ name: req.tgUser.first_name, points: points.get(id) ?? 0 });
});

app.post('/api/claim', telegramUser, (req, res) => {
  const id = req.tgUser.id;
  const today = new Date().toISOString().slice(0, 10);
  if (lastClaim.get(id) === today) {
    return res.status(409).json({ error: 'already claimed today', points: points.get(id) ?? 0 });
  }
  lastClaim.set(id, today);
  points.set(id, (points.get(id) ?? 0) + 10);
  res.json({ points: points.get(id) });
});

app.listen(process.env.PORT ?? 3000);

Every API route runs telegramUser first, so handlers only see validated users. The Map objects keep the example short; use a database in production. Start it with BOT_TOKEN=your-token node server.js.

Test the validation locally with a generated test vector

You do not need Telegram to test the check. This helper signs a launch string the way Telegram does, using a fake token. Save it as make-test-init-data.js:

const crypto = require('node:crypto');

function makeTestInitData(botToken, fields) {
  const params = new URLSearchParams(fields);
  const dataCheckString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([key, value]) => `${key}=${value}`)
    .join('\n');
  const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
  params.set('hash', crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'));
  return params.toString();
}

module.exports = { makeTestInitData };

Then a short test, check-init-data.js: a valid string must pass, and a changed user ID or a different token must fail.

const assert = require('node:assert');
const { validateInitData } = require('./validate-init-data');
const { makeTestInitData } = require('./make-test-init-data');

const token = '123456789:TEST-token-for-local-checks-only';
const initData = makeTestInitData(token, {
  query_id: 'AAHtest',
  user: JSON.stringify({ id: 42, first_name: 'Ana' }),
  auth_date: String(Math.floor(Date.now() / 1000)),
});

assert.strictEqual(validateInitData(initData, token).user.id, 42);
assert.strictEqual(validateInitData(initData.replace('%22id%22%3A42', '%22id%22%3A43'), token), null);
assert.strictEqual(validateInitData(initData, '123456789:another-token'), null);
console.log('initData checks passed');

node check-init-data.js prints initData checks passed.

Why we also checked against an outside example

A helper that signs the same way as your validator can share its bugs, so we also ran the validator against the worked example in the community Telegram Mini Apps init data docs (age limit raised, since that sample is from 2022), and it passed. All code on this page ran on Node.js 22 with Express 5.3; tampered, expired, and missing launch data got a 401.

Step 4: host it on HTTPS and connect it to the bot

Telegram’s Bot API describes a Mini App URL as “an HTTPS URL”, so production needs a real certificate. You have three practical options:

  • Your own host. Any server that runs Node.js behind HTTPS works. Serve the page and the API from the same origin, as server.js does, and you avoid CORS.
  • A tunnel while you develop. Cloudflare’s Quick Tunnels give a local server a temporary trycloudflare.com URL with no account: run cloudflared tunnel --url http://localhost:3000. Cloudflare says they are for testing only, and the URL stops working when you stop the tunnel.
  • Telegram Serverless. Since October 6, 2026, Telegram Serverless can host a Mini App’s built front end at https://app<app_id>.tgcloud.ai/ and run endpoint functions that the page calls with Telegram.WebApp.Serverless.call. Telegram validates initData before it runs an endpoint. It is a different setup (JavaScript modules deployed with the tgcloud CLI), worth a look if you would rather not run a server.

Once the page is online, tell Telegram where it is. Telegram supports seven launch methods; these are the ones most apps use:

Launch methodHow to set it upGood to know
Main Mini App@BotFather: /mybots, select the bot, Bot Settings, Configure Mini App, Enable Mini AppAdds a Launch app button and media previews to the bot profile; opens from https://t.me/botusername?startapp; full height by default
Menu button/setmenubutton in BotFather, or setChatMenuButton in the Bot API for specific usersWorks the same way as a launch from an inline button
Inline buttonA web_app button under a bot messageGets the user and a query_id for answerWebAppQuery
Direct linkhttps://t.me/botusername/appnameGets chat_type and chat_instance; add mode=compact to open at half height
Keyboard buttonA web_app keyboard buttonsendData sends up to 4096 bytes back to the bot and closes the app; launch data is empty
Attachment menu/setattach in BotFatherOnly for major advertisers on the Telegram Ad Platform, but every bot can use it in the test environment

For most apps, set a Main Mini App and a menu button with the same URL. Avoid the keyboard button for anything account-based: Telegram’s docs say the launch data is empty when the app opens from a keyboard button or inline mode, so there is no signed user to check. With ?startapp=summer in a link, the value arrives as start_param in initData; if it affects rewards, such as a referral code, read it on the server from validated data, as the validator above returns it.

Mistakes that break a Mini App’s security

Putting the bot token in front-end code, trusting initDataUnsafe or a user ID the page sends, skipping the auth_date check, comparing hashes with ===, and crediting points or goods from a client message instead of from your own server’s decision.

Step 5: test and debug your Mini App

Telegram offers a dedicated test environment, completely separate from the main one, where you create a new account and a new bot with BotFather. On iOS you reach it by tapping the Settings icon 10 times, then Accounts, Login to another account, Test; on Telegram Desktop, with Shift + Alt + right click on Add Account, then Test Server. There you may use plain HTTP links without TLS, and Bot API calls go to https://api.telegram.org/bot<token>/test/METHOD_NAME. Flood limits are not raised and may be stricter.

To inspect the page itself, turn on web view debugging:

  • Android: enable USB debugging, press and hold the version number at the bottom of Telegram Settings twice, choose Enable WebView Debug, and open chrome://inspect/#devices in Chrome.
  • iOS: tap Settings 10 times, toggle Allow Web View Inspection, connect the phone by USB, and use Safari’s Develop menu on your Mac.
  • Desktop: in the Beta version for Windows and Linux, enable webview inspection under Settings, Advanced, Experimental settings; in the macOS Beta, click Settings 5 times and enable Debug Mini Apps. Then right click and inspect.

Then run through the example: open it from the menu button, claim once and get 10 points, claim again and see the alert, switch between day and night themes, and open the URL in a normal browser, where the server must return 401.

Payments: sell digital goods only in Telegram Stars

Section 6.2 of the Bot Platform Developer Terms says all transactions for digital goods and services in a bot or Mini App must use Telegram Stars, because of the rules of the stores that host Telegram’s apps. Telegram’s Stars payments guide describes the flow, checked on October 22, 2026:

  1. Your server creates an invoice with sendInvoice or, for a Mini App, createInvoiceLink, with currency set to XTR, an empty provider_token, and exactly one price item.
  2. The page opens the link with tg.openInvoice(url, callback). The callback and the invoiceClosed event report paid, cancelled, failed, or pending.
  3. Your bot answers the pre_checkout_query update with answerPreCheckoutQuery within 10 seconds, or the transaction is canceled.
  4. You deliver only after the successful_payment update arrives, and store its telegram_payment_charge_id in case you need refundStarPayment.

The page’s paid status only updates the interface; delivery happens on the server. Your bot must also answer /paysupport, and section 6.2.1 says refunds come out of your balance. Physical goods use third-party payment providers instead. How to monetize a Telegram bot covers Stars subscriptions, paid media, and ad revenue, and Telegram Stars vs offerwall compares Stars with letting users earn.

Adding an offerwall to your Mini App

Once you have validated users and server-side balances, an offerwall is a small addition. Your server builds a signed offerwall link for the user’s own account ID, the page opens it with tg.openLink() in a tap handler (Telegram only allows openLink in response to a user interaction), and your server credits points when the offerwall sends a signed postback. If you also sell the same points, the Stars rule applies to those sales.

We cover the full integration, with code, in Add an offerwall to a Telegram Mini App, so we will not repeat it here. For the parts around it, see what to check before you choose an offerwall for a Mini App, the Node.js postback handler, postback security, and, if your Mini App is a game, how to build a Telegram tap-to-earn game. When the app works, how to grow a Telegram Mini App is the next step.

Where Sharklio fits in a Telegram Mini App

Sharklio is a pay-per-result affiliate network. A Mini App needs no SDK: your server signs one offerwall link per user, and Sharklio sends a signed server-to-server postback for every credit and every reversal, so points land in the same balance as the daily check-in above. Each app sets its own currency name and icon, conversion rate, user split, and wall design with a live preview. Offers depend on the user’s country and device, users on VPNs, proxies, Tor, or data center networks are blocked from the wall, and end users must be at least 16. Every publisher application is reviewed by hand, and once your account is approved, each app you add is live right away. See how the wall works, or get in touch with questions.

Frequently asked questions

What language is a Telegram Mini App written in?

The front end is HTML, CSS, and JavaScript, in plain code or any framework such as React or Vue. The backend can be any language, because the initData check only needs HMAC-SHA256.

How do I validate Telegram Mini App initData?

Send Telegram.WebApp.initData to your server, remove hash, sort the other fields as key=value lines joined with a line feed, and compute HMAC-SHA256 of that string with a key that is the HMAC-SHA256 of your bot token under the key WebAppData. The hex result must equal hash, and auth_date must be recent.

What is the difference between initData and initDataUnsafe?

initData is the raw signed string meant for validation on your server. initDataUnsafe is the same data parsed into an object in the browser, and Telegram says it should not be trusted.

Do I need a server for a Telegram Mini App?

A purely static page needs only HTTPS hosting, but anything that must be trusted, such as accounts, points, or payments, needs a backend. That can be your own server or Telegram Serverless endpoints.

Can I test a Telegram Mini App without HTTPS?

Yes, in Telegram’s test environment, which allows plain HTTP links. In production, the Mini App URL must be HTTPS.

How do I open a Mini App from a link?

Set a Main Mini App in BotFather and share https://t.me/botusername?startapp. Add a value, such as ?startapp=summer, and it reaches your app as start_param.