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.

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:
- Userul deschide Mini App din Telegram (buton in chat sau in meniul botului). Telegram injecteaza
initDatasemnat. - Frontend-ul trimite
initDatala backend pentru validare si primirea unuisession_tokenpropriu (sau foloseste directtelegram_user_id). - Userul alege un produs; frontend cere de la backend crearea unui
PaymentIntent(sau a unui Checkout Session) pe bazaproduct_id(pretul vine DOAR de la server). - In cazul Elements: frontend monteaza Stripe Elements si cheama
confirmPayment. In cazul Checkout: redirect in aceeasi webview sau deschidere in browser extern. - Stripe finalizeaza 3DS/SCA; backend receptioneaza webhook-ul
payment_intent.succeededsaucheckout.session.completedsi marcheazaorderca platita. - 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_SECRETin 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)
| Optiune | Cand | Pro | Contra |
|---|---|---|---|
| Stripe Checkout | MVP rapid, redirectionare simpla | UI validat, SCA/3DS out-of-box, mai putin cod | Redirectionare in webview poate fi fragila; customizare limitata |
| Stripe Elements | UX inline in mini app | Control UI, evitare salturi intre pagini | Implementare mai complexa; 3DS poate deschide ferestre suplimentare |
| Telegram Stars | Bunuri digitale in-app, mai ales iOS | Conformitate platforma, flux nativ Telegram | Conversie/moneda proprii; conversie la fiat separata; comisioane platforma |
| Telegram Payments API (alti provideri) | Tari fara Stripe sau preferinte locale | Integrare in ecosistem Telegram | Fragmentare 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_actionesueaza, deschide browser extern cureturn_urlsi relua confirmarea. - Webhook-uri duplicate: Stripe retrimite evenimente. Foloseste idempotency pe procesare (cheie
event.idsaupi.id + status). - Curs valutar si zecimale: foloseste
unit_amountin 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-Policysiframe-ancestors https://web.telegram.org https://*.telegram.orgdaca 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
initDatala fiecare cerere sensibila si expira sesiunile vechi (auth_date+ marja, ex. 24h). - Nu loga
client_secretsi 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_SECRETsi pastreaza un jurnal de reconciliere perorder_id. - Secret management: nu pune chei in
.envpe 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 KEYintreorders.user_id -> users.idsipayments.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
confirmPaymentesueaza curequires_actionin webview, afiseaza buton „Deschide in browser” si reincearcaconfirmacolo (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 minsau mismatch intreorders.statussi Stripe. - Alerta: webhook 5xx rate > 1% in 5 minute, crestere anormala a
payment_failed. - Backfill job: o data pe ora, trage
payment_intentsdin ultimele 24h si reconciliaza (API Stripelistcucreatedfilter) — 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
SetupIntentsisubscription 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
initDatasemnat de Telegram si verifica HMAC server-side la fiecare cerere sensibila. Nu stoca parole;telegram_user_ideste 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_eventscuevent_idsi 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
initDatasi 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.