How to Accept USDT Payments in a Telegram Bot
Telegram bots are one of the best places to sell digital goods and one of the worst places to bolt on a traditional checkout. There is no card form, no hosted payment page that feels native, and users hate being kicked out to a browser. A crypto payment gateway with a plain REST API fits far better: your bot creates the invoice, posts the payment details as a message, and reacts when a webhook says the money arrived.
What you need before you start
- A bot token from @BotFather.
- A server that can receive HTTPS requests — your bot's backend and the webhook receiver can be the same application.
- A VisualPay account with two-factor authentication enabled, a merchant created, and the merchant API key copied from the panel. Registration and your first merchant are free, and there is no identity verification to clear first.
- A wallet address you control for each coin and network you plan to accept, declared on the merchant.
The shape of the flow
Before writing code, it helps to be precise about who does what, because getting this wrong is how bot payments leak money:
- The user picks a product in the bot.
- Your backend — never the client — calls the gateway to create a transaction.
- Your bot posts the payment address, the exact USDT amount and the deadline as a message.
- The user pays from their own wallet.
- The gateway detects the payment and calls your webhook.
- Your backend verifies the state, marks the order paid and has the bot deliver the goods.
Notice that step 6 is triggered by step 5, not by anything the user does. A "I've paid" button in the chat is fine as a hint to re-check status; it must never be the thing that releases the product.
Step 1 — Ask which network
USDT exists on several chains and they are not interchangeable. Send TRC20 USDT to an ERC20 address and it is gone. So make the network an explicit choice, with an inline keyboard rather than free text:
{
"inline_keyboard": [
[{ "text": "USDT · TRC20 (Tron)", "callback_data": "pay:USDT:trc20" }],
[{ "text": "USDT · BEP20 (BSC)", "callback_data": "pay:USDT:bep20" }],
[{ "text": "USDT · ERC20 (Ethereum)", "callback_data": "pay:USDT:erc20" }]
]
}
A practical tip: put TRC20 first. Network fees on Tron are low enough that it is usually the friendliest option for small purchases, which is most of what bots sell.
Step 2 — Create the transaction server-side
When the callback query arrives, your backend creates the invoice. The API key goes in a header and never leaves your server:
const res = await fetch(
'https://visualpay.net/api/v1/merchant/transaction/create',
{
method: 'POST',
headers: {
'x-api-key': process.env.VISUALPAY_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
currency_symbol: 'USDT',
network_code: 'trc20',
amount_usd: 25.5,
ttl: 20,
order_id: order.id,
comment: `Telegram user ${ctx.from.id}`,
}),
}
);
const tx = await res.json();
A few field choices worth thinking about rather than copying:
ttlis the payment window in minutes, and it is also how long the exchange rate stays locked. Twenty minutes is a reasonable default for bots: long enough for someone to open a wallet app, short enough to limit your exposure to price movement.order_idis your own identifier. Store it against the Telegram user and chat so the webhook can find its way back to the right conversation.commentis free text that shows up in your panel — useful for support when a user says "I paid and got nothing".
Persist the returned tracking code immediately, keyed to the order. It is how you will look the payment up later.
Step 3 — Render the invoice as a message
Because the gateway returns the payment data rather than redirecting, you can present it natively. Put the amount and the address in monospace so they are tap-to-copy in Telegram, and never break the address across lines:
Send exactly:
42.185000 USDT
Network: TRC20 (Tron)
To address:
TXk...c72E
⏱ Expires in 20 minutes.
Two additions pay for themselves. Attach the QR code so mobile users can scan from a second device. And add a "Check payment" button that calls the status endpoint — not to release the goods, but so an impatient user has something to press instead of messaging your support.
Step 4 — Handle the webhook
This is where the order actually completes. Your endpoint must be HTTPS, and it should be dull and defensive:
app.post('/webhooks/visualpay', async (req, res) => {
// 1. Acknowledge fast so the gateway is not left waiting.
res.sendStatus(200);
const { tracking_code } = req.body;
// 2. Re-verify server-side instead of trusting the payload.
const status = await verifyWithGateway(tracking_code);
if (status !== 'confirmed') return;
// 3. Idempotency: a webhook can arrive more than once.
const order = await orders.findByTrackingCode(tracking_code);
if (!order || order.fulfilled) return;
await orders.markFulfilled(order.id);
await bot.telegram.sendMessage(order.chat_id, 'Payment received. Here is your product: …');
});
The three numbered comments are the whole lesson. Respond quickly, because slow webhook handlers cause retries. Re-verify with the status endpoint rather than trusting the request body, which costs one API call and removes an entire category of spoofing. Be idempotent, because a retried webhook that delivers the product twice is a real bug that will happen in production.
Step 5 — Handle the unhappy paths
Bots live or die on what happens when things do not go cleanly:
- Expired. The window closed unpaid. Nothing was charged. Edit the message to say so and offer a "Try again" button that creates a fresh transaction at the current rate.
- Underpaid. If it falls inside the shortfall tolerance you configured on the merchant, the payment clears and your credited amount is recomputed from what actually arrived. Outside that margin, tell the user plainly what landed and what was expected.
- Overpaid. Accepted and recorded as an overpayment. Decide your policy up front — credit the difference to their account balance, or refund it manually — and say it in the bot before they pay.
- Paid late. Someone pays a minute after expiry. The recheck endpoint re-verifies an on-chain payment for an expired or cancelled transaction, so this is recoverable rather than a support incident.
Getting settled
Because VisualPay is non-custodial, confirmed payments do not sit in a provider balance waiting for you to withdraw. Once your balance for a network crosses its settlement threshold, funds move automatically to the wallet address you declared, usually in under a minute, with only the chain's own network fee deducted.
Security notes worth taking seriously
- The API key belongs in an environment variable on your server. Never in the bot's client code, never in a repository, never in a message.
- Webhook endpoints must be HTTPS, and yours should be an obscure path rather than something guessable.
- Rate-limit the command that creates transactions. Without it, one user can spam hundreds of open invoices.
- Reconcile on a schedule. A nightly job that lists transactions and compares them against your own orders catches the small number of cases where a webhook was missed entirely.
Why this pattern suits bots
There is no plugin to install, no SDK to keep current and no redirect to break the conversation. The bot stays a bot: a message thread where an invoice appears, gets paid, and turns into a delivered product. Everything the gateway does — rate locking, address issuance, chain monitoring, settlement — happens behind two HTTP calls and one webhook.
Start accepting USDT and USDC today
No KYC, no redirect checkout and settlement straight to your own wallet. Registration and your first merchant are free.