Telegram Mini App integration
Who this is for
You already have a Telegram Mini App (Web App) and want users to pay without leaving it for an external browser.
Before you start
- You have an API KEY (see Get started) and a collection address configured.
- The Mini App is configured (fill in the Web App URL in BotFather).
- You have a server to hold the API KEY and receive callbacks. A Mini App is a browser environment and must never hold the KEY.
A Mini App is a frontend — the KEY cannot go in
Mini App code is web code; users can view the source and the network requests. Order creation must run on your server, and the Mini App calls only your own backend.
Install
Include the Telegram WebApp SDK in your Mini App page:
<script src="https://telegram.org/js/telegram-web-app.js"></script>Minimal working example
1. Order creation on your server
// Same as any other channel: call the Open API with the KEY
app.post('/api/miniapp/checkout', async (req, res) => {
const { orderNo, amount } = req.body
const r = await fetch(`${process.env.API_BASE}/api/pay/open/createOrder`, {
method: 'POST',
headers: { 'X-Pay-Api-Key': process.env.PAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
merchantOrderNo: orderNo,
chainId: 2,
tokenId: 1,
amount: String(amount), // decimal string in token units
subject: `MiniApp order ${orderNo}`,
notifyUrl: 'https://your-app.com/pay/notify',
}),
}).then((x) => x.json())
if (r.code !== 0) return res.status(400).json(r)
const o = r.data.order
res.json({
code: 0,
orderNo: o.orderNo,
payAddress: o.payAddress,
payAmountDisplay: o.payAmountDisplay,
payAmountMinimal: o.payAmountMinimal,
chainKey: o.chainKey,
identify: o.identify,
cashierUrl: `${req.body.cashierBaseUrl}/pay/cashier/m/${o.merchantNo}`, // path segment, not a query string
})
})2. Display inside the Mini App
const tg = window.Telegram.WebApp
tg.ready()
tg.expand()
export async function startCheckout(orderNo, amount) {
const o = await fetch('/api/miniapp/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderNo, amount }),
}).then((r) => r.json())
if (o.code !== 0) {
tg.showAlert('Order failed')
return
}
// Path A: render the payment details right inside the Mini App (recommended, on-chain settlement)
tg.MainButton.setText(`Amount due ${o.payAmountDisplay} USDT`)
tg.MainButton.show()
// A little haptic feedback goes a long way
tg.HapticFeedback?.impactOccurred('light')
// Poll status; your backend queries the platform
const timer = setInterval(async () => {
const s = await fetch(`/api/miniapp/status?orderNo=${o.orderNo}`).then((r) => r.json())
if (s.status === 3 || s.status === 4) {
clearInterval(timer)
tg.MainButton.hide()
tg.showPopup({ title: 'Paid', message: 'Preparing your order' })
}
}, 3000)
return o
}You can also let the user jump straight to the platform checkout (passing the merchant number as a path segment):
tg.openLink(o.cashierUrl) // {cashierBaseUrl}/pay/cashier/m/{merchantNo}The merchant number is a path segment, not a query string
The counter URL has the shape {cashierBaseUrl}/pay/cashier/m/{merchantNo}. Writing it as ?m=... gets truncated at the = by some wallet deep links and will not open.
3. Using a Telegram Invoice (openInvoice)
If your bot has already connected a Telegram payment provider, a Mini App can open an invoice with openInvoice:
// Your server creates the invoice link via the Bot API
const { result: invoiceLink } = await fetch(
`https://api.telegram.org/bot${process.env.BOT_TOKEN}/createInvoiceLink`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `Order ${orderNo}`,
description: 'Item',
payload: orderNo, // comes back in the receipt
provider_token: process.env.TG_PROVIDER_TOKEN, // the provider you connected
currency: 'USD',
prices: [{ label: 'Total', amount: 2990 }],
}),
},
).then((r) => r.json())
// Open it in the Mini App
tg.openInvoice(invoiceLink, (status) => {
// status: 'paid' | 'cancelled' | 'failed' | 'pending'
if (status === 'paid') {
// Do not ship on this alone — use the platform query API or a verified callback
tg.showPopup({ title: 'Paid', message: 'Preparing your order' })
}
})Do not mix the two paths
openInvoice settles through a Telegram payment provider; Web3SDK collects on-chain stablecoins, and delivery is driven by the platform callback. To collect USDT, use path A (render payment details in the Mini App, or open the platform checkout). openInvoice only makes sense if you also connected Telegram Payments.
Callback handling
Callbacks are received by your server, independent of the Mini App:
import crypto from 'node:crypto'
function verify(rawBody, ts, sig) {
const want = crypto.createHmac('sha256', process.env.PAY_API_KEY)
.update(ts).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))
}
app.post('/pay/notify', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('X-Pay-Timestamp'), req.get('X-Pay-Signature'))) {
return res.sendStatus(401)
}
const p = JSON.parse(raw)
if (isProcessed(p.merchantOrderNo)) return res.sendStatus(200) // idempotent
markPaid(p.merchantOrderNo, p.txHash)
res.sendStatus(200) // 2xx stops retries
})- Idempotent: callbacks retry; deduplicate on
merchantOrderNo. - Return fast: about a 10-second timeout.
- Query is authoritative: neither the
paidstatus ofopenInvoicenor the buyer's word is enough to ship; rely on Query order or the platform callback.
FAQ
| Question | Answer |
|---|---|
| How do I confirm the user's identity? | Validate tg.initData on your server (Telegram's official method); do not trust a user.id sent from the frontend |
| Can the KEY go in the Mini App? | No — the page source is visible |
| Why wait for the callback before shipping? | Confirming in the wallet is not on-chain confirmation; only "confirmed" is safe |
| The counter link will not open | Make sure it is /pay/cashier/m/{merchantNo} as a path segment, and that cashierBaseUrl came from getMyProfile |
| Which path collects USDT? | Path A (render in the Mini App / open the platform checkout) — not openInvoice |