Типовой магазин в Telegram Mini App: каталог → корзина → счёт. Фронт на Vue 3 держит UI и корзину, бэкенд на PHP проверяет initData, считает сумму заново и вызывает sendInvoice. Ниже — рабочий контур без лишней абстракции: composable/store, оформление заказа и PHP-скетч инвойса.
Поток целиком
- Пользователь открывает Mini App (
t.me/Bot/Appили Menu Button). - Vue вызывает
Telegram.WebApp.ready(), запрашивает каталог с заголовкомX-Telegram-Init-Data. - Бэкенд валидирует HMAC, отдаёт товары (id, title, price в копейках/минимальных единицах).
- Клиент кладёт позиции в store (Pinia или простой reactive + localStorage).
- «Оплатить» → POST
/api/checkoutс id позиций и количеством (не с итоговой суммой с клиента как истиной). - Сервер пересчитывает сумму, создаёт заказ, вызывает
sendInvoice, возвращает ссылку или сразу открывает инвойс через бота. - Клиент:
Telegram.WebApp.openInvoice(url)(или ждёт сообщение-счёт в чате). - Бот обрабатывает
pre_checkout_queryиsuccessful_payment.
Каталог: запрос с initData
// 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 } если счёт ушёл в чат
}
Store корзины (Pinia-скетч)
// 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()
},
},
})
В main.js после создания приложения: useCartStore().load(). localStorage удобен для UX, но сумма заказа на сервере всегда пересчитывается из БД по id товаров.
Composable оформления
// 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 }
}
MainButton можно повесить на тот же pay:
const tg = window.Telegram.WebApp
tg.MainButton.setText('Оплатить')
tg.MainButton.show()
tg.MainButton.onClick(() => pay())
PHP: checkout + sendInvoice
После HMAC-проверки initData (см. отдельный разбор WebApp) сервер не доверяет цене с клиента:
<?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),
];
}
Для оплаты Stars (
currency=XTR) полеprovider_tokenне передают. Для рублёвого/карточного провайдера токен обязателен. Суммы вprices[].amount— целые минимальные единицы валюты.
После оплаты на стороне бота
- pre_checkout_query — быстро ответить
answerPreCheckoutQuery(порядка 10 секунд). Сверить payload и сумму с заказом в БД. - successful_payment — пометить заказ оплаченным по
invoice_payload, сохранитьtelegram_payment_charge_id. - Идемпотентность: один и тот же update не должен дважды «закрывать» заказ.
<?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 : 'Заказ недоступен',
]);
}
Практические замечания
- Не принимайте итоговую сумму с фронта как истину — только id и qty.
payloadинвойса ≤ 128 байт; туда кладут непрозрачный id заказа.- localStorage корзины может исчезнуть; критичные черновики дублируйте на сервере после логина по initData.
openInvoiceдоступен в актуальных клиентах; запасной путь —sendInvoiceв личку.
Если нужно собрать каталог + оплату под конкретный прайс и провайдера, схему можно разобрать на botservice.biz.