METABYTE
К списку статей

Как сделать Telegram Mini App со Stripe-платежами: продакшн-гайд

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

10 мая 202612 мин чтенияAI-research draft
Как сделать Telegram Mini App со Stripe-платежами: продакшн-гайд

Ошибки в платежах в 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

Поток событий:

  1. Пользователь открывает Mini App. Telegram кладёт initData в WebApp.
  2. Клиент отправляет initData на сервер для валидации и получает серверную сессию (JWT).
  3. Пользователь выбирает товар/план. Клиент отправляет на сервер product_id.
  4. Сервер рассчитывает итоговую цену (валюта, налоги, скидки), создает заказ в БД (статус pending) и PaymentIntent в Stripe.
  5. Сервер возвращает client_secret PaymentIntent.
  6. Клиент инициализирует Stripe Elements и вызывает confirmPayment (поддержка 3DS/SCA).
  7. Stripe отправляет вебхук payment_intent.succeeded/payment_intent.payment_failed на сервер.
  8. Сервер атомарно обновляет заказ в БД, выдает доступ/формирует отгрузку, отправляет пользователю сообщение через 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 внутри WebAppPayment 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: pendingpaid/failed/canceled.
  • Уникальные ограничения: orders.payment_intent_id UNIQUE, payments.stripe_event_id UNIQUE.
  • Транзакции с 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 и страницу статуса как резерв.
  • Зачем вебхуки, если confirmPayment вернул успех?

    • Вебхуки — единственный надёжный источник истины. Фронт может упасть/закрыться. Только после вебхука фиксируйте paid и выдавайте доступ.
  • Как делать возвраты?

    • Инициируйте refund через сервер (Stripe API), обновляйте заказ по вебхукам charge.refunded. Изменяйте доступы/отметки в БД атомарно.
  • Как хранить суммы и валюты?

    • В минорных единицах (INT центов/копеек). Валюту — ISO-кодом. Никогда не FLOAT для денег.
  • Что делать с дублями платежей при нестабильной сети?

    • Идемпотентные ключи при создании Intent, уникальные ограничения в БД, обработка вебхуков с защитой от повторов (таблица processed_events).

Key takeaways

  • Валидируйте initData и стройте серверную сессию; не доверяйте клиенту ни цены, ни статусы.
  • Stripe в Mini App идеален для физики/услуг; для цифровых на iOS нужен фолбэк (Stars или внешний чек-аут).
  • Статус платежа фиксируйте только по вебхуку Stripe с идемпотентностью и транзакциями в БД.
  • Готовьте UX к 3DS в WebView и держите return_url на случай сбоев встроенного челенджа.
  • Проектируйте БД с явными статусами, уникальными ограничениями и журналом финансовых событий.

Если вы строите Telegram Mini App с платёжной логикой, MTBYTE может спроектировать архитектуру, собрать MVP и провести в прод за 3–6 недель. Напишите нам через /contact — обсудим стек, ограничения и бюджет без лишних обещаний.

СЛЕДУЮЩИЙ ШАГ

Понравилось как мыслим?

Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.