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¤cy=USD→ exact crypto amount. - Invoices:
POST /api/public/v1/invoices→ returns QR + address + pay_amount + order id. - Webhook: server POSTs
invoice.paidto your endpoint when the tx lands → balance auto-credit.
What we'll build
- The 3 secrets and where to get them
- Install Node + dependencies
- Verify the API is active (3 curl calls)
- Typed API client (
src/api.ts) - List enabled wallets / currencies
- Telegraf bot — deposit flow with live quote
- IPN webhook server (HMAC) — auto-credit
- Register the webhook URL + secret
- Webhook simulator — test your IPN from this page
- Response shapes (invoice + webhook)
- Tolerance rules
- Run it
1. The 3 secrets — where to set them up
The integration uses exactly three secret values. Generate them once, paste them into .env.
- API_KEY + API_SECRET — sign in to
pay.itemodeverify.com→ API 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. - WEBHOOK_SECRET — Settings → 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 eventinvoice.paid. The WEBHOOK_SECRET is shown ONCE — copy it now. - TELEGRAM_BOT_TOKEN — talk to
@BotFatherin Telegram →/newbot→ copy the token.
# .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 server2. Install Node + dependencies
# 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// 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.
# 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¤cy=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 .statusCommon 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.
// 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.
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.
// 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 };// 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.
// 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);
}// 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(), notexpress.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)
# 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 200Local 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.
Common failures & fixes
- 401 / 403 — HMAC mismatch. Verify
X-Signatureagainst the raw body, not parsed JSON. In Express, useexpress.raw({ type: "application/json" }), thenJSON.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:
// 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
# ✅ 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
# 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