Telegram Bot integration
Who this is for
Sellers who run their business inside Telegram and use a bot to take orders. The buyer places an order in the chat, the bot shows payment details, and delivery happens automatically once payment is confirmed.
Before you start
- You have an API KEY (see Get started) and a collection address configured.
- A bot: create one with BotFather and note the Bot Token.
- A publicly reachable HTTPS endpoint for the webhook, receiving both Telegram updates and platform callbacks.
- A server to hold the API KEY (your bot logic usually already runs on one).
The KEY stays on the server
Your bot backend is a server, so the API KEY belongs there. But do not hand it to any client (Mini App, Web App, desktop client).
Install
Calling the Telegram Bot API needs no SDK — a plain HTTPS request will do:
# set the webhook
curl "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \
-d "url=https://your-bot.com/tg/webhook" -d "secret_token=$TG_WEBHOOK_SECRET"Minimal working example
The recommended path is show payment details in the bot + pay on-chain: the platform settles, the bot communicates and delivers.
// Node/Express: Telegram webhook + Web3SDK order creation
import express from 'express'
const app = express()
app.use(express.json())
const TG = `https://api.telegram.org/bot${process.env.BOT_TOKEN}`
async function tg(method, body) {
return fetch(`${TG}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => r.json())
}
// 1) The buyer taps "buy" in the bot
app.post('/tg/webhook', async (req, res) => {
res.sendStatus(200) // answer Telegram first, before it re-delivers
const msg = req.body.message
if (!msg || !msg.text?.startsWith('/buy')) return
const chatId = msg.chat.id
const orderNo = `TG-${msg.from.id}-${Date.now()}`
// 2) Your server creates the platform order (with the API KEY — never in a client)
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, // idempotency key
chainId: 2,
tokenId: 1,
amount: '29.9', // decimal string in token units
subject: `Telegram order ${orderNo}`,
notifyUrl: 'https://your-bot.com/pay/notify',
}),
}).then((x) => x.json())
if (r.code !== 0) return tg('sendMessage', { chat_id: chatId, text: `Order failed: ${r.msg}` })
const o = r.data.order
// 3) Send the payment details (use the display value of payAmountMinimal)
await tg('sendMessage', {
chat_id: chatId,
parse_mode: 'HTML',
text:
`Order <code>${orderNo}</code>\n` +
`Amount due: <b>${o.payAmountDisplay}</b> USDT (${o.chainKey})\n` +
`Address: <code>${o.payAddress}</code>\n\n` +
`Transfer exactly this amount, then tap the button below.`,
reply_markup: {
inline_keyboard: [[
{ text: 'I have paid', callback_data: `paid:${o.orderNo}` },
{ text: 'Refresh status', callback_data: `check:${o.orderNo}` },
]],
},
})
})
// 4) Handle button callbacks (Telegram callback queries)
app.post('/tg/callback', async (req, res) => { /* the same webhook works too */ })Answer callback queries with answerCallbackQuery:
async function onCallbackQuery(q) {
const [action, orderNo] = (q.data || '').split(':')
if (action === 'check' || action === 'paid') {
// Verify the real state with the query API (never trust the buyer's word alone)
const r = await fetch(
`${process.env.API_BASE}/api/pay/open/queryOrder?orderNo=${orderNo}`,
{ headers: { 'X-Pay-Api-Key': process.env.PAY_API_KEY } },
).then((x) => x.json())
const status = r.data?.status
const text =
status === 3 || status === 4 ? 'Payment confirmed. Preparing your order…' :
status === 2 ? 'Transaction seen on-chain, waiting for confirmations…' :
'We have not received this payment yet — please try again shortly.'
await tg('answerCallbackQuery', { callback_query_id: q.id, text, show_alert: false })
}
}If you want to use the Telegram Invoice flow
The Bot API offers sendInvoice / pre_checkout_query / successful_payment, which fits the case where you have already connected a Telegram payment provider:
// Send an invoice (the amount unit depends on currency)
await tg('sendInvoice', {
chat_id: chatId,
title: `Order ${orderNo}`,
description: 'USDT payment',
payload: orderNo, // your merchant order number; comes back in the receipt
provider_token: process.env.TG_PROVIDER_TOKEN, // from the provider you connected
currency: 'USD',
prices: [{ label: 'Total', amount: 2990 }], // integer in the smallest unit
})
// You must answer before settlement (within 10s), or the buyer sees a failure
async function onPreCheckoutQuery(q) {
await tg('answerPreCheckoutQuery', { pre_checkout_query_id: q.id, ok: true })
}
// Settlement succeeded
async function onSuccessfulPayment(m) {
const orderNo = m.successful_payment.invoice_payload
await markPaid(orderNo, m.successful_payment.provider_payment_charge_id)
}Do not mix the two paths
sendInvoice settles through a Telegram payment provider, not on-chain; Web3SDK collects on-chain, and delivery is driven by the platform callback. If you want to collect USDT, use the "payment details + on-chain payment" path above; sendInvoice only makes sense if you have also connected Telegram Payments. Pick one per order — never both.
Callback handling
The platform's payment callback lands on the same bot backend:
import crypto from 'node:crypto'
function verify(rawBody, ts, sig) {
const want = crypto
.createHmac('sha256', process.env.PAY_API_KEY)
.update(ts).update(rawBody) // timestamp + raw body
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))
}
app.post('/pay/notify', express.raw({ type: '*/*' }), async (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
await markPaid(p.merchantOrderNo, p.txHash)
await deliverGoods(p.merchantOrderNo) // deliver
res.sendStatus(200) // 2xx stops the retries
})- Idempotent: callbacks retry; deduplicate on
merchantOrderNoso you never deliver twice. - Return fast: about a 10-second timeout; queue delivery or persist first and deliver asynchronously.
- Query is authoritative: when a buyer says "I paid", verify with Query order rather than trusting the chat.
FAQ
| Question | Answer |
|---|---|
| Can the bot hold the KEY? | Yes — the bot backend is a server. But never hand it to a client |
| How do I stop forged "I paid" claims? | The button only triggers a query; truth comes from the platform query API or a verified callback |
| The webhook receives no updates | Check the setWebhook URL, the secret_token check, and whether the server is publicly reachable |
| Duplicate callbacks | Deduplicate on merchantOrderNo and return 2xx to stop retries |
| Can the buyer just post an address in the chat? | Not recommended. Use a bot DM plus a platform order so the address and amount come from the platform |