Telegram Mini App на Vue 3: інтеграція WebApp JS, стабільний viewport і валідація initData на PHP

Telegram Mini App на Vue 3 — це звичайний SPA всередині WebView клієнта. SDK надає тему, висоту viewport, кнопку MainButton і рядок initData. Нижче — практична схема: підключення WebApp, safe area / viewport без «прыжків», різниця між initData та initDataUnsafe, і чому потрібно перевіряти HMAC на бекенді.

\n\n

Підключення SDK та composable

\n

Скрипт Telegram підключають у index.html до бандла Vue. Так завжди актуальна версія клієнта, без окремого npm-пакету:

\n
<!-- index.html -->
<head>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
\n

Composable обертає window.Telegram.WebApp, викликає ready() / expand() і надавляє реактивні поля:

\n
// composables/useTelegram.js
import { reactive, readonly } from 'vue'

export function useTelegram() {
const tg = window.Telegram?.WebApp
if (!tg) {
throw new Error('Telegram.WebApp is not available')
}

const state = reactive({
viewportHeight: tg.viewportHeight,
viewportStableHeight: tg.viewportStableHeight,
isExpanded: tg.isExpanded,
colorScheme: tg.colorScheme,
})

tg.ready()
tg.expand()

tg.onEvent('viewportChanged', () => {
state.viewportHeight = tg.viewportHeight
state.viewportStableHeight = tg.viewportStableHeight
state.isExpanded = tg.isExpanded
document.documentElement.style.setProperty(
'--tg-viewport-stable-height',
`${tg.viewportStableHeight}px`
)
})

// первичная установка CSS-переменной
document.documentElement.style.setProperty(
'--tg-viewport-stable-height',
`${tg.viewportStableHeight}px`
)

return {
tg,
state: readonly(state),
initData: tg.initData,
initDataUnsafe: tg.initDataUnsafe,
}
}
\n

У корні прикладання:

\n
// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.mount('#app')
\n
// App.vue (script setup)
import { onMounted } from 'vue'
import { useTelegram } from './composables/useTelegram'

const { tg, state } = useTelegram()

onMounted(() => {
// тема из Telegram уже пробрасывается в CSS-переменные --tg-theme-*
document.body.style.backgroundColor = tg.backgroundColor || ''
})
\n\n

Viewport і safe area

\n

Мобільний WebView змінює висоту при клавіатурі та закритті панелей. 100vh тут вдає собі навіку. Орієнтуйтеся на viewportStableHeight та CSS-перемінні Telegram.

\n
/* styles.css */
html, body, #app {
margin: 0;
min-height: var(--tg-viewport-stable-height, 100vh);
}

.app-shell {
min-height: var(--tg-viewport-stable-height, 100vh);
padding-top: var(--tg-safe-area-inset-top, 0px);
padding-bottom: calc(
var(--tg-safe-area-inset-bottom, 0px) + var(--tg-content-safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
}

.checkout-bar {
position: sticky;
bottom: 0;
/* не перекрывать жестом «домой» и MainButton */
padding-bottom: max(12px, var(--tg-safe-area-inset-bottom, 0px));
}
\n

Собыття viewportChanged оновлює висоту. Для фіксованої кнопки «Оформить» краще використовувати SDK MainButton або sticky-блок з урахуванням safe area — інакше контент виїжджає під системну панель.

\n
\n

Якщо потрібен повноцінний екранний режим, викличте tg.expand() одразу після ready(). На частині клієнтів без expand() додаток відкривається у компактній висоті та «прыгає» при першому прокрутці.

\n
\n\n

initData проти initDataUnsafe

\n

Telegram.WebApp.initData — це подписна рядок (query string): параметри користувача плюс поле hash. Його й треба відправляти на бекенд.

\n

Telegram.WebApp.initDataUnsafe — вже розбірений об'єкт у JS. Винятково корисний для UI («Привіт, {{first_name}}»), але не є доказом, що запит прийшов від Telegram. Кожен може вставити свій user.id у власний фронтенд і постукатися у ваш API.

\n
    \n
  • UI, локальні підказки — можна читати initDataUnsafe.
  • \n
  • Заказ, баланс, особисті дані — лише після перевірки HMAC від сирого initData на сервері.
  • \n
\n\n

Запит із боку

\n

Передавайте рядок цілим, без ручної «складання» параметрів:

\n
// api.js
export async function apiPost(path, body) {
const tg = window.Telegram.WebApp
const res = await fetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telegram-Init-Data': tg.initData,
},
body: JSON.stringify(body),
})
if (!res.ok) {
throw new Error('API ' + res.status)
}
return res.json()
}
\n\n

Перевірка HMAC на PHP

\n

Алгоритм з документації Telegram: секрет = HMAC-SHA256 від токену бота з ключем WebAppData; рядок перевірки — пара key=value, відсортовані за ключем, без hash, з'єднані через \\n.

\n
<?php

/**
* @return array{ok:bool, user:?array, auth_date:?int, error?:string}
*/
function validateTelegramWebAppInitData(string $initData, string $botToken, int $maxAgeSec = 86400): array
{
parse_str($initData, $data);
if (!isset($data['hash']) || !is_string($data['hash'])) {
return ['ok' => false, 'user' => null, 'auth_date' => null, 'error' => 'no hash'];
}
$hash = $data['hash'];
unset($data['hash']);

ksort($data);
$pairs = [];
foreach ($data as $k => $v) {
$pairs[] = $k . '=' . $v;
}
$dataCheckString = implode("\n", $pairs);

$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
$calculated = hash_hmac('sha256', $dataCheckString, $secretKey);

if (!hash_equals($calculated, $hash)) {
return ['ok' => false, 'user' => null, 'auth_date' => null, 'error' => 'bad hash'];
}

$authDate = isset($data['auth_date']) ? (int) $data['auth_date'] : 0;
if ($authDate <= 0 || (time() - $authDate) > $maxAgeSec) {
return ['ok' => false, 'user' => null, 'auth_date' => $authDate, 'error' => 'expired'];
}

$user = null;
if (!empty($data['user'])) {
$user = json_decode($data['user'], true);
}

return ['ok' => true, 'user' => is_array($user) ? $user : null, 'auth_date' => $authDate];
}

// пример в контроллере
$initData = $_SERVER['HTTP_X_TELEGRAM_INIT_DATA'] ?? '';
$result = validateTelegramWebAppInitData($initData, getenv('TELEGRAM_BOT_TOKEN'));
if (!$result['ok']) {
http_response_code(401);
echo json_encode(['error' => $result['error']]);
exit;
}
$userId = (int) ($result['user']['id'] ?? 0);
\n

Без цієї перевірки будь-який клієнт може підробити замовлення «від імені» іншого користувача. Срок auth_date обмежує replay старих рядків.

\n\n

sendData — окрема пастка

\n

tg.sendData() працює лише якщо Mini App відкритий з кнопки клавіатури (reply keyboard). З Menu Button, inline-кнопки або пряму посилання метод безздатний. Для замовлень та платежів надішліть дані на свій API + бот відповідає через Bot API.

\n\n

Короткий чеклист

\n
    \n
  1. SDK у index.html, composable з ready / expand / viewportChanged.
  2. \n
  3. Висота і відступи — через viewport/safe-area перемінні, а не через «чистий» 100vh.
  4. \n
  5. UI може дивиться у initDataUnsafe; сервер довіряє лише HMAC від initData.
  6. \n
  7. Токен бота лише на бекенді; на фронте його бути не має.
  8. \n
\n

Потрібна допомога з зв'язком Vue Mini App + PHP-валидація під ваш бот — можна набрати схему на botservice.biz.

"}

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.