METABYTE
Inapoi la articole

Cum construiesti un Telegram Mini App cu plati Stripe (arhitectura, cod, capcane)

Ghid tehnic pas-cu-pas pentru un Telegram Mini App cu Stripe: arhitectura end-to-end, cod, 3DS/SCA, webhook-uri, capcane iOS si costuri reale.

10 mai 202612 min de cititAI-research draft
Cum construiesti un Telegram Mini App cu plati Stripe (arhitectura, cod, capcane)

Daca ratezi fluxul de plata intr-un Telegram Mini App, pierzi conversii, suportul devine iad si poti incalca reguli de platforma. Aici punem pe masa cum construiesti corect integrarea Stripe pentru Mini Apps, cu cod, arhitectura si ce se strica in productie.

Pe scurt: pentru un Telegram Mini App cu Stripe, autentifici userul cu initData, creezi PaymentIntent server-side (niciodata din client), confirmi plata cu Stripe Elements in webview (sau redirectionezi la Checkout), asculti webhook-uri pentru starea finala si tratezi capcanele iOS/3DS. Pentru bunuri digitale pe iOS, ia in calcul limitarile de platforma si, la nevoie, fallback la Stars sau browser extern.

Cand are sens Stripe intr-un Telegram Mini App

  • Stripe e optim pentru bunuri fizice, servicii, abonamente B2B si cazuri in care vrei control complet la checkout.
  • Pentru bunuri digitale consumate in aplicatie pe iOS, regulile de platforma pot interzice platile externe. In practica, multi folosesc Stars sau un fallback in browser extern pentru a ramane in zona gri cat mai deschisa.
  • Avantajul Stripe: acoperire internationala, SCA/3DS, refunds, dispute management, abonamente, rapoarte. Costul: integrare corecta si atentie la edge cases in webview.

Un comentariu de inginer: webview-ul Telegram face treaba, dar nu iti rezolva toate fluxurile SCA magic; daca testul 3DS sare prin trei cercuri, nu e circ, e PSD2.

Arhitectura recomandata (end-to-end)

Componente:

  • Telegram Bot API: Telegraf/grammY (Node.js) pentru comenzi, deep-linking si trimiterea link-ului catre Mini App.
  • Telegram Mini App (Web App): Next.js/React servit prin HTTPS (Cloudflare, Vercel sau propriul Nginx), folosind Telegram Web Apps JS API pentru auth UI/UX in-app.
  • Backend API: Node.js (NestJS/Express) cu Stripe SDK, Postgres, Redis (BullMQ) pentru joburi si webhook processing.
  • Stripe: Payment Intents, Elements/Checkout, Webhooks.
  • Baza de date: Postgres (ORM: Prisma/TypeORM). Modele: users, products, orders, payments.
  • Observabilitate: Sentry (frontend + backend), Grafana/Prometheus, PostHog pentru funnel-uri.
  • Edge: Cloudflare pentru TLS, cache static, rate limiting.

Fluxul utilizatorului:

  1. Userul deschide Mini App din Telegram (buton in chat sau in meniul botului). Telegram injecteaza initData semnat.
  2. Frontend-ul trimite initData la backend pentru validare si primirea unui session_token propriu (sau foloseste direct telegram_user_id).
  3. Userul alege un produs; frontend cere de la backend crearea unui PaymentIntent (sau a unui Checkout Session) pe baza product_id (pretul vine DOAR de la server).
  4. In cazul Elements: frontend monteaza Stripe Elements si cheama confirmPayment. In cazul Checkout: redirect in aceeasi webview sau deschidere in browser extern.
  5. Stripe finalizeaza 3DS/SCA; backend receptioneaza webhook-ul payment_intent.succeeded sau checkout.session.completed si marcheaza order ca platita.
  6. Botul trimite confirmarea in chat si Mini App afiseaza starea din backend (polling sau WebSocket/SSE).

Descriere text a diagramei: Telegram client initiaza Web App -> Next.js ruleaza in webview -> Back-end API + Stripe -> Stripe Webhooks -> Back-end -> Postgres -> Bot API catre utilizator.

Implementare pas cu pas (cu cod)

1) Configurare bot si Mini App

  • Creeaza bot cu @BotFather si ia BOT_TOKEN.
  • Seteaza Web App URL in meniul botului: setmenu -> Add a button -> Web App -> URL-ul tau (HTTPS obligatoriu).
  • Pe server, stocheaza BOT_TOKEN, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET in secret manager (Doppler, 1Password, AWS SSM), nu in repo.

2) Verificare initData (autentificare Telegram)

Nu te baza pe datele clientului; verifica HMAC SHA-256 conform documentatiei Telegram Web Apps. Exemplu Node.js:

import crypto from 'crypto';

export function verifyInitData(initData: string, botToken: string): boolean {
  // initData vine ca querystring (ex: 'user=...&auth_date=...&hash=...')
  const urlParams = new URLSearchParams(initData);
  const hash = urlParams.get('hash') || '';
  urlParams.delete('hash');
  const dataCheckString = Array.from(urlParams.entries())
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([k, v]) => `${k}=${v}`)
    .join('\n');

  const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
  const computed = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(hash));
}

Pe server, dupa verificare, construieste sau recupereaza user in Postgres pe baza telegram_user_id. Pentru chei si integritate relationala vezi discutia noastra despre chei straine vs chei surogat.

3) Modele de date esentiale

  • users(id, telegram_id, username, created_at)
  • products(id, sku, name, currency, unit_amount, active)
  • orders(id, user_id, product_id, status[pending|paid|failed], amount, currency, payment_intent_id, created_at)
  • payments(id, order_id, provider[stripe], provider_ref, status, raw_payload_json)

Preturile nu vin niciodata din client; doar product_id/sku.

4) Creare PaymentIntent (server-side)

import Stripe from 'stripe';
import { v4 as uuid } from 'uuid';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });

// Express handler
app.post('/api/create-intent', async (req, res) => {
  const { initData, productId } = req.body;
  if (!verifyInitData(initData, process.env.BOT_TOKEN!)) return res.status(401).send('unauthorized');

  const user = await getOrCreateUserFromInitData(initData); // extrage telegram_id
  const product = await db.products.findFirst({ where: { id: productId, active: true } });
  if (!product) return res.status(404).send('no product');

  const order = await db.orders.create({
    data: {
      id: uuid(),
      user_id: user.id,
      product_id: product.id,
      status: 'pending',
      amount: product.unit_amount,
      currency: product.currency,
    }
  });

  const intent = await stripe.paymentIntents.create({
    amount: product.unit_amount,
    currency: product.currency,
    metadata: { order_id: order.id, telegram_user_id: String(user.telegram_id) },
    automatic_payment_methods: { enabled: true },
  }, { idempotencyKey: `order:${order.id}` });

  await db.orders.update({ where: { id: order.id }, data: { payment_intent_id: intent.id } });
  res.json({ clientSecret: intent.client_secret, orderId: order.id });
});

Pentru Stripe Checkout, in loc de PaymentIntent, creezi Checkout Session si returnezi url.

5) Frontend: Telegram Web App + Stripe Elements

// pages/index.tsx (Next.js)
import { useEffect, useMemo, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK!);

function CheckoutForm({ clientSecret, orderId }: { clientSecret: string; orderId: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);

  const onPay = async () => {
    if (!stripe || !elements) return;
    setLoading(true);
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/thank-you?order=${orderId}` },
    });
    if (error) alert(error.message);
    setLoading(false);
  };

  return (
    <div>
      <PaymentElement />
      <button disabled={loading} onClick={onPay}>Plateste</button>
    </div>
  );
}

export default function Home() {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
  const [orderId, setOrderId] = useState<string | null>(null);

  useEffect(() => {
    // Telegram init
    // @ts-ignore
    const tg = window?.Telegram?.WebApp;
    tg?.ready();
    const initData = tg?.initData ?? new URLSearchParams(window.location.search).get('initData');

    fetch('/api/create-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ initData, productId: 'prod_basic' })
    }).then(r => r.json()).then(d => {
      setClientSecret(d.clientSecret);
      setOrderId(d.orderId);
    });
  }, []);

  const options = useMemo(() => ({ clientSecret: clientSecret || undefined }), [clientSecret]);
  if (!clientSecret || !orderId) return <div>Se incarca...</div>;

  return (
    <Elements stripe={stripePromise} options={options}>
      <CheckoutForm clientSecret={clientSecret} orderId={orderId} />
    </Elements>
  );
}

In mini app, foloseste Telegram.WebApp.MainButton pentru actiuni principale si openLink ca fallback pentru redirectionari catre Checkout sau pentru 3DS care cere top-level browser:

Telegram.WebApp.openLink(checkoutUrl, { try_instant_view: false });

6) Webhook Stripe (stare finala)

Webhook-ul e sursa de adevar. Nu marca comanda ca platita pe baza raspunsului clientului.

app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err: any) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  switch (event.type) {
    case 'payment_intent.succeeded': {
      const pi = event.data.object as Stripe.PaymentIntent;
      const orderId = String(pi.metadata?.order_id || '');
      await db.$transaction(async (tx) => {
        await tx.orders.update({ where: { id: orderId }, data: { status: 'paid' } });
        await tx.payments.create({ data: { order_id: orderId, provider: 'stripe', provider_ref: pi.id, status: 'succeeded', raw_payload_json: JSON.stringify(event) } });
      });
      queue.add('notifyUser', { orderId });
      break;
    }
    case 'payment_intent.payment_failed': {
      // marcheaza failed, eventual reintenteaza
      break;
    }
  }

  res.json({ received: true });
});

Worker-ul (BullMQ) poate trimite mesajul final catre user prin Bot API: sendMessage(chat_id, "Plata confirmata").

Checkout vs Elements vs Stars vs Payments API (comparatie)

OptiuneCandProContra
Stripe CheckoutMVP rapid, redirectionare simplaUI validat, SCA/3DS out-of-box, mai putin codRedirectionare in webview poate fi fragila; customizare limitata
Stripe ElementsUX inline in mini appControl UI, evitare salturi intre paginiImplementare mai complexa; 3DS poate deschide ferestre suplimentare
Telegram StarsBunuri digitale in-app, mai ales iOSConformitate platforma, flux nativ TelegramConversie/moneda proprii; conversie la fiat separata; comisioane platforma
Telegram Payments API (alti provideri)Tari fara Stripe sau preferinte localeIntegrare in ecosistem TelegramFragmentare provideri; suport variabil pe tari

Alegerea depinde de tipul produsului, geografie si viteza de livrare. Pentru un MVP cross-platform, incepem cu Checkout si un fallback openLink in browser extern; pentru un UX slefuit, trecem pe Elements.

Ce se strica in productie

  • 3DS/SCA in webview: unele banci cer redirectionari sau challenge-uri care nu merg bine embed-uite. Solutie: daca requires_action esueaza, deschide browser extern cu return_url si relua confirmarea.
  • Webhook-uri duplicate: Stripe retrimite evenimente. Foloseste idempotency pe procesare (cheie event.id sau pi.id + status).
  • Curs valutar si zecimale: foloseste unit_amount in cei mai mici submultipli (centi), evita float. Converteste la afisare.
  • iOS restrictii: pentru digitale in-app, foloseste Stars sau du utilizatorul in browser extern; testeaza pe device real.
  • Timeout pe webhook: Stripe asteapta raspuns rapid; proceseaza scurt si pune logica grea in joburi async.
  • Pret din client: interzis. Daca cineva modifica JS, poate plati 0. Serverul decide pretul.
  • CORS/hotlink in webview: seteaza corect Content-Security-Policy si frame-ancestors https://web.telegram.org https://*.telegram.org daca ai pagini embed-uite externe.
  • Fraud/abuz: rate limit pe create-intent pe telegram_user_id, Captcha (Turnstile) pentru actiuni suspecte si velocity checks pe card.

Securitate si conformitate

  • Verifica initData la fiecare cerere sensibila si expira sesiunile vechi (auth_date + marja, ex. 24h).
  • Nu loga client_secret si nici numere partiale de card; cu Elements/Checkout ramai in scope PCI SAQ A (minim) atata timp cat nu atingi date de card in backend.
  • Verifica semnatura webhook cu STRIPE_WEBHOOK_SECRET si pastreaza un jurnal de reconciliere per order_id.
  • Secret management: nu pune chei in .env pe server fara ACL; foloseste KMS/SSM. Rotire la 90 zile e un obicei sanatos.
  • GDPR: stocheaza minimul necesar (telegram_id, email doar daca il ceri), drept de stergere, politici de retentie.
  • Integritate relationala in Postgres: FOREIGN KEY intre orders.user_id -> users.id si payments.order_id -> orders.id (vezi analiza despre chei).

Costuri, timeline si ROI

  • Timeline MVP: 1–2 saptamani (Checkout, un produs, webhook, confirmare in chat).
  • Timeline productie: 4–8 saptamani (catalog produse, reduceri, abonamente, Elements, observabilitate, antifrauda, fallback iOS, testare end-to-end).
  • Cost dezvoltare (ordine de marime, variaza dupa scope):
    • MVP: 6k–12k EUR.
    • Productie: 15k–35k EUR (inclusiv QA, hardening, monitorizare, rularea in cloud si migrare de date).
  • Costuri operationale: Stripe ~2–3% + fee fix/ tranzactie, hosting 50–300 EUR/luna (depinde de trafic), Sentry/observabilitate 50–200 EUR/luna.
  • ROI: Mini App + checkout inline reduce frictiunea fata de trimis userul pe un site extern. Conversii mai bune in funnel-uri scurte (sub 3 clickuri pana la plata).

Un mic adevar cinic: reducerile cresc conversia, dar fara webhook-uri solide cresc si inbox-ul de suport.

Testare si lansare

  • Stripe test: foloseste Stripe CLI pentru webhook-uri (stripe listen --forward-to localhost:3000/stripe/webhook) si carduri de test (inclusiv 3DS declinat/acceptat).
  • Staging pe un domeniu staging.example.com, cu bot separat si chei Stripe test. Nu amesteca mediile.
  • Device matrix: iOS (Telegram App webview), Android (Telegram si Chrome webview), Desktop (Web, Telegram Desktop). Verifica 3DS pe banci diferite.
  • Failover: daca confirmPayment esueaza cu requires_action in webview, afiseaza buton „Deschide in browser” si reincearca confirm acolo (functioneaza mai stabil cu 3DS pe iOS Safari).

Operatiuni si mentenanta

  • Logging structurat (pino/winston) cu corelare pe order_id.
  • Dashboard de reconciliere: lista comenzi pending > 15 min sau mismatch intre orders.status si Stripe.
  • Alerta: webhook 5xx rate > 1% in 5 minute, crestere anormala a payment_failed.
  • Backfill job: o data pe ora, trage payment_intents din ultimele 24h si reconciliaza (API Stripe list cu created filter) — prinde evenimente ratate.

Tipuri de UX si micro-optimizari

  • Pre-populeaza email/phone din Telegram (daca userul a acceptat sa le partajeze) pentru a simplifica Elements/Link.
  • Pre-autorizare vs captura: pentru produse livrabile, capture_method='manual' si capture dupa confirmarea stocului.
  • Abonamente: foloseste SetupIntent si subscription schedule; atentie la SCA la prima plata.
  • Multi-valuta: expune preturi locale si creeaza PaymentIntents in moneda respectiva (evita conversii client-side).

Cand NU trebuie Stripe in Mini App

  • Strict bunuri digitale pe iOS cu volum mare: foloseste Stars sau un proxy de valoare acceptat de Telegram.
  • Tari unde Stripe nu opereaza: alege un provider suportat de Telegram Payments API local (ex. YooMoney, Tranzzo etc.).

FAQ

  • Pot folosi Stripe pentru bunuri digitale in Telegram pe iOS? Depinde de politica de platforma. In general este riscant. Pentru siguranta, foloseste Stars sau un fallback in browser extern pentru finalizarea platii.

  • Ce aleg: Stripe Checkout sau Elements? Checkout pentru MVP rapid si SCA out-of-box. Elements pentru UX inline si control complet. Multi incep cu Checkout si migreaza la Elements cand stabilizeaza funnel-ul.

  • Cum autentific utilizatorul in Mini App? Foloseste initData semnat de Telegram si verifica HMAC server-side la fiecare cerere sensibila. Nu stoca parole; telegram_user_id este identitatea.

  • Trebuie sa stochez cardurile clientilor? Nu. Cu Elements/Checkout, Stripe gestioneaza datele de card. Backend-ul tau nu atinge PCI sensibile (scope SAQ A daca implementezi corect).

  • Cum tratez duplicatele de webhook? Pastreaza un tabel webhook_events cu event_id si ignora dublurile. Ruleaza actualizarile de stare intr-o tranzactie idempotenta.

  • Pot trimite confirmarea platii direct din Mini App fara webhook? Nu e recomandat. Webhook-ul este sursa de adevar. Mini App poate arata „processing”, iar cand webhook-ul vine, actualizezi la „paid”.

Key takeaways

  • Valideaza initData si nu te baza pe preturi din client; serverul decide produsul si suma.
  • Alege intre Checkout (rapid) si Elements (control) in functie de timeline si UX; testeaza 3DS in webview timpuriu.
  • Webhook-urile Stripe sunt adevarul; construieste procesare idempotenta si reconciliere periodica.
  • Pentru digitale pe iOS, planifica Stars sau fallback in browser; altfel risti blocaje de platforma.
  • Observabilitate si operare conteaza: loguri corelate pe order_id, alerte pe webhook si dashboard de reconciliere.
  • Cost realist: MVP 6k–12k EUR, productie 15k–35k EUR; taxele Stripe si hostingul trebuie bugetate.

Daca vrei un Mini App Telegram cu plati Stripe care functioneaza si in productie, discutam arhitectura si livram un MVP solid. Scrie-ne pe /contact si haide să punem platile la treaba in 2–4 saptamani.

URMATORUL PAS

Ti-a placut abordarea?

Aplicam aceleasi principii in proiectele clientilor: AI, automatizari, produse care nu se sting dupa lansare.