DEV Community

IronixPay
IronixPay

Posted on

Accept USDT Payments in Your Telegram Bot (Complete Guide)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
    );
}
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • Node.js crypto instead 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!") });
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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}`));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!" ──│                       │                │
Enter fullscreen mode Exit fullscreen mode

Going to Production

  1. Switch credentials:
IRONIXPAY_SECRET_KEY=sk_live_...
IRONIXPAY_API_URL=https://api.ironixpay.com
Enter fullscreen mode Exit fullscreen mode
  1. Replace in-memory pendingOrders with Redis or PostgreSQL
  2. Deploy the bot + webhook server behind a reverse proxy (Caddy/Nginx)
  3. 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)