Product Catalog in Telegram Mini App with Vue: Pinia, localStorage and payment via Invoice

Typical shop in Telegram Mini App: catalog → cart → invoice. Frontend on Vue 3 holds UI and cart, backend on PHP validates initData, recalculates the total and calls sendInvoice. Below is a working flow without unnecessary abstraction: composable/store, order formatting and PHP invoice sketch.

\n\n

Full flow

\n
    \n
  1. User opens Mini App (t.me/Bot/App or Menu Button).
  2. \n
  3. Vue calls Telegram.WebApp.ready(), requests the catalog with header X-Telegram-Init-Data.
  4. \n
  5. Backend validates HMAC, returns items (id, title, price in cents/minimal units).
  6. \n
  7. Client puts positions into store (Pinia or simple reactive + localStorage).
  8. \n
  9. “Pay” → POST /api/checkout with position ids and quantities (not the final sum with client as truth).
  10. \n
  11. Server recalculates the total, creates an order, calls sendInvoice, returns a link or immediately opens the invoice via the bot.
  12. \n
  13. Client: Telegram.WebApp.openInvoice(url) (or waits for a message-invoice in chat).
  14. \n
  15. Bot handles pre_checkout_query and successful_payment.
  16. \n
\n\n

Category: request with initData

\n
// api/client.js
export async function fetchCatalog() {
const initData = window.Telegram.WebApp.initData
const res = await fetch('/api/catalog', {
method: 'GET',
headers: { 'X-Telegram-Init-Data': initData },
})
if (!res.ok) throw new Error('catalog ' + res.status)
return res.json() // [{ id, title, price, currency, photoUrl }]
}

export async function checkout(items) {
// items: [{ id, qty }] — без client-side total как источника правды
const res = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telegram-Init-Data': window.Telegram.WebApp.initData,
},
body: JSON.stringify({ items }),
})
if (!res.ok) throw new Error('checkout ' + res.status)
return res.json() // { invoiceUrl } или { ok: true } если счёт ушёл в чат
}
\n\n

Cart store (Pinia sketch)

\n
// stores/cart.js
import { defineStore } from 'pinia'

const STORAGE_KEY = 'tma_cart_v1'

export const useCartStore = defineStore('cart', {
state: () => ({
lines: [], // { id, title, price, qty }
}),
getters: {
total() {
return this.lines.reduce((s, l) => s + l.price * l.qty, 0)
},
count() {
return this.lines.reduce((s, l) => s + l.qty, 0)
},
},
actions: {
load() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) this.lines = JSON.parse(raw)
} catch (_) {
this.lines = []
}
},
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.lines))
},
add(product) {
const row = this.lines.find((l) => l.id === product.id)
if (row) row.qty += 1
else this.lines.push({
id: product.id,
title: product.title,
price: product.price,
qty: 1,
})
this.persist()
},
setQty(id, qty) {
const row = this.lines.find((l) => l.id === id)
if (!row) return
if (qty <= 0) this.lines = this.lines.filter((l) => l.id !== id)
else row.qty = qty
this.persist()
},
clear() {
this.lines = []
this.persist()
},
},
})
\n

In main.js after app creation: useCartStore().load(). localStorage is convenient for UX, but the order total on the server is always recalculated from the DB by product id.

\n\n

Composable designs

\n
// composables/useCheckout.js
import { ref } from 'vue'
import { useCartStore } from '../stores/cart'
import { checkout } from '../api/client'

export function useCheckout() {
const loading = ref(false)
const error = ref('')
const cart = useCartStore()

async function pay() {
loading.value = true
error.value = ''
try {
const items = cart.lines.map((l) => ({ id: l.id, qty: l.qty }))
if (items.length === 0) throw new Error('Корзина пуста')

const result = await checkout(items)
const tg = window.Telegram.WebApp

if (result.invoiceUrl) {
tg.openInvoice(result.invoiceUrl, (status) => {
// status: 'paid' | 'cancelled' | 'failed' | 'pending'
if (status === 'paid') cart.clear()
})
} else {
// счёт отправили сообщением в чат — закрываем или показываем подсказку
tg.showAlert('Счёт отправлен в чат с ботом')
cart.clear()
}
} catch (e) {
error.value = e.message || 'Ошибка оплаты'
} finally {
loading.value = false
}
}

return { loading, error, pay }
}
\n

MainButton can be attached to the same pay:

\n
const tg = window.Telegram.WebApp
tg.MainButton.setText('Оплатить')
tg.MainButton.show()
tg.MainButton.onClick(() => pay())
\n\n

PHP: checkout + sendInvoice

\n

After HMAC validation of initData (see separate breakdown of WebApp) the server does not trust the price from the client:

\n
<?php

function tgApi(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
$ch = curl_init('https://api.telegram.org/bot' . $token . '/' . $method);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
curl_close($ch);
$decoded = json_decode($raw, true);
if (empty($decoded['ok'])) {
throw new RuntimeException($decoded['description'] ?? 'Telegram error');
}
return $decoded['result'];
}

/**
* $items — [{id, qty}] из Mini App
* цены берём только из своей БД
*/
function createInvoiceForCart(int $chatId, array $items): array
{
$prices = [];
$total = 0;
foreach ($items as $line) {
$product = findProduct((int) $line['id']); // ваша БД
if (!$product || !$product['active']) {
throw new RuntimeException('Product unavailable');
}
$qty = max(1, (int) $line['qty']);
$amount = (int) $product['price'] * $qty; // минимальные единицы (копейки)
$total += $amount;
$prices[] = [
'label' => $product['title'] . ' ×' . $qty,
'amount' => $amount,
];
}
if ($prices === []) {
throw new RuntimeException('Empty cart');
}

$payload = bin2hex(random_bytes(8)); // ≤ 128 байт, корреляция заказа
saveOrder([
'payload' => $payload,
'chat_id' => $chatId,
'total' => $total,
'status' => 'pending',
]);

// Обычный провайдер (не Stars): нужен provider_token от BotFather
$result = tgApi('sendInvoice', [
'chat_id' => $chatId,
'title' => 'Заказ в магазине',
'description' => 'Оплата заказа в Mini App',
'payload' => $payload,
'provider_token' => getenv('TELEGRAM_PROVIDER_TOKEN'),
'currency' => 'RUB',
'prices' => json_encode($prices, JSON_UNESCAPED_UNICODE),
]);

// Для openInvoice из Mini App удобнее createInvoiceLink
$link = tgApi('createInvoiceLink', [
'title' => 'Заказ в магазине',
'description' => 'Оплата заказа в Mini App',
'payload' => $payload,
'provider_token' => getenv('TELEGRAM_PROVIDER_TOKEN'),
'currency' => 'RUB',
'prices' => json_encode($prices, JSON_UNESCAPED_UNICODE),
]);

return [
'message' => $result,
'invoiceUrl' => is_string($link) ? $link : ($link['url'] ?? $link),
];
}
\n
\n

For Stars (currency=XTR) the field provider_token is not passed. For ruble/card providers the token is mandatory. Amounts in prices[].amount are whole minimal currency units.

\n
\n\n

After payment on bot side

\n
    \n
  • pre_checkout_query — quickly answer answerPreCheckoutQuery (about 10 seconds). Compare payload and order total with the database.
  • \n
  • successful_payment — mark the order as paid via invoice_payload, save telegram_payment_charge_id.
  • \n
  • Idempotency: the same update should not close an order twice.
  • \n
\n
<?php

function handlePreCheckout(array $query): void
{
$order = findOrderByPayload($query['invoice_payload'] ?? '');
$ok = $order
&& $order['status'] === 'pending'
&& (int) $order['total'] === (int) $query['total_amount'];

tgApi('answerPreCheckoutQuery', [
'pre_checkout_query_id' => $query['id'],
'ok' => $ok,
'error_message' => $ok ? null : 'Заказ недоступен',
]);
}
\n\n

Practical notes

\n
    \n
  • Do not take the final total from the frontend as absolute truth — only id and qty.
  • \n
  • payload of the invoice ≤ 128 bytes; there it stores an opaque order id.
  • \n
  • Cart stored in localStorage may disappear; critical drafts should be duplicated on the server after login via initData.
  • \n
  • openInvoice is available in current clients; fallback path is sendInvoice in personal chat.
  • \n
\n

If you need to combine the category + payment under a specific price list and provider, the scheme can be broken down at botservice.biz.

"}

New articles on Telegram

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