Telegram Mini App with Payments: Complete Developer Tutorial (2026)
Telegram Mini Apps are web applications that run inside Telegram. No app store, no separate install, no new account - users interact with your app inside the messenger they already use daily. With 900M+ monthly active users and Telegram Payments built in, this is one of the fastest ways to ship a transactional product.
This tutorial builds a simple product store Mini App with real payments end-to-end.
What We're Building
A Mini App where users can:
- Browse a product catalog
- Add items to cart
- Pay via Telegram Payments (connected to your payment provider)
- Receive order confirmation in chat
Stack: React + Vite (frontend), Node.js + TypeScript (bot + backend), grammY (Telegram bot library).
Part 1: Bot Setup
npm init -y
npm install grammy express dotenv
npm install -D typescript tsx @types/node @types/express
Create your bot with @BotFather:
/newbot→ set name and username- Copy the token →
BOT_TOKENin.env /mybots→ Bot Settings → Menu Button → configure Web App URL- For payments:
/mybots→ Payments → connect provider (Stripe, YooKassa, etc.)
// bot.ts
import { Bot, webhookCallback } from 'grammy';
import express from 'express';
const bot = new Bot(process.env.BOT_TOKEN!);
// Command to open the Mini App
bot.command('start', async (ctx) => {
await ctx.reply('Welcome to our store!', {
reply_markup: {
inline_keyboard: [[
{
text: '🛍 Open Store',
web_app: { url: process.env.WEBAPP_URL! } // Your Mini App URL
}
]]
}
});
});
// Handle payment pre-checkout (must answer within 10 seconds)
bot.on('pre_checkout_query', async (ctx) => {
await ctx.answerPreCheckoutQuery(true); // Approve payment
// Add validation here: check stock, verify price, etc.
});
// Handle successful payment
bot.on('message:successful_payment', async (ctx) => {
const payment = ctx.message.successful_payment;
const payload = JSON.parse(payment.invoice_payload);
await fulfillOrder(payload.orderId, ctx.from.id);
await ctx.reply(
`✅ Payment received! Order #${payload.orderId} confirmed.\n` +
`Amount: ${payment.total_amount / 100} ${payment.currency}`
);
});
Part 2: React Mini App Frontend
npm create vite@latest mini-app -- --template react-ts
cd mini-app && npm install
// src/App.tsx
import { useEffect, useState } from 'react';
// Telegram WebApp SDK is injected by Telegram
declare global {
interface Window {
Telegram: {
WebApp: TelegramWebApp;
};
}
}
const tg = window.Telegram.WebApp;
function App() {
const [products, setProducts] = useState<Product[]>([]);
const [cart, setCart] = useState<CartItem[]>([]);
useEffect(() => {
// Initialize Telegram WebApp
tg.ready();
tg.expand(); // Expand to full screen
tg.enableClosingConfirmation(); // Ask before closing with items in cart
fetchProducts();
}, []);
// Update MainButton when cart changes
useEffect(() => {
if (cart.length > 0) {
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
tg.MainButton.setText(`Checkout - ${total} ₽`);
tg.MainButton.show();
tg.MainButton.onClick(handleCheckout);
} else {
tg.MainButton.hide();
}
}, [cart]);
async function handleCheckout() {
tg.MainButton.showProgress(true);
try {
// Send cart to backend, get invoice link
const response = await fetch('/api/create-invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cart,
userId: tg.initDataUnsafe.user?.id,
initData: tg.initData, // For server-side auth verification
}),
});
const { invoiceLink } = await response.json();
// Open Telegram payment dialog
tg.openInvoice(invoiceLink, (status) => {
if (status === 'paid') {
tg.showAlert('Order confirmed! 🎉', () => tg.close());
} else if (status === 'failed') {
tg.showAlert('Payment failed. Please try again.');
}
// status: 'paid' | 'cancelled' | 'failed' | 'pending'
});
} finally {
tg.MainButton.hideProgress();
}
}
return (
<div style={{ padding: 16, backgroundColor: tg.themeParams.bg_color }}>
<h1 style={{ color: tg.themeParams.text_color }}>Our Store</h1>
<ProductGrid products={products} onAddToCart={(p) => setCart(prev => addToCart(prev, p))} />
</div>
);
}
Part 3: User Authentication
Never trust data sent from the frontend directly. Verify initData server-side:
// auth.middleware.ts
import crypto from 'crypto';
export function verifyTelegramInitData(initData: string): TelegramUser | null {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
params.delete('hash');
// Sort params and create data check string
const dataCheckString = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
// HMAC-SHA256 with key = HMAC-SHA256("WebAppData", BOT_TOKEN)
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(process.env.BOT_TOKEN!)
.digest();
const expectedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
if (expectedHash !== hash) return null;
// Check auth_date is not too old (prevent replay attacks)
const authDate = parseInt(params.get('auth_date') || '0');
if (Date.now() / 1000 - authDate > 3600) return null; // 1 hour max
return JSON.parse(params.get('user') || 'null');
}
Part 4: Create Invoice (Backend)
// invoice.controller.ts
import { Bot } from 'grammy';
const bot = new Bot(process.env.BOT_TOKEN!);
app.post('/api/create-invoice', async (req, res) => {
const { cart, userId, initData } = req.body;
// Verify Telegram auth
const telegramUser = verifyTelegramInitData(initData);
if (!telegramUser || telegramUser.id !== userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Calculate total
const total = cart.reduce((sum: number, item: CartItem) =>
sum + item.price * item.quantity, 0
);
// Create order in DB
const order = await db.orders.create({
data: {
userId: telegramUser.id,
items: cart,
total,
status: 'pending',
},
});
// Create Telegram invoice link
const invoiceLink = await bot.api.createInvoiceLink(
'Your Order', // Title
`Order #${order.id} - ${cart.length} items`, // Description
JSON.stringify({ orderId: order.id }), // Payload (returned on payment)
process.env.PAYMENT_PROVIDER_TOKEN!, // Provider token from BotFather
'RUB', // Currency
[{ label: 'Total', amount: total * 100 }], // Amount in kopecks/cents
{
need_name: false,
need_phone_number: false,
need_email: false,
need_shipping_address: false,
is_flexible: false,
// Add photo for better UX:
photo_url: 'https://yoursite.com/store-logo.jpg',
photo_size: 512,
}
);
res.json({ invoiceLink });
});
Part 5: Telegram Theme Integration
Mini Apps should match Telegram's theme automatically:
// Use CSS variables provided by Telegram
const styles = {
container: {
backgroundColor: 'var(--tg-theme-bg-color)',
color: 'var(--tg-theme-text-color)',
},
button: {
backgroundColor: 'var(--tg-theme-button-color)',
color: 'var(--tg-theme-button-text-color)',
border: 'none',
borderRadius: 8,
padding: '12px 24px',
},
card: {
backgroundColor: 'var(--tg-theme-secondary-bg-color)',
borderRadius: 12,
padding: 16,
},
};
Payment Providers for Telegram Payments
| Provider | Regions | Notes |
|---|---|---|
| Stripe | Global (except RU) | Best for international |
| YooKassa | Russia | Best for RU market |
| Sberbank | Russia | High Sber user adoption |
| Payme | Uzbekistan | UZ market |
| Click | Uzbekistan | UZ market |
| Robokassa | Russia/CIS | Wide method support |
Connect via BotFather: /mybots → Payments → choose provider → get provider token.
Deployment
The Mini App frontend must be served over HTTPS - Telegram rejects HTTP.
# Build
cd mini-app && npm run build
# Serve with nginx or upload to Vercel/Cloudflare Pages
# Set WEBAPP_URL in your bot .env to the HTTPS URL
Telegram requirement: valid SSL certificate, no self-signed certs.
Useful Telegram WebApp API Methods
tg.ready() // Tell Telegram the app is loaded
tg.expand() // Full-screen mode
tg.close() // Close the app
tg.sendData(data: string) // Send string to bot (no payment)
tg.openLink(url: string) // Open external URL
tg.openInvoice(url, callback) // Open payment dialog
tg.showAlert(message, callback) // Native alert dialog
tg.showConfirm(message, callback) // Native confirm dialog
tg.showPopup(params, callback) // Custom popup
tg.hapticFeedback.impactOccurred() // Haptic feedback (mobile)
tg.MainButton // Bottom action button
tg.BackButton // Back navigation button
tg.themeParams // Current theme colors
tg.initDataUnsafe.user // Current user (verify server-side!)
tg.platform // 'ios' | 'android' | 'web'