Skip to content

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:

html
<script src="https://telegram.org/js/telegram-web-app.js"></script>

Minimal working example ​

1. Order creation on your server ​

js
// 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 ​

js
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):

js
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:

js
// 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:

js
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 paid status of openInvoice nor the buyer's word is enough to ship; rely on Query order or the platform callback.

FAQ ​

QuestionAnswer
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 openMake 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

Web3 payment infrastructure for global merchants