Catalog and Cart in a Telegram Mini App with Vue 3: Pinia, createInvoiceLink, and External Payments

Developing a full-featured интернет-магазин inside a Telegram Mini App requires solving three key tasks: building a fast catalog UI, reliably persisting the cart state on the client side, and securely calculating the total amount on the backend. Errors at any of these stages lead to price discrepancies, lost items hidden from the user, or payment processing vulnerabilities.

In this guide, we will cover building the client side using Vue 3 with the Pinia state manager, saving data to localStorage, and writing a PHP backend to validate initData and create payment invoices via Telegram Bot API and external acquiring services.

Workflow: From Product Selection to Payment Processing

A typical user interaction scenario with an e-commerce interface inside a WebApp includes the following steps:

  1. Browsing the catalog and building the cart: The user adds items and changes quantities. The state is instantly saved in Pinia and duplicated to localStorage to protect against accidental tab reloads.
  2. Checkout initialization: Upon clicking the payment button, the frontend sends the cart contents and the raw initData string to the server. You must not send the final price from the client — the server is obligated to recalculate the cost of each item using its own database.
  3. Validation and order creation: The server verifies the authenticity of initData using HMAC-SHA-256, generates a unique lead_id, and creates a record with a pending status.
  4. Payment gateway selection: The server either calls the Bot API method createInvoiceLink and returns a native Telegram Invoice link, or generates a URL for an external payment system (Yookassa, Tinkoff, Stripe).
  5. Transaction completion: Upon successful payment, the bot sends a notification to the user, and the Mini App clears the local cart and closes the WebApp window.

Cart Store in Vue 3 and Pinia with localStorage Support

To manage the state of cart items, we use Pinia. We need to implement adding, changing quantities, removing items, as well as calculating the total amount and item count. Automatic synchronization with localStorage guarantees the preservation of user data.

import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
state: () => ({
items: JSON.parse(localStorage.getItem('app_cart') || '[]')
}),
getters: {
totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0)
},
actions: {
addItem(product) {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
this.items.push({
id: product.id,
title: product.title,
price: product.price,
quantity: 1
});
}
this.saveToStorage();
},
removeItem(productId) {
this.items = this.items.filter(i => i.id !== productId);
this.saveToStorage();
},
updateQuantity(productId, quantity) {
const item = this.items.find(i => i.id === productId);
if (item) {
item.quantity = Math.max(1, quantity);
this.saveToStorage();
}
},
clearCart() {
this.items = [];
this.saveToStorage();
},
saveToStorage() {
localStorage.setItem('app_cart', JSON.stringify(this.items));
}
}
});

Sending an Order from Vue to Telegram WebApp SDK

On the client side, we read Telegram.WebApp.initData, send the data to the PHP backend, and process the response. Depending on the payment type, we call the openInvoice method for native invoices or openLink for third-party acquiring services.

import { useCartStore } from './cartStore';

async function processCheckout() {
const cart = useCartStore();
const tg = window.Telegram?.WebApp;

if (!tg || !tg.initData) {
alert('Пожалуйста, откройте приложение внутри Telegram');
return;
}

if (cart.items.length === 0) {
tg.showAlert('Ваша корзина пуста');
return;
}

tg.MainButton.showProgress();

try {
const response = await fetch('/api/checkout.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
initData: tg.initData,
cart: cart.items.map(item => ({ id: item.id, quantity: item.quantity }))
})
});

const result = await response.json();

if (!response.ok || !result.ok) {
throw new Error(result.error || 'Ошибка при формировании заказа');
}

if (result.pay_type === 'telegram' && result.invoice_url) {
// Нативный счет Telegram Payments
tg.openInvoice(result.invoice_url, (status) => {
if (status === 'paid') {
cart.clearCart();
tg.close();
} else if (status === 'failed') {
tg.showAlert('Оплата не прошла или была отменена');
}
});
} else if (result.pay_type === 'external' && result.payment_url) {
// Внешний платежный шлюз
tg.openLink(result.payment_url);
}
} catch (err) {
tg.showAlert(err.message || 'Сетевая ошибка');
} finally {
tg.MainButton.hideProgress();
}
}

PHP Backend: initData Validation, Price Recalculation, and createInvoiceLink

On the backend, you must never trust prices passed from the frontend. The server must retrieve product IDs from the database, calculate the total amount, validate the Telegram HMAC signature, and call the createInvoiceLink method via cURL.

<?php
declare(strict_types=1);

header('Content-Type: application/json');

$botToken = getenv('BOT_TOKEN') ?: '';
if (empty($botToken)) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Server configuration error']);
exit;
}

$rawInput = file_get_contents('php://input');
$payload = json_decode($rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE || empty($payload['initData']) || empty($payload['cart'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid payload']);
exit;
}

// 1. Валидация Telegram initData
if (!validateTelegramInitData($payload['initData'], $botToken)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Invalid initData signature']);
exit;
}

// 2. Имитация базы данных товаров и пересчет суммы
$catalogDb = [
1 => ['name' => 'Футболка Dev', 'price' => 150000], // Цена в копейках/сантимах (1500.00 RUB)
2 => ['name' => 'Худи VueJS', 'price' => 350000],
];

$totalAmount = 0;
$labeledPrices = [];

foreach ($payload['cart'] as $item) {
$id = (int)($item['id'] ?? 0);
$qty = (int)($item['quantity'] ?? 0);

if (isset($catalogDb[$id]) && $qty > 0) {
$itemPrice = $catalogDb[$id]['price'];
$totalAmount += $itemPrice * $qty;
$labeledPrices[] = [
'label' => $catalogDb[$id]['name'] . " x{$qty}",
'amount' => $itemPrice * $qty
];
}
}

if ($totalAmount === 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Cart contains invalid items']);
exit;
}

// 3. Генерация уникального идентификатора заказа (lead_id)
$leadId = bin2hex(random_bytes(7)); // 14 hex символов

// 4. Запрос к Telegram Bot API для получения createInvoiceLink
$providerToken = getenv('PAYMENT_PROVIDER_TOKEN') ?: ''; // Пусто для Telegram Stars
$invoiceData = [
'title' => "Заказ #{$leadId}",
'description' => 'Оплата заказа в магазине Mini App',
'payload' => $leadId,
'provider_token' => $providerToken,
'currency' => 'RUB',
'prices' => json_encode($labeledPrices)
];

$ch = curl_init('https://api.telegram.org/bot' . $botToken . '/createInvoiceLink');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query($invoiceData),
CURLOPT_TIMEOUT => 10,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);

if ($curlErr || $httpCode !== 200) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Failed to reach Telegram API']);
exit;
}

$resData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($resData['ok'])) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $resData['description'] ?? 'Telegram API error']);
exit;
}

echo json_encode([
'ok' => true,
'pay_type' => 'telegram',
'invoice_url' => $resData['result'],
'lead_id' => $leadId
]);

function validateTelegramInitData(string $initData, string $botToken): bool {
parse_str($initData, $data);
if (!isset($data['hash'])) {
return false;
}

$hash = $data['hash'];
unset($data['hash']);

$dataCheckArray = [];
foreach ($data as $key => $val) {
$dataCheckArray[] = $key . '=' . $val;
}
sort($dataCheckArray);
$dataCheckString = implode("
", $dataCheckArray);

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

return hash_equals($calculatedHash, $hash);
}

Alternative: Using an External Payment Gateway

If products require specific acquiring or fiscal receipts under 54-FZ, native Telegram invoices can be replaced by directly generating a link to a payment system (such as Yookassa or Sberbank).

In this case, the backend logic looks as follows:

  • The server also validates initData and calculates the final order amount using the database.
  • A record is created in the local database with a pending status and a unique lead_id.
  • The server sends a REST request to the payment system API, passing return_url and webhook_url to record the payment processing.
  • The generated URL is returned to the Mini App and opened via the tg.openLink(url) method.

Important: When opening external redirects inside a Mini App, always pass the order tracking parameter (lead_id) in the payment metadata so that the payment gateway's webhook handler can unambiguously identify the paid receipt.

Common Mistakes When Designing E-Commerce in Mini Apps

  • Trusting prices from the frontend: Passing the calculated purchase amount (e.g., totalAmount) from Vue to PHP allows an attacker to tamper with the request body and buy an item for 1 ruble. Send only an array of IDs and their quantities.
  • Lack of initData expiration checks: The auth_date parameter from initData must be validated on the backend. If more than 24 hours have passed since signing, the request should be rejected.
  • Using $SESSION in the API: The REST API for a Mini App should operate statelessly without server-side sessions. All requests are authenticated strictly via initData signature validation.
  • Ignoring network errors with cURL: Always check the Telegram API response HTTP status code, the result of curl_error(), and the json_last_error() field.

If you require professional integration of complex catalogs, inventory systems, and payment solutions into Telegram Mini Apps, the specialists at BotCreator will help implement a turn-key project.

New articles on Telegram

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