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 нужно проверять на бэкенде.

Подключение SDK и composable

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

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

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

// 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,
}
}

В корне приложения:

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

const app = createApp(App)
app.mount('#app')
// 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 || ''
})

Viewport и safe area

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

/* 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));
}

Событие viewportChanged обновляет высоту. Для фиксированной кнопки «Оформить» лучше MainButton SDK или sticky-блок с учётом safe area — иначе контент уедет под системную панель.

Если нужен полноэкранный режим, вызывайте tg.expand() сразу после ready(). На части клиентов без expand() приложение открывается в компактной высоте и «прыгает» при первом скролле.

initData vs initDataUnsafe

Telegram.WebApp.initData — это подписьная строка (query string): параметры пользователя плюс поле hash. Её и нужно слать на бэкенд.

Telegram.WebApp.initDataUnsafe — уже разобранный объект в JS. Удобен для UI («Привет, {{first_name}}»), но не является доказательством, что запрос пришёл от Telegram. Любой может подставить чужой user.id в свой фронт и постучаться в ваш API.

  • UI, локальные подсказки — можно читать initDataUnsafe.
  • Заказ, баланс, персональные данные — только после проверки HMAC от сырой initData на сервере.

Запрос с фронта

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

// 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()
}

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

Алгоритм из документации Telegram: секрет = HMAC-SHA256 от токена бота с ключом WebAppData; строка проверки — пары key=value, отсортированные по ключу, без hash, соединённые через \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);

Без этой проверки любой клиент может подделать заказ «от имени» другого пользователя. Срок auth_date ограничивает replay старых строк.

sendData — отдельная ловушка

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

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

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

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

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.