Build a Telegram Bot that accepts USDT — from /buy command to on-chain confirmation — in under 20 minutes.
Telegram Bots are everywhere: VPN subscriptions, digital goods, SaaS tools, community memberships. But accepting payments? Stripe doesn't work inside Telegram. Traditional payment gateways add friction. Crypto payments solve this perfectly — and USDT (a dollar-pegged stablecoin) means your users don't need to worry about volatility.
In this guide, we'll build a Telegram Bot that:
- Shows a product catalog with inline buttons
- Creates payment sessions via IronixPay
- Sends users a "Pay Now" link to a hosted checkout page
- Receives a webhook when payment confirms on-chain
- Sends a "✅ Payment confirmed!" message automatically
Tech stack: Node.js + grammY (Telegram Bot framework) + Express (webhook server)
💡 Full source code: github.com/IronixPay/ironixpay-examples/telegram-bot
1. Setup
mkdir my-tg-bot && cd my-tg-bot
npm init -y
npm install grammy express dotenv
npm install -D typescript tsx @types/express @types/node
Create a bot with @BotFather on Telegram → /newbot → copy the token.
Create .env:
BOT_TOKEN=123456:ABC-DEF...
IRONIXPAY_SECRET_KEY=sk_test_your_key_here
IRONIXPAY_API_URL=https://sandbox.ironixpay.com
IRONIXPAY_WEBHOOK_SECRET=whsec_your_secret_here
WEBHOOK_PORT=3000
PUBLIC_URL=https://your-domain.com
Get your IronixPay credentials at app.ironixpay.com → API Keys.
2. IronixPay API Helper
Create src/ironixpay.ts:
import crypto from "node:crypto";
const API_URL = process.env.IRONIXPAY_API_URL || "https://sandbox.ironixpay.com";
const SECRET_KEY = process.env.IRONIXPAY_SECRET_KEY || "";
export async function createCheckoutSession(params: {
amount: number; // Micro-units: 1 USDT = 1,000,000
currency: "USDT";
network: string;
success_url: string;
cancel_url: string;
client_reference_id?: string;
}) {
const res = await fetch(`${API_URL}/v1/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(`IronixPay error (${res.status}): ${error.message || res.statusText}`);
}
return res.json();
}
export function verifyWebhookSignature(
payload: string,
signature: string,
timestamp: string,
secret: string
): boolean {
// Reject requests older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
// IronixPay signs: "{timestamp}.{payload}"
const message = `${timestamp}.${payload}`;
const computed = crypto.createHmac("sha256", secret).update(message).digest("hex");
if (computed.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(computed, "hex"),
Buffer.from(signature, "hex")
);
}
Key decisions:
-
Node.js
cryptoinstead of Web Crypto API — synchronous, simpler for server-side -
timingSafeEqual— prevents timing attacks on signature comparison - 5-minute replay window — even if an attacker captures a webhook, they can't replay it later
3. The Bot
Create src/index.ts:
import { Bot, InlineKeyboard } from "grammy";
import { createCheckoutSession } from "./ironixpay.js";
import { startWebhookServer } from "./webhook-server.js";
const bot = new Bot(process.env.BOT_TOKEN!);
const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:3000";
// Products (amounts in USDT micro-units)
const PRODUCTS = [
{ id: "starter", name: "⚡ Starter", price: 9_990_000 }, // $9.99
{ id: "pro", name: "🚀 Pro", price: 29_990_000 }, // $29.99
];
// In-memory order store (use a database in production!)
const pendingOrders = new Map<string, { chatId: number; productId: string }>();
// /buy — show product list
bot.command("buy", async (ctx) => {
const kb = new InlineKeyboard();
for (const p of PRODUCTS) {
kb.text(`${p.name} — $${(p.price / 1_000_000).toFixed(2)}`, `buy:${p.id}`).row();
}
await ctx.reply("🛒 Choose a product:", { reply_markup: kb });
});
// User selects a product → create payment
bot.callbackQuery(/^buy:(.+)$/, async (ctx) => {
const product = PRODUCTS.find(p => p.id === ctx.match[1]);
if (!product) return ctx.answerCallbackQuery({ text: "Not found" });
await ctx.answerCallbackQuery({ text: "Creating payment..." });
try {
const orderId = `tg_${ctx.from.id}_${Date.now()}`;
const session = await createCheckoutSession({
amount: product.price,
currency: "USDT",
network: "TRON",
success_url: `${PUBLIC_URL}/success`,
cancel_url: `${PUBLIC_URL}/cancel`,
client_reference_id: orderId,
});
// Save order for webhook matching
pendingOrders.set(orderId, { chatId: ctx.chat!.id, productId: product.id });
const kb = new InlineKeyboard().url("💳 Pay Now", session.url);
await ctx.editMessageText(
`${product.name}\n💰 $${(product.price / 1_000_000).toFixed(2)} USDT\n\nClick below to pay:`,
{ reply_markup: kb }
);
} catch (error) {
console.error("Payment error:", error);
await ctx.editMessageText("❌ Failed to create payment. Try /buy again.");
}
});
// Start webhook server + bot
startWebhookServer(bot, pendingOrders);
bot.start({ onStart: () => console.log("🤖 Bot is running!") });
The complete flow from the user's perspective:
User sends /buy → sees product buttons
User taps "⚡ Starter — $9.99" → bot creates IronixPay session
User taps "💳 Pay Now" → opens checkout page in browser
User sends USDT → IronixPay verifies on-chain
Bot sends "✅ Payment confirmed!" → done!
4. Webhook Server
Create src/webhook-server.ts:
import express from "express";
import type { Bot } from "grammy";
import { verifyWebhookSignature } from "./ironixpay.js";
export function startWebhookServer(
bot: Bot,
pendingOrders: Map<string, { chatId: number; productId: string }>
) {
const app = express();
app.use("/webhooks", express.text({ type: "application/json" }));
app.post("/webhooks/ironixpay", async (req, res) => {
const body = req.body as string;
const sig = (req.headers["x-signature"] as string) || "";
const ts = (req.headers["x-timestamp"] as string) || "";
const secret = process.env.IRONIXPAY_WEBHOOK_SECRET || "";
// Verify HMAC-SHA256 signature
if (secret && !verifyWebhookSignature(body, sig, ts, secret)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(body);
if (event.event_type === "session.completed") {
const order = pendingOrders.get(event.data.client_reference_id);
if (order) {
const amount = (parseInt(event.data.amount_received) / 1_000_000).toFixed(2);
await bot.api.sendMessage(order.chatId,
`✅ Payment confirmed!\n💰 $${amount} USDT received\n\nThank you! 🎉`
);
pendingOrders.delete(event.data.client_reference_id);
}
}
res.json({ received: true });
});
const port = process.env.WEBHOOK_PORT || 3000;
app.listen(port, () => console.log(`🌐 Webhook server on port ${port}`));
}
Why a separate Express server?
The bot uses long polling to receive Telegram messages. But IronixPay needs to push payment notifications to you via HTTP. So we run a small Express server alongside the bot on port 3000.
5. Test It
# Start the bot
npx tsx --require dotenv/config src/index.ts
Open Telegram → message your bot → /buy → pick a product → tap "Pay Now".
For local webhook testing, use a simulated curl:
curl -X POST http://localhost:3000/webhooks/ironixpay \
-H "Content-Type: application/json" \
-d '{"event_type":"session.completed","data":{"amount_received":"9990000","client_reference_id":"YOUR_ORDER_ID"}}'
For production webhook testing, expose your port with ngrok:
npx ngrok http 3000
# Set the ngrok URL as your webhook URL in the IronixPay dashboard
The Architecture
Telegram User Your Server IronixPay Blockchain
│ │ │ │
│── /buy ─────────────▶│ │ │
│◀── Product buttons ──│ │ │
│── Tap "Starter" ────▶│ │ │
│ │── Create session ────▶│ │
│ │◀── { url } ──────────│ │
│◀── "Pay Now" button ─│ │ │
│ │ │ │
│── Tap "Pay Now" ────▶│──── Opens checkout ──▶│ │
│ │ │◀── USDT tx ────│
│ │ │── Verify ─────▶│
│ │◀── Webhook ──────────│ │
│◀── "✅ Confirmed!" ──│ │ │
Going to Production
- Switch credentials:
IRONIXPAY_SECRET_KEY=sk_live_...
IRONIXPAY_API_URL=https://api.ironixpay.com
- Replace in-memory
pendingOrderswith Redis or PostgreSQL - Deploy the bot + webhook server behind a reverse proxy (Caddy/Nginx)
- Set your webhook URL in the IronixPay dashboard
Production mode automatically supports all 7 networks (TRON, BSC, Ethereum, Polygon, Arbitrum, Optimism, Base).
Resources
IronixPay — Accept USDT on 7 blockchains with a single API. No Web3 knowledge required. Get started free →
Top comments (0)