Building an e-commerce catalog inside a Telegram Mini App looks straightforward at first glance: render a grid of items in Vue 3, track selected items in local state, and call a payment gateway when the user clicks buy. However, embedded WebViews inside iOS and Android Telegram clients introduce strict constraints that ruin naive implementations.
If you rely purely on client-side state, user carts vanish every time Telegram reclaims memory or when the user toggles between chat threads. Worse, if your Vue frontend calculates the final order amount and sends that total directly to your bot backend or payment processor, malicious users can open Telegram Web Inspector, tamper with the Pinia store, and purchase high-ticket items for pennies.
In this tutorial, we will build a resilient product catalog in Vue 3 using Pinia for persisted local storage state, synchronize the interface with Telegram's native MainButton, and route checkout requests to a secure backend endpoint that verifies Telegram initData HMAC hashes and recalculates total costs from canonical database pricing.
Local Cart Management with Pinia and LocalStorage Synchronization
Telegram Mini Apps run inside embedded WebViews. On mobile devices, operating systems aggressively purge WebView memory when users temporarily switch apps to copy a promo code or answer a call. If your Pinia store relies exclusively on in-memory reactive state, returning to the Mini App results in an empty cart and a frustrated buyer.
We must design a Pinia store that automatically syncs every state change to localStorage. Furthermore, to prevent price-tampering attacks, the store must only keep track of product IDs and quantities—never trust client-side item prices for payment generation.
Here is a production-grade Pinia cart store (stores/cart.js) that handles item incrementing, persistent storage, and strict schema validation:
import { defineStore } from 'pinia';
const STORAGE_KEY = 'tg_app_cart_v1';
export const useCartStore = defineStore('cart', {
state: () => ({
items: {},
}),
getters: {
totalCount: (state) => {
return Object.values(state.items).reduce((sum, qty) => sum + qty, 0);
},
itemQuantities: (state) => state.items,
},
actions: {
initStore() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
// Ensure all quantities are positive integers
this.items = Object.entries(parsed).reduce((acc, [id, qty]) => {
const numericQty = parseInt(qty, 10);
if (!isNaN(numericQty) && numericQty > 0) {
acc[id] = numericQty;
}
return acc;
}, {});
}
}
} catch (err) {
console.error('Failed to restore cart state from localStorage:', err);
this.items = {};
}
},
addItem(productId) {
const id = String(productId);
if (!this.items[id]) {
this.items[id] = 1;
} else {
this.items[id] += 1;
}
this.persist();
},
removeItem(productId) {
const id = String(productId);
if (!this.items[id]) return;
if (this.items[id] > 1) {
this.items[id] -= 1;
} else {
delete this.items[id];
}
this.persist();
},
clearCart() {
this.items = {};
localStorage.removeItem(STORAGE_KEY);
},
persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.items));
} catch (err) {
console.error('Failed to persist cart state:', err);
}
}
}
});
Notice how initStore() explicitly validates data type integrity when parsing JSON. Storing corrupted or hand-modified values in localStorage could break application rendering or lead to invalid payload submissions during checkout. Calling initStore() during Vue app mounting ensures instant state hydration.
Rendering Catalog Items and Binding Telegram MainButton
Telegram Mini Apps provide native UI controls such as MainButton and BackButton. Instead of rendering a custom fixed button at the bottom of the DOM (which often overlaps with system gesture bars or native software keyboards), you should bind the cart state directly to window.Telegram.WebApp.MainButton.
A common mistake when using Telegram native components in Vue is failing to clean up global event listeners. Because Telegram.WebApp.MainButton.onClick registers event callbacks on a global SDK single instance, mounting and unmounting Vue catalog components without detaching handlers causes multiple duplicate checkout triggers.
The following Vue component (components/ProductCatalog.vue) renders a product grid, binds changes in Pinia cart count to MainButton, and cleans up all native handlers on unmount:
<template>
<div class="catalog-container">
<header class="catalog-header">
<h1>Store Catalog</h1>
</header>
<div class="product-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<img :src="product.image" :alt="product.title" class="product-image" />
<div class="product-details">
<h3>{{ product.title }}</h3>
<p class="product-price">${{ (product.priceCents / 100).toFixed(2) }}</p>
<div class="cart-controls">
<button
v-if="cartStore.items[product.id]"
@click="cartStore.removeItem(product.id)"
class="btn-decrement"
>-</button>
<span v-if="cartStore.items[product.id]" class="quantity-count">
{{ cartStore.items[product.id] }}
</span>
<button
@click="cartStore.addItem(product.id)"
class="btn-increment"
>Add</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, watch } from 'vue';
import { useCartStore } from '../stores/cart';
const props = defineProps({
products: {
type: Array,
required: true
}
});
const emit = defineEmits(['checkout-requested']);
const cartStore = useCartStore();
const updateTelegramMainButton = () => {
const tg = window.Telegram?.WebApp;
if (!tg || !tg.MainButton) return;
const count = cartStore.totalCount;
if (count > 0) {
tg.MainButton.setText(`VIEW CART (${count})`);
tg.MainButton.show();
tg.MainButton.enable();
} else {
tg.MainButton.hide();
}
};
const handleMainButtonClick = () => {
emit('checkout-requested', cartStore.items);
};
onMounted(() => {
cartStore.initStore();
updateTelegramMainButton();
const tg = window.Telegram?.WebApp;
if (tg?.MainButton) {
tg.MainButton.onClick(handleMainButtonClick);
}
});
watch(() => cartStore.totalCount, () => {
updateTelegramMainButton();
});
onUnmounted(() => {
const tg = window.Telegram?.WebApp;
if (tg?.MainButton) {
tg.MainButton.offClick(handleMainButtonClick);
tg.MainButton.hide();
}
});
</script>
This setup guarantees that whenever the user increments or decrements items in Vue state, the watcher reacts and updates the native Telegram button instantly. When navigating away from the view, offClick removes the listener so future clicks do not fire multiple times.
Generating Server-Validated Invoices via Telegram Bot API
When the user initiates a checkout request from the Mini App, the client app must post initData along with the list of selected product IDs and their requested quantities to a backend server. Do not trust prices passed from the frontend.
The server performs three mandatory checks: 1. Re-calculates the HMAC-SHA256 signature of initData using the Bot Token to verify authentication. 2. Verifies that auth_date is fresh (e.g., within 24 hours) to block replay attacks. 3. Looks up each product ID in the server database, multiplies authoritative backend prices by requested quantities, and calls Telegram's createInvoiceLink Bot API endpoint.
Here is a complete Node.js/Express controller (server/checkout.js) that safely generates native Telegram invoices:
import express from 'express';
import crypto from 'crypto';
import axios from 'axios';
const router = express.Router();
// Canonical server-side database
const PRODUCT_DATABASE = {
'prod_101': { title: 'Premium Espresso Beans', priceCents: 1500 },
'prod_102': { title: 'Cold Brew Concentrate', priceCents: 1200 },
'prod_103': { title: 'Ceramic Mug', priceCents: 1800 }
};
function verifyTelegramInitData(rawInitData, botToken) {
const urlParams = new URLSearchParams(rawInitData);
const hash = urlParams.get('hash');
if (!hash) return false;
urlParams.delete('hash');
const params = [];
for (const [key, value] of urlParams.entries()) {
params.push(`${key}=${value}`);
}
params.sort();
const dataCheckString = params.join('\n');
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(botToken)
.digest();
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
if (calculatedHash !== hash) return false;
const authDate = parseInt(urlParams.get('auth_date') || '0', 10);
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 86400) {
return false; // Replay attack protection (>24 hours old)
}
return true;
}
router.post('/api/create-checkout', async (req, res) => {
const { initData, cart } = req.body;
const BOT_TOKEN = process.env.BOT_TOKEN;
const PROVIDER_TOKEN = process.env.PAYMENT_PROVIDER_TOKEN;
if (!initData || !verifyTelegramInitData(initData, BOT_TOKEN)) {
return res.status(401).json({ error: 'Unauthorized: Invalid initData HMAC signature' });
}
if (!cart || typeof cart !== 'object' || Object.keys(cart).length === 0) {
return res.status(400).json({ error: 'Cart payload is empty or invalid' });
}
const labeledPrices = [];
let totalAmount = 0;
for (const [productId, quantity] of Object.entries(cart)) {
const product = PRODUCT_DATABASE[productId];
const qty = parseInt(quantity, 10);
if (!product || isNaN(qty) || qty <= 0) {
return res.status(400).json({ error: `Invalid product or quantity for ID: ${productId}` });
}
const lineTotal = product.priceCents * qty;
totalAmount += lineTotal;
labeledPrices.push({
label: `${product.title} x${qty}`,
amount: lineTotal
});
}
try {
const response = await axios.post(`https://api.telegram.org/bot${BOT_TOKEN}/createInvoiceLink`, {
title: 'Store Checkout',
description: 'Order payment from Telegram Mini App',
payload: JSON.stringify({ orderId: `ord_${Date.now()}`, timestamp: Date.now() }),
provider_token: PROVIDER_TOKEN,
currency: 'USD',
prices: labeledPrices
});
if (response.data.ok) {
return res.json({ invoiceUrl: response.data.result });
} else {
console.error('Telegram Bot API Error:', response.data);
return res.status(500).json({ error: 'Failed to generate Telegram invoice' });
}
} catch (error) {
console.error('Checkout Endpoint Exception:', error.message);
return res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
By building invoice lines exclusively from PRODUCT_DATABASE, backend price tampering is structurally impossible. Even if an attacker inspects local memory and injects arbitrary JSON values, your server discards unrecognized fields and uses database price metrics.
Handling WebApp Payment Callbacks and MainButton Edge Cases
Once the backend produces a valid invoice link via createInvoiceLink, the Vue application must trigger the native payment sheet using Telegram.WebApp.openInvoice(). Alternatively, if you operate in a region where native Telegram payment providers are unavailable, you can open an external checkout URL (such as Stripe Checkout or a custom payment gateway) using Telegram.WebApp.openLink().
Here is how you handle the checkout flow inside your root view or orchestrator component (App.vue):
<template>
<main class="app-main">
<ProductCatalog
:products="availableProducts"
@checkout-requested="handleCheckout"
/>
</main>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import ProductCatalog from './components/ProductCatalog.vue';
import { useCartStore } from './stores/cart';
const cartStore = useCartStore();
const availableProducts = ref([
{ id: 'prod_101', title: 'Premium Espresso Beans', priceCents: 1500, image: '/images/coffee.jpg' },
{ id: 'prod_102', title: 'Cold Brew Concentrate', priceCents: 1200, image: '/images/coldbrew.jpg' },
{ id: 'prod_103', title: 'Ceramic Mug', priceCents: 1800, image: '/images/mug.jpg' }
]);
onMounted(() => {
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
}
});
const handleCheckout = async (cartItems) => {
const tg = window.Telegram?.WebApp;
if (!tg) return;
tg.MainButton.showProgress(false);
try {
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
initData: tg.initData,
cart: cartItems
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Checkout initiation failed');
}
// Open Native Invoice Popup
tg.openInvoice(data.invoiceUrl, (status) => {
tg.MainButton.hideProgress();
if (status === 'paid') {
cartStore.clearCart();
tg.showAlert('Payment successful! Your order is being processed.');
} else if (status === 'failed') {
tg.showAlert('Payment failed. Please try again.');
} else if (status === 'cancelled') {
// User closed payment window; keep cart intact
console.log('User cancelled payment');
}
});
} catch (err) {
tg.MainButton.hideProgress();
tg.showAlert(`Error: ${err.message}`);
}
};
</script>
Production Notes
1. Viewport Heights and Scroll Lock: Always invoke Telegram.WebApp.ready() and Telegram.WebApp.expand() as early as possible during startup. If you do not execute expand(), mobile clients show the WebApp inside a collapsed bottom sheet. Dragging catalog items can cause vertical gesture conflicts that close the Mini App instead of scrolling the catalog grid. 2. Race Conditions during App Launch: Never call MainButton.show() before Telegram.WebApp.ready() has completed execution on low-spec Android hardware. Executing SDK UI modifications during initial web view load can cause visual flickering or missing button layers. 3. Replay Attack Expiration: Ensure your backend checks auth_date from initData and enforces a strict maximum cutoff window (e.g., 24 hours). Attackers can intercept old, valid initData parameters and try to reuse them on your endpoints to spoof session contexts. 4. Idempotency in Payment Handlers: Payment confirmation should never rely solely on the client-side openInvoice callback string paid. Always set up a webhook handler on your backend for the successful_payment update payload sent directly from Telegram servers to your bot before updating order status in your database.
If you are building custom commercial checkout flows, handling multi-currency stores, or setting up dedicated payment backend integrations, BotCreator — studio that ships Telegram bots / Mini Apps can help you build and deploy secure, production-ready Mini Apps.