← BackTypeScript + Telegram bot

TypeScript Telegram Bot — 100% Automatic Crypto Deposits

AI-friendly, copy-paste ready integration for pay.itemodeverify.com. User taps /deposit, enters an amount, sees the exact crypto amount per wallet (live FX), pays. The IPN webhook auto-credits the user's balance the moment the deposit lands — no manual status checks.

User → /deposit → enter 100 USD
     → bot calls GET /wallets + GET /wallets/{id}/quote
     → buttons:
         USDT TRC20  →  92.4012 USDT
         USDT BEP20  →  92.4012 USDT
         BNB BSC     →  0.1531  BNB
         Binance Pay →  100 USD (exact)
     → user taps → POST /invoices (wallet bound, 30-min expiry, 2% tolerance)
     → bot returns QR + address + EXACT pay_amount + order id + status
     → user pays → server matches the tx
     → POST {WEBHOOK_URL} { "event":"invoice.paid", ... }
     → bot credits balance + DMs the user ✅

Base URL & how the API works

Every endpoint lives under the single base URL: https://pay.itemodeverify.com. REST, JSON in / JSON out, authenticated with one header. There are no SDKs — plain fetch works.

  • Auth: every request includes Authorization: Bearer <API_KEY>:<API_SECRET> (the colon is mandatory).
  • Wallets: GET /api/public/v1/wallets → the currencies/networks currently enabled.
  • Quotes: GET /api/public/v1/wallets/{id}/quote?amount=100&currency=USD → exact crypto amount.
  • Invoices: POST /api/public/v1/invoices → returns QR + address + pay_amount + order id.
  • Webhook: server POSTs invoice.paid to your endpoint when the tx lands → balance auto-credit.

What we'll build

1. The 3 secrets — where to set them up

The integration uses exactly three secret values. Generate them once, paste them into .env.

  1. API_KEY + API_SECRET — sign in to pay.itemodeverify.comAPI Keys Create key. Pick the wallets this key is allowed to use, name it (e.g. "TG Bot — production"). The SECRET is shown ONCE — copy it now.
  2. WEBHOOK_SECRETSettings → Webhooks → Add endpoint. Paste your IPN URL (e.g. https://bot.your-domain.com/ipn), select the same API key from step 1 (strict per-key routing — events only go where they belong, so the wrong bot never receives the webhook), select event invoice.paid. The WEBHOOK_SECRET is shown ONCE — copy it now.
  3. TELEGRAM_BOT_TOKEN — talk to @BotFather in Telegram → /newbot → copy the token.
dotenv
# .env — keep out of git
API_KEY="ci_live_xxxxxxxxxxxxxxxx"
API_SECRET="sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxx"   # shown ONCE when you add the webhook
TELEGRAM_BOT_TOKEN="123456:AA..."             # from @BotFather
PUBLIC_BASE_URL="https://bot.your-domain.com" # public HTTPS for the IPN server

2. Install Node + dependencies

bash
# Node.js 20+
mkdir tg-deposit-bot && cd tg-deposit-bot
npm init -y
npm i telegraf express dotenv
npm i -D typescript tsx @types/node @types/express
npx tsc --init --rootDir src --outDir dist --target ES2022 --module NodeNext --moduleResolution NodeNext --esModuleInterop --strict
json
// package.json — scripts
{
  "type": "module",
  "scripts": {
    "dev":   "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

3. Verify the API is active

Run these 3 calls from your shell. If all return HTTP 200, your key/secret are correctly set up and the API is reachable.

bash
# Verify the 3 secrets work BEFORE wiring the bot. Each call should return HTTP 200.

# 1) Liveness + auth check (lists active wallets)
curl -sS https://pay.itemodeverify.com/api/public/v1/wallets \
     -H "Authorization: Bearer $API_KEY:$API_SECRET" | jq .

# 2) Live FX quote for the first wallet
WID=$(curl -sS https://pay.itemodeverify.com/api/public/v1/wallets \
     -H "Authorization: Bearer $API_KEY:$API_SECRET" | jq -r '.data[0].id')
curl -sS "https://pay.itemodeverify.com/api/public/v1/wallets/$WID/quote?amount=100&currency=USD" \
     -H "Authorization: Bearer $API_KEY:$API_SECRET" | jq .

# 3) Create + immediately cancel an invoice (no balance change)
INV=$(curl -sS -X POST https://pay.itemodeverify.com/api/public/v1/invoices \
     -H "Authorization: Bearer $API_KEY:$API_SECRET" \
     -H "Content-Type: application/json" \
     -d "{\"amount\":100,\"currency\":\"USD\",\"wallet_id\":\"$WID\",\"expires_in_minutes\":30,\"tolerance_pct\":2}")
echo "$INV" | jq .
ID=$(echo "$INV" | jq -r .id)
curl -sS -X POST "https://pay.itemodeverify.com/api/public/v1/invoices/$ID/cancel" \
     -H "Authorization: Bearer $API_KEY:$API_SECRET" | jq .status

Common failures: 401 invalid_key = wrong key, 401 invalid_secret = wrong secret, 401 missing_credentials = forgot the colon between key and secret in the Authorization header. See /websetup for the full error-code reference.

4. Typed API client (src/api.ts)

One file wraps every endpoint with full TypeScript types — no SDK, no extra deps. Exports: ping, listWallets, quoteWallet, createInvoice, getInvoice, cancelInvoice.

ts
// src/api.ts — typed HTTP client for pay.itemodeverify.com
import "dotenv/config";

export const API_BASE = "https://pay.itemodeverify.com";
const KEY    = process.env.API_KEY!;
const SECRET = process.env.API_SECRET!;
const AUTH   = `Bearer ${KEY}:${SECRET}`; // the colon is REQUIRED

export interface Wallet {
  id: string;
  currency: string;          // e.g. "USDT", "BNB", "USD"
  network: string;           // e.g. "TRC20", "BEP20", "BINANCE_PAY"
  address: string;
  label: string | null;
  qr_url: string;
}

export interface Quote {
  pay_amount: number;        // exact amount the user must send to THIS wallet
  pay_currency: string;      // e.g. "USDT"
  rate: number;              // 1 invoice-currency = rate * pay-currency
}

export interface Invoice {
  id: string;
  amount: number;
  currency: string;
  status: "pending" | "paid" | "expired" | "cancelled" | "underpaid" | "overpaid";
  pay_amount: number | null;
  pay_currency: string | null;
  rate: number | null;
  expires_at: string | null;
  paid_at: string | null;
  paid_amount: number | null;
  tx_hash: string | null;
  wallet: Wallet | null;
  pay_url: string;
  tolerance_pct: number;
  source: string;
}

async function api<T>(method: string, path: string, opts: { query?: Record<string, string | number>; body?: unknown } = {}): Promise<T> {
  const url = new URL(API_BASE + path);
  if (opts.query) for (const [k, v] of Object.entries(opts.query)) url.searchParams.set(k, String(v));
  const r = await fetch(url, {
    method,
    headers: { Authorization: AUTH, "Content-Type": "application/json" },
    body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
  });
  const text = await r.text();
  if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${text}`);
  return (text ? JSON.parse(text) : {}) as T;
}

/** Liveness check — call this once at boot to confirm key + secret work. */
export async function ping(): Promise<boolean> {
  try { await api<{ data: Wallet[] }>("GET", "/api/public/v1/wallets"); return true; }
  catch (e) { console.error("[api] ping failed:", e); return false; }
}

/** List ENABLED wallets / currencies on the account. */
export async function listWallets(): Promise<Wallet[]> {
  const r = await api<{ data: Wallet[] }>("GET", "/api/public/v1/wallets");
  return r.data;
}

/** Live FX quote: how much of wallet.currency the user must send for `amount` in `currency`. */
export async function quoteWallet(walletId: string, amount: number, currency = "USD"): Promise<Quote> {
  return api<Quote>("GET", `/api/public/v1/wallets/${walletId}/quote`, { query: { amount, currency } });
}

/** Create an invoice bound to a specific wallet — no hosted pay page required. */
export async function createInvoice(input: {
  amount: number;
  currency?: string;
  wallet_id: string;
  expires_in_minutes?: number;
  tolerance_pct?: number;
  description?: string;
  customer_email?: string;
}): Promise<Invoice> {
  return api<Invoice>("POST", "/api/public/v1/invoices", {
    body: { currency: "USD", expires_in_minutes: 30, tolerance_pct: 2, ...input },
  });
}

export async function getInvoice(id: string): Promise<Invoice> {
  return api<Invoice>("GET", `/api/public/v1/invoices/${id}`);
}

export async function cancelInvoice(id: string): Promise<Invoice> {
  return api<Invoice>("POST", `/api/public/v1/invoices/${id}/cancel`);
}

5. List enabled wallets / currencies

This is how the bot discovers which cryptocurrencies are available right now. The dashboard owner can enable/disable wallets at any time — the bot always reflects the live list.

ts
import { listWallets } from "./api.js";

const wallets = await listWallets();
// → [
//     { id, currency: "USDT", network: "TRC20",      address, qr_url, label },
//     { id, currency: "USDT", network: "BEP20",      address, qr_url, label },
//     { id, currency: "BNB",  network: "BSC",        address, qr_url, label },
//     { id, currency: "USD",  network: "BINANCE_PAY", address, qr_url, label },
//   ]

6. Telegraf bot — deposit flow with live quote

Each button shows the live converted amount, e.g. USDT TRC20 • 92.4012 USDT. When the user taps, the bot calls POST /invoices and presents the QR + address + exact pay_amount + Order ID.

ts
// src/bot.ts — Telegram deposit bot (Telegraf + TypeScript)
import "dotenv/config";
import { Telegraf, Markup } from "telegraf";
import { listWallets, quoteWallet, createInvoice, cancelInvoice, ping, type Quote, type Wallet } from "./api.js";
import { rememberInvoiceChat } from "./store.js";

const QUOTE_CCY = "USD";

const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);

interface PendingState {
  amount: number | null;
  quotes: Record<string, { wallet: Wallet; quote: Quote }>;
}
const PENDING = new Map<number, PendingState>();   // chat_id → state

bot.start((ctx) => ctx.reply("👋 Welcome! Tap /deposit to top up your balance."));

bot.command("deposit", async (ctx) => {
  PENDING.set(ctx.chat.id, { amount: null, quotes: {} });
  await ctx.reply(`💰 Enter the amount in ${QUOTE_CCY} you want to deposit:`);
});

bot.on("text", async (ctx, next) => {
  const state = PENDING.get(ctx.chat.id);
  if (!state || state.amount !== null) return next();

  const amount = Number(ctx.message.text.trim());
  if (!Number.isFinite(amount) || amount <= 0) return ctx.reply("❌ Enter a positive number. Try again.");
  state.amount = amount;

  const wallets = await listWallets();
  if (wallets.length === 0) {
    PENDING.delete(ctx.chat.id);
    return ctx.reply("⚠️ No payment methods are active right now.");
  }

  const rows: ReturnType<typeof Markup.button.callback>[][] = [];
  for (const w of wallets) {
    try {
      const q = await quoteWallet(w.id, amount, QUOTE_CCY);
      state.quotes[w.id] = { wallet: w, quote: q };
      const label = w.label ?? `${w.currency} ${w.network}`;
      const pay = String(q.pay_amount).replace(/\.?0+$/, "");
      rows.push([Markup.button.callback(`${label}  •  ${pay} ${q.pay_currency}`, `pick:${w.id}`)]);
    } catch (e) { console.warn("quote failed for", w.id, e); }
  }
  if (rows.length === 0) { PENDING.delete(ctx.chat.id); return ctx.reply("⚠️ Live rates unavailable. Try again later."); }

  await ctx.reply(
    `Select a payment method for <b>${amount} ${QUOTE_CCY}</b>:\n<i>Binance-Pay = exact match. Other networks accept ±2% to absorb rate movement.</i>`,
    { parse_mode: "HTML", ...Markup.inlineKeyboard(rows) },
  );
});

bot.action(/^pick:(.+)$/, async (ctx) => {
  await ctx.answerCbQuery();
  const chatId = ctx.chat!.id;
  const walletId = ctx.match[1];
  const state = PENDING.get(chatId);
  if (!state || !state.amount) return ctx.reply("Session expired. Send /deposit again.");
  PENDING.delete(chatId);

  const inv = await createInvoice({
    amount: state.amount, currency: QUOTE_CCY, wallet_id: walletId,
    expires_in_minutes: 30, tolerance_pct: 2,
    description: `TG deposit • chat ${chatId}`,
  });
  await rememberInvoiceChat(inv.id, chatId);

  const w = inv.wallet!;
  const pay = inv.pay_amount ?? state.amount;
  const cur = inv.pay_currency ?? QUOTE_CCY;
  const text =
    `🧾 <b>Invoice</b> <code>${inv.id}</code>\n\n` +
    `💵 <b>Pay exactly:</b> <code>${pay} ${cur}</code>\n` +
    `🌐 <b>Network:</b> ${w.network} (${w.currency})\n` +
    `📍 <b>Address:</b>\n<code>${w.address}</code>\n` +
    `⏱ <b>Expires:</b> ${inv.expires_at ?? "-"}\n\n` +
    `⚠️ Send the <b>exact</b> amount.\n` +
    `✅ Balance updates automatically when the deposit lands.`;

  await ctx.replyWithPhoto(w.qr_url, {
    caption: text, parse_mode: "HTML",
    ...Markup.inlineKeyboard([[Markup.button.callback("🗑 Cancel", `cancel:${inv.id}`)]]),
  });
});

bot.action(/^cancel:(.+)$/, async (ctx) => {
  await ctx.answerCbQuery();
  try { await cancelInvoice(ctx.match[1]); } catch {}
  await ctx.reply("❎ Invoice cancelled.");
});

export async function startBot() {
  if (!(await ping())) throw new Error("API key/secret invalid — check .env");
  await bot.launch();
  console.log("✅ Telegram bot running");
}
process.once("SIGINT", () => bot.stop("SIGINT"));
process.once("SIGTERM", () => bot.stop("SIGTERM"));

export { bot };
ts
// src/store.ts — invoice_id ↔ telegram chat_id mapping
// Swap the in-memory Maps for Redis / Postgres in production.
import type { Telegraf } from "telegraf";
const MAP = new Map<string, number>();

export async function rememberInvoiceChat(invoiceId: string, chatId: number) { MAP.set(invoiceId, chatId); }
export async function popInvoiceChat(invoiceId: string): Promise<number | undefined> {
  const v = MAP.get(invoiceId); MAP.delete(invoiceId); return v;
}

const SEEN = new Set<string>();
export function alreadyProcessed(key: string): boolean {
  if (SEEN.has(key)) return true; SEEN.add(key); return false;
}

export async function dmUser(bot: Telegraf, chatId: number, html: string) {
  await bot.telegram.sendMessage(chatId, html, { parse_mode: "HTML" });
}

7. IPN webhook server — auto-credit on payment

This is what makes the flow 100% automatic. The server fires invoice.paid the moment the matching tx is confirmed; your endpoint credits the balance and DMs the user.

ts
// src/ipn.ts — Express IPN webhook (HMAC-SHA256, auto-credits balance)
import "dotenv/config";
import express from "express";
import crypto from "node:crypto";
import { bot } from "./bot.js";
import { alreadyProcessed, dmUser, popInvoiceChat } from "./store.js";
import { getInvoice } from "./api.js";

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

function verifySignature(rawBody: Buffer, signatureHeader: string | undefined): boolean {
  if (!signatureHeader) return false;
  const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

interface IpnEvent {
  event: "invoice.paid" | "invoice.expired" | "invoice.cancelled" | "invoice.pending";
  data: {
    invoice_id: string; status: string; amount: number; currency: string;
    paid_amount?: number; tx_hash?: string;
    wallet_currency?: string; wallet_network?: string;
  };
}

export function makeIpnApp() {
  const app = express();
  // IMPORTANT: raw body parser, otherwise the HMAC won't match
  app.post("/ipn", express.raw({ type: "*/*" }), async (req, res) => {
    if (!verifySignature(req.body as Buffer, req.header("X-Webhook-Signature") ?? undefined)) {
      console.warn("[ipn] bad signature"); return res.status(401).send("bad signature");
    }
    const evt = JSON.parse((req.body as Buffer).toString("utf8")) as IpnEvent;
    if (evt.event !== "invoice.paid") return res.status(200).send("ignored");

    const d = evt.data;
    const key = `${d.invoice_id}:${d.tx_hash ?? ""}`;
    if (alreadyProcessed(key)) return res.status(200).send("dup");

    // defence-in-depth: re-read the invoice from the server
    const fresh = await getInvoice(d.invoice_id);
    if (fresh.status !== "paid") { console.error("[ipn] paid event but server says", fresh.status); return res.status(200).send("ok"); }

    // 1) credit balance in YOUR database (transaction + UNIQUE(invoice_id, tx_hash))
    await creditUserBalance({
      invoice_id: d.invoice_id, amount: d.amount, currency: d.currency,
      paid_amount: d.paid_amount ?? d.amount, tx_hash: d.tx_hash ?? "",
    });

    // 2) DM the Telegram user — chat_id was stored when the invoice was created
    const chatId = await popInvoiceChat(d.invoice_id);
    if (chatId) {
      await dmUser(bot, chatId,
        `✅ <b>Payment received</b>\nInvoice <code>${d.invoice_id}</code>\n` +
        `Paid: ${d.paid_amount ?? d.amount} ${d.wallet_currency ?? d.currency}\nYour balance has been credited.`);
    }
    // respond 2xx FAST — non-2xx triggers the auto-retry queue (6 attempts, up to 24h)
    res.status(200).send("ok");
  });
  return app;
}

async function creditUserBalance(row: { invoice_id: string; amount: number; currency: string; paid_amount: number; tx_hash: string }) {
  // TODO: your DB write. Make (invoice_id, tx_hash) a UNIQUE index.
  console.log("[CREDIT]", row);
}
ts
// src/index.ts — boot bot + IPN server together
import { startBot } from "./bot.js";
import { makeIpnApp } from "./ipn.js";

const PORT = Number(process.env.PORT ?? 8080);

async function main() {
  await startBot();
  makeIpnApp().listen(PORT, () => console.log(`✅ IPN listening on :${PORT}/ipn`));
}
main().catch((e) => { console.error(e); process.exit(1); });
  • Always verify the HMAC signature against the raw request body — never against parsed JSON. Use express.raw(), not express.json(), on the IPN route.
  • Make it idempotent. Use (invoice_id, tx_hash) as a UNIQUE key in your DB.
  • Respond 2xx fast. Non-2xx triggers the auto-retry queue (6 attempts, exponential backoff up to 24h).

8. Register the webhook URL + secret (one time)

bash
# 1) Run the bot+IPN server behind public HTTPS (Render, Fly.io, Railway, VPS, Cloudflare Tunnel, ngrok)
#    Final IPN URL example:  https://bot.your-domain.com/ipn
#
# 2) Register it ONCE in the dashboard:
#       https://pay.itemodeverify.com  →  Settings → Webhooks → Add endpoint
#    URL    : https://bot.your-domain.com/ipn
#    API key: select the SAME api key the bot uses  (strict per-key routing — events
#             from invoices created with key X are delivered ONLY to webhooks linked to X)
#    Events : invoice.paid  (optionally invoice.expired, invoice.cancelled)
#    Copy the WEBHOOK_SECRET shown ONCE → paste into .env
#
# 3) Sanity-check delivery from the CLI:
SIG=$(printf '%s' '{"event":"invoice.paid","data":{}}' \
      | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -r | cut -d' ' -f1)
curl -X POST https://bot.your-domain.com/ipn \
     -H "Content-Type: application/json" \
     -H "X-Webhook-Signature: $SIG" \
     -d '{"event":"invoice.paid","data":{}}'
# → expect HTTP 200

Local dev behind a tunnel: ngrok http 8080 → use the public HTTPS URL.

9. Webhook simulator — test your IPN right here

Sends a real POST with a synthetic invoice.paid body to your IPN URL, signed with HMAC-SHA256 using the secret you paste below. Use this to confirm the endpoint is reachable and your signature verification works — no real money moves.

Privacy: your secret is sent over HTTPS to our server only to sign this one request, then discarded. Nothing is stored. For a 100% client-side test, also try registering this URL in your dashboard and using "Send test" there.
Common failures & fixes
  • 401 / 403 — HMAC mismatch. Verify X-Signature against the raw body, not parsed JSON. In Express, use express.raw({ type: "application/json" }), then JSON.parse(req.body) AFTER the check. Also make sure no auth middleware sits in front of the IPN route.
  • 404 — Wrong path. Check the URL you registered matches the route exactly (case-sensitive, no trailing slash mismatch).
  • 405 — Endpoint exists but rejects POST. Add a POST handler.
  • 301/302/308 — Redirects aren't followed. Use the final URL (usually the https:// version).
  • Timeout — Return 2xx fast, then do heavy work async. Slow handlers also trigger our 6-attempt retry queue.
  • DNS / connection refused — URL not publicly reachable. Use a tunnel (ngrok http 8080) or deploy to a public host. Localhost & private IPs are blocked.
  • TLS error — Renew your SSL cert (Let's Encrypt is free) or fix the chain.
  • 5xx — Your handler threw. Check server logs; the response body above usually shows the error.

10. Response shapes (invoice + webhook)

The bot returns whatever the API returns. Here's exactly what you get back:

json
// Successful POST /api/public/v1/invoices response (HTTP 201)
{
  "id": "9b1f3c84-...",
  "amount": 100,
  "currency": "USD",
  "status": "pending",
  "pay_amount": 92.4012,
  "pay_currency": "USDT",
  "rate": 0.924012,
  "expires_at": "2026-06-29T12:30:00.000Z",
  "paid_at": null,
  "paid_amount": null,
  "tx_hash": null,
  "tolerance_pct": 2,
  "source": "api",
  "pay_url": "https://pay.itemodeverify.com/pay/9b1f3c84-...",
  "wallet": {
    "id": "f01...",
    "currency": "USDT",
    "network": "TRC20",
    "address": "TXyz...address...",
    "label": "USDT TRC20",
    "qr_url": "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=..."
  }
}

// invoice.paid webhook payload
{
  "event": "invoice.paid",
  "sent_at": "2026-06-29T12:11:22.000Z",
  "data": {
    "invoice_id": "9b1f3c84-...",
    "status": "paid",
    "amount": 100,
    "currency": "USD",
    "paid_amount": 92.4012,
    "tx_hash": "0xabc...",
    "wallet_currency": "USDT",
    "wallet_network": "TRC20"
  }
}

11. Tolerance rules

text
# ✅ Tolerance rules (enforced server-side — you don't configure this in the bot)
#
#   Binance Pay  (binance → binance, SAME asset as invoice)   → 0%   (EXACT)
#   Binance Pay  (cross-asset, server FX-converts)            → tolerance_pct (default 2%)
#   On-chain crypto deposit (TRC20, BEP20, BSC, etc.)         → tolerance_pct (default 2%)
#
# Send the EXACT pay_amount returned by the API; the 2% is a safety margin for
# rate movement between quote time and settlement time, not a discount.

12. Run it

bash
# dev (hot reload):
npm run dev

# prod:
npm run build && npm start

# then in Telegram, message your bot:
#   /deposit  →  100  →  pick a wallet  →  pay  →  ✅ balance auto-credited