Telegram Mini App on Vue 3: integration of WebApp JS, stable viewport and initData validation on PHP

Telegram Mini App on Vue 3 — is a regular SPA inside the WebView client. The SDK provides a theme, viewport height, MainButton button, and the initData string. Below is a practical scheme: connecting the WebApp, safe area / viewport without \"jumps\", the difference between initData and initDataUnsafe, and why HMAC needs to be checked on the backend.

\n\n

Connecting the SDK and composable

\n

The Telegram script is included in index.html before the Vue bundle. This ensures the always-up-to-date client version, without a separate npm package:

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

The Composable wraps window.Telegram.WebApp, calls ready() / expand() and returns reactive fields:

\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

In the application root:

\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 and safe area

\n

The mobile WebView changes its height when the keyboard appears and panels are collapsed. 100vh here lies. Refer to viewportStableHeight and Telegram CSS variables.

\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

The viewportChanged event updates the height. For a fixed \"Submit\" button, the MainButton SDK or a sticky block taking safe area into account is better — otherwise content will go under the system bar.

\n
\n

If you need fullscreen mode, call tg.expand() immediately after ready(). On clients without expand() the app opens at compact height and \"jumps\" on the first scroll.

\n
\n\n

initData vs initDataUnsafe

\n

Telegram.WebApp.initData is a signed string (query string): user parameters plus the hash field. It must also be sent to the backend.

\n

Telegram.WebApp.initDataUnsafe is already a parsed JavaScript object. Useful for UI (\"Hello, {{first_name}}\"), but it is not proof that the request came from Telegram. Anyone can substitute someone else's user.id in their frontend and hit your API.

\n
    \n
  • UI, local hints — can read initDataUnsafe.
  • \n
  • Order, balance, personal data — only after verifying HMAC against raw initData on the server.
  • \n
\n\n

Request from the frontend

\n

Pass the string whole, without manual \"assembly\" of parameters:

\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 verification on PHP

\n

Algorithm from Telegram documentation: secret = HMAC-SHA256 of the bot token with key WebAppData; the verification string is a series of key=value pairs, sorted by key, without hash, joined by \\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

Without this check any client can forge an order \"on behalf of\" another user. The auth_date expiry limits replay of old strings.

\n\n

sendData — a separate trap

\n

tg.sendData() works only if the Mini App is opened with the keyboard button (reply keyboard). From the Menu Button, inline buttons, or direct links the method is useless. For orders and payments send data to your own API + the bot responds via Bot API.

\n\n

Brief checklist

\n
    \n
  1. SDK in index.html, composable with ready / expand / viewportChanged.
  2. \n
  3. Height and margins — via viewport/safe-area variables, not via \"raw\" 100vh.
  4. \n
  5. UI may look at initDataUnsafe; the server trusts only the HMAC from initData.
  6. \n
  7. Bot token only on the backend; there should be none on the frontend.
  8. \n
\n

Need help connecting the Vue Mini App + PHP validation for your bot — we can sketch a scheme on botservice.biz.

"}

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.