Как сделать Telegram Mini App со Stripe-платежами: продакшн-гайд
Пошаговая архитектура и код: валидация initData, Stripe Payment Intents, webhooks, iOS-правила, безопасность и продакшн-подводные камни.

Ошибки в платежах в Telegram Mini App обходятся дорого: бан мерчанта, откаты релизов, замороженные выплаты, конфликт с правилами iOS. Ниже — практичный разбор, как запустить Stripe-оплату в Mini App так, чтобы это пережило продакшн и аудит комплаенса.
Если вам нужен краткий ответ: Stripe в Telegram Mini App лучше использовать для физических товаров и сервисов. Для цифровых товаров на iOS — закладывайте фолбэк на Telegram Stars или внешний браузерный чек-аут. Бэкенд обязан валидировать initData, хранить заказы в базе, создавать PaymentIntent на сервере, подтверждать оплату через вебхуки Stripe и не доверять клиенту ни цену, ни статус.
Когда Stripe уместен в Telegram Mini App
Прежде чем писать код, проверьте совместимость с правилами платформ:
- Физические товары/услуги (доставка, бронирования, офлайн-сервисы): Stripe встраивается в WebApp без ограничений. На iOS разрешено.
- Цифровые товары/контент (подписки, скины, доступ к функциям): на iOS может потребоваться использование Telegram Stars или обход через внешний браузер. Игнорирование правил грозит отклонением приложения/бота и блокировкой платежей. На Android и десктопе Stripe обычно допустим.
Из практики: делайте платежную стратегию с ветвлением по платформе. Mini App получает platform из Telegram WebApp API (по косвенным признакам — user agent, наличие Apple Pay, etc.), а сервер решает — Stripe/Stars/внешний линк. Один кодовый путь для всех устройств редко проходит ревью ровно.
Архитектура: из чего состоит безопасный платежный поток
Минимально жизнеспособная схема для Stripe в Telegram Mini App:
- Клиент: Telegram WebApp (JS + Stripe.js)
- Сервер: Node.js/TypeScript (Fastify/Express)
- База данных: Postgres (заказы, пользователи, транзакции)
- Кэш/сессии/блокировки: Redis
- Платежи: Stripe Payment Intents + Webhooks
- Очередь фоновых задач (по желанию): BullMQ / RabbitMQ (обработка уведомлений, ретраи)
- CDN/фронт: Cloudflare (статические ресурсы), Nginx/Ingress
Поток событий:
- Пользователь открывает Mini App. Telegram кладёт
initDataв WebApp. - Клиент отправляет
initDataна сервер для валидации и получает серверную сессию (JWT). - Пользователь выбирает товар/план. Клиент отправляет на сервер
product_id. - Сервер рассчитывает итоговую цену (валюта, налоги, скидки), создает заказ в БД (статус
pending) и PaymentIntent в Stripe. - Сервер возвращает
client_secretPaymentIntent. - Клиент инициализирует Stripe Elements и вызывает
confirmPayment(поддержка 3DS/SCA). - Stripe отправляет вебхук
payment_intent.succeeded/payment_intent.payment_failedна сервер. - Сервер атомарно обновляет заказ в БД, выдает доступ/формирует отгрузку, отправляет пользователю сообщение через Bot API.
Примечание: подтверждение статуса — по вебхуку. Не доверяйте фронту даже при успешном confirmPayment. Вебвью может закрыться, сеть может отвалиться, а вы останетесь в «полусделке».
Валидация Telegram initData на сервере
Идентифицировать пользователя в Mini App нужно через initData. Это подпись Telegram, которую вы проверяете на бэкенде по токену бота. Никогда не считайте user.id валидным без этой проверки.
Пример на Node.js/TypeScript (Fastify/Express не принципиально):
import crypto from 'crypto';
function validateTelegramInitData(initData: string, botToken: string): { ok: boolean; data?: URLSearchParams } {
const secret = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
const url = new URLSearchParams(initData);
const hash = url.get('hash');
url.delete('hash');
const dataCheckString = [...url.entries()]
.map(([k, v]) => `${k}=${v}`)
.sort()
.join('\n');
const signature = crypto.createHmac('sha256', secret).update(dataCheckString).digest('hex');
const ok = signature === hash;
return { ok, data: ok ? url : undefined };
}
Типовой серверный эндпоинт с выдачей сессии (JWT) после валидации:
import jwt from 'jsonwebtoken';
import express from 'express';
const app = express();
app.use(express.json());
app.post('/auth/telegram', (req, res) => {
const { initData } = req.body;
const { ok, data } = validateTelegramInitData(initData, process.env.BOT_TOKEN!);
if (!ok) return res.status(401).json({ error: 'Invalid Telegram init data' });
const userJson = data!.get('user');
const user = JSON.parse(userJson!); // { id, first_name, ... }
// Создайте/обновите пользователя в БД. Привяжите к telegram_user_id.
const token = jwt.sign(
{ sub: `tg:${user.id}`, tg: { id: user.id, username: user.username } },
process.env.JWT_SECRET!,
{ expiresIn: '12h' }
);
res.json({ token });
});
Зачем это делать сразу? Потому что дальше вы будете доверять только своему JWT и внутренним ID. Любая цена, скидка и права доступа вычисляются на сервере по вашему стору, а не по полям из WebApp.
Создание и подтверждение платежа Stripe (Payment Intents)
Сервер: создаём заказ и PaymentIntent. Ключевые моменты — атомарность, идемпотентность, минимальные единицы валюты (cents/kopeks).
import Stripe from 'stripe';
import { v4 as uuid } from 'uuid';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
app.post('/orders', async (req, res) => {
const { product_id } = req.body;
const userId = req.user.sub; // из JWT мидлвари
// 1) Найти продукт в БД и рассчитать цену
const product = await db.products.findById(product_id);
if (!product) return res.status(404).json({ error: 'Product not found' });
const currency = 'usd';
const amount = Math.round(product.price_usd * 100); // в центах
// 2) Создать заказ (pending)
const orderId = uuid();
await db.orders.insert({ id: orderId, user_id: userId, product_id, amount, currency, status: 'pending' });
// 3) Создать PaymentIntent (идемпотентный ключ на случай повторов)
const idemKey = `order:${orderId}`;
const pi = await stripe.paymentIntents.create({
amount,
currency,
metadata: { orderId, userId },
automatic_payment_methods: { enabled: true }
}, { idempotencyKey: idemKey });
// 4) Сохранить client_secret к заказу (опционально)
await db.orders.update(orderId, { payment_intent_id: pi.id });
res.json({ orderId, clientSecret: pi.client_secret });
});
Клиент: Stripe Elements внутри Telegram WebApp. Да, https://js.stripe.com/v3/ работает во встроенном WebView. Учтите тему (light/dark) из Telegram.WebApp.colorScheme.
<script src="https://js.stripe.com/v3/"></script>
<script>
const tg = window.Telegram.WebApp;
tg.ready();
async function pay(orderId, clientSecret) {
const stripe = Stripe(window.STRIPE_PUBLISHABLE_KEY);
const elements = stripe.elements({ appearance: { theme: tg.colorScheme === 'dark' ? 'night' : 'stripe' } });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: { return_url: window.location.origin + '/thanks?order=' + orderId }
});
if (error) {
// Покажите ошибку пользователю, логируйте
console.error(error.message);
}
}
</script>
<div id="payment-element"></div>
Webhooks Stripe: единый источник истины по статусам. Не забудьте проверять подпись вебхука и обрабатывать ретраи идемпотентно.
import bodyParser from 'body-parser';
app.post('/stripe/webhook', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
try {
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent;
const orderId = pi.metadata.orderId;
await db.withTransaction(async (tx) => {
const order = await tx.orders.findByIdForUpdate(orderId);
if (!order || order.status === 'paid') return; // идемпотентность
await tx.orders.update(orderId, { status: 'paid', paid_at: new Date() });
// Выдать доступ/запустить отгрузку
});
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
const orderId = pi.metadata.orderId;
await db.orders.update(orderId, { status: 'failed', failure_code: pi.last_payment_error?.code });
break;
}
}
} catch (e) {
console.error('Webhook handling error', e);
return res.status(500).end();
}
res.json({ received: true });
});
Куда девать подтверждение пользователю? Минимум — отправить sendMessage от бота после статуса paid. Можно также держать в Mini App SSE/WebSocket для мгновенного обновления UI.
Осторожно с 3DS/SCA: Stripe сам вызовет челендж. Во встроенном WebView это работает, но иногда требует allow-popups и корректной истории переходов. Если часть iOS-устройств не проходит 3DS в WebView — откатывайтесь на return_url и отображайте страницу статуса вне Mini App (в SafariViewController), а затем вернитесь в WebApp через startapp-параметры.
Выбор платежной стратегии внутри Telegram
Полезно иметь сравнительную таблицу перед реализацией:
| Стратегия | Что это | Плюсы | Минусы | iOS-совместимость | Комиссии/выплаты |
|---|---|---|---|---|---|
| Stripe внутри WebApp | Payment Intents + Elements | Богатые методы оплаты (карта, Apple Pay/Google Pay, локальные), контроль UX, быстрые выплаты | Политики для цифровых товаров на iOS, настройка 3DS в WebView, вебхуки | Физические — ок. Цифровые — риски, нужен фолбэк | Комиссия Stripe + чарджбеки, быстрые пэй-ауты |
| Telegram Stars | Встроенная валюта для цифровых товаров | Нативно для iOS, не спорит с правилами, простой UX | Ограниченная применимость (цифровые), конверсия в валюту/комиссии, экосистема молода | Отличная | Комиссии экосистемы Stars, нюансы вывода |
| Внешний чек-аут (открыть в браузере) | Открыть ссылку на ваш хостед-чекаут | Обход ограничения WebView/3DS, меньше багов | Потеря части трафика при выходе из Telegram, больше трения | Обычно ок, если не цифровой товар в обход IAP | Зависит от провайдера, выплаты по провайдеру |
Нередко продакшн-конфигурация выглядит так: Stripe как основной, Stars как фолбэк для iOS-цифровых, внешний чек-аут как аварийный путь при ошибках WebView/3DS. Звучит избыточно, но дешевле, чем неделя поддержки из-за «платежи не проходят у 10% пользователей на iPhone 12».
UX и интеграция с Telegram WebApp API
- Тема/цвета:
Telegram.WebApp.colorScheme,themeParams. Подгоняйте Stripe Elements под тёмную тему, иначе диссонанс режет глаз. - Высота и клавиатура: используйте
Telegram.WebApp.expand()и следите за ресайзом при фокусе на полях. - Back-навигатор: подпишитесь на
Telegram.WebApp.onEvent('backButtonClicked', ...). - Оплата: избегайте модалок перед
confirmPayment— 3DS может открыть оверлей; лишние перекрытия иногда ломают фокус. - Локали/валюты: передавайте локаль в Elements (например,
locale: 'ru'). Поддерживайте ISO-валюты и точность в минорных единицах.
Что ломается в продакшене
- Дубли заказов при ретраях: используйте идемпотентные ключи (
idempotencyKey) иSELECT ... FOR UPDATEна заказах. - Потерянные вебхуки: Stripe ретраит, но вы храните смещения: обрабатывайте вебхук идемпотентно по
event.idв отдельной таблице. - Несовпадение сумм: считайте сумму на сервере. Никогда не берите
amountиз клиента. Расхождения в копейках из‑за округлений — частая причина спорных транзакций. - 3DS-зависания в WebView: фолбэк на
return_urlи страницу статуса вне Mini App. Да, это некрасиво, но работает. - Валюты и локальные методы: если включили
automatic_payment_methods, будьте готовы к методам вроде iDEAL/Przelewy24. Не все они мгновенные — статус может бытьprocessing. - Возвраты/частичные возвраты: синхронизируйте их по вебхуку
charge.refunded/charge.refund.updated, иначе в UI будут «выплачено», а по факту — возвращено. - Безопасность токенов: храните
BOT_TOKEN,STRIPE_SECRET_KEY,WEBHOOK_SECRETв секрет-хранилище (AWS Secrets Manager, Doppler, Vault). Никогда не кладите их в .env на проде без контроля доступа.
И ещё один «весёлый» случай: в iOS WebView пользователь уходит свайпом назад на экран чата ровно в момент 3DS-челленджа. Если вы полагаетесь на фронтовое «оплата прошла» — встречайте зомби-заказы. Лечится только вебхуками и атомарной сменой статуса на бэке.
Модель данных и транзакционная целостность
Минимум таблиц: users, products, orders, payments (журнал событий по вебхукам). Для ключей и связей подойдёт классическая схема с суррогатными PK и внешними ключами. Если колеблетесь, когда использовать натуральные/суррогатные, посмотрите наш разбор про внешние и суррогатные ключи.
Важно:
orders.status:pending→paid/failed/canceled.- Уникальные ограничения:
orders.payment_intent_idUNIQUE,payments.stripe_event_idUNIQUE. - Транзакции с
FOR UPDATEпри апдейте статусов. - Денежные суммы храните в минорных единицах (
amount_cents INT).
Стоимость, сроки и ROI
Оценки для MVP (Mini App + Stripe + сервер + вебхуки + базовый каталог):
- Срок: 3–6 недель, если без сложной каталогизации, мультивалюты и складского учёта.
- Бюджет: от 12k до 30k USD эквивалента в зависимости от объёма UI, доп. интеграций и фолбэков под iOS-цифровые товары.
- Операционные расходы: хостинг (100–300 USD/мес для малого трафика), логирование/алёртинг (Sentry/Datadog), домен/SSL, комиссии Stripe.
- Комиссии: Stripe берёт процент + фикс; добавьте чарджбеки и возможные валютные конверсии.
- Комплаенс: для карт — SAQ A (Stripe хостит поля), KYC/выплаты у Stripe. Для цифровых товаров — проверьте налоговые правила (VAT/НДС) по юрисдикциям, возможно нужен расчёт налога на сервере или включение Stripe Tax.
С точки зрения окупаемости — Mini App снижает трение входа (пользователь уже в Telegram), а Stripe даёт зрелую антифрод-инфраструктуру. Рентабельность чаще всего упирается не в технологию, а в конверсию в оплату — тестируйте чек‑аут‑экран как функциональный продукт, а не просто форму.
Безопасность и комплаенс: короткий чеклист
- Валидируйте
initDataдля каждого захода, выпускайте краткоживущие JWT. - Цены и скидки — только с сервера. Продукты на клиенте — по
product_id. - Вебхуки Stripe — с проверкой подписи, логами и идемпотентностью.
- Отдельная роль для админов с защитой от изменения цен «на лету».
- Лимиты и антифрод: лимитируйте частоту создания Intent-ов на пользователя/IP, следите за аномалиями.
- Храните истории финансовых событий неизменяемо (append-only журнал).
- Резервное копирование БД и проверка восстановления.
Развёртывание и окружения
- Окружения:
dev,staging,prod; у каждого свой бот/токен/Stripe-аккаунт (или Connect). - Stripe CLI для локальных вебхуков:
stripe listen --forward-to localhost:3000/stripe/webhook
- Инфраструктура: Docker Compose для dev, Kubernetes/managed PaaS для prod.
- Наблюдаемость: метрики (Prometheus/Grafana), логи (Loki/ELK), алёрты по отказам вебхуков и всплескам
payment_failed.
Отладка UX платежа
- Плейсхолдеры и автозаполнение в WebView не всегда ведут себя как в мобильном браузере. Тестируйте на реальных девайсах.
- Apple Pay/Google Pay: Stripe Elements автоматически покажет кнопку, если среда поддерживает. В Telegram WebView на iOS Apple Pay обычно недоступен; не обещайте пользователю то, чего среда не даёт.
- «Спасибо-страница»: используйте
return_urlи параллельно ждите вебхук, чтобы не зависеть от закрытия WebView.
Небольшая сухая шутка по делу: нет такой вещи, как «слишком много логов» в платежах — есть только «слишком дорого хранить логи без TTL».
Частые вопросы по iOS и цифровым товарам
Коротко резюмируем практику:
- Если вы продаёте цифровой контент, продумайте Stars или внешний чек-аут, проверяйте политику перед релизом.
- Для физики или услуг — Stripe встраивается нативно и проходит ревью.
- Если спорите с правилами — будьте готовы к откатам и потерянным неделям. Это не дешевле фолбэка.
FAQ
-
Можно ли принимать Stripe-платежи в Telegram Mini App для цифровых товаров на iOS?
- Рискованно. Для цифровых товаров на iOS безопаснее использовать Telegram Stars или внешний чек-аут. Для Android/desktop Stripe обычно допустим.
-
Работает ли 3D Secure (SCA) внутри Telegram WebView?
- В большинстве кейсов — да, через Stripe Elements. Но часть iOS-конфигураций может вести себя нестабильно. Держите
return_urlи страницу статуса как резерв.
- В большинстве кейсов — да, через Stripe Elements. Но часть iOS-конфигураций может вести себя нестабильно. Держите
-
Зачем вебхуки, если
confirmPaymentвернул успех?- Вебхуки — единственный надёжный источник истины. Фронт может упасть/закрыться. Только после вебхука фиксируйте
paidи выдавайте доступ.
- Вебхуки — единственный надёжный источник истины. Фронт может упасть/закрыться. Только после вебхука фиксируйте
-
Как делать возвраты?
- Инициируйте
refundчерез сервер (Stripe API), обновляйте заказ по вебхукамcharge.refunded. Изменяйте доступы/отметки в БД атомарно.
- Инициируйте
-
Как хранить суммы и валюты?
- В минорных единицах (
INTцентов/копеек). Валюту — ISO-кодом. Никогда неFLOATдля денег.
- В минорных единицах (
-
Что делать с дублями платежей при нестабильной сети?
- Идемпотентные ключи при создании Intent, уникальные ограничения в БД, обработка вебхуков с защитой от повторов (таблица
processed_events).
- Идемпотентные ключи при создании Intent, уникальные ограничения в БД, обработка вебхуков с защитой от повторов (таблица
Key takeaways
- Валидируйте
initDataи стройте серверную сессию; не доверяйте клиенту ни цены, ни статусы. - Stripe в Mini App идеален для физики/услуг; для цифровых на iOS нужен фолбэк (Stars или внешний чек-аут).
- Статус платежа фиксируйте только по вебхуку Stripe с идемпотентностью и транзакциями в БД.
- Готовьте UX к 3DS в WebView и держите
return_urlна случай сбоев встроенного челенджа. - Проектируйте БД с явными статусами, уникальными ограничениями и журналом финансовых событий.
Если вы строите Telegram Mini App с платёжной логикой, MTBYTE может спроектировать архитектуру, собрать MVP и провести в прод за 3–6 недель. Напишите нам через /contact — обсудим стек, ограничения и бюджет без лишних обещаний.
СЛЕДУЮЩИЙ ШАГ
Понравилось как мыслим?
Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.