Приймання криптоплатежів у Telegram-боті на Telegraf

Бот на Telegraf виставляє рахунок за командою /buy через SDK @moonvex/node і надсилає кнопку оплати, сервер на Express приймає webhook Moonvex, перевіряє підпис і пише клієнтові в чат. Для ботів із балансом — постійна адреса поповнення.

Покроково

  1. 1.Отримайте ключ API і секрет webhook

    У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. Для бота потрібен ще BOT_TOKEN від @BotFather.

    npm install telegraf express @moonvex/node pg
    
    # .env
    BOT_TOKEN=123456:ABC...
    MOONVEX_SECRET_KEY=sk_live_...
    MOONVEX_WEBHOOK_SECRET=whsec_...
    DATABASE_URL=postgres://user:pass@localhost:5432/bot
  2. 2.Виставте рахунок за командою /buy

    moonvex.payments.create з orderId з чату й повідомлення, customerId — id користувача, chatId — у metadata. Metadata повернеться у webhook. idempotencyKey захищає від подвійного рахунку, якщо користувач повторить команду через повільну мережу.

    // bot.js
    import { Markup, Telegraf } from 'telegraf';
    import { Moonvex } from '@moonvex/node';
    
    export const bot = new Telegraf(process.env.BOT_TOKEN);
    export const moonvex = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY });
    
    bot.command('buy', async (ctx) => {
      // унікальний номер замовлення: чат + повідомлення
      const orderId = 'tg-' + ctx.chat.id + '-' + ctx.message.message_id;
      const payment = await moonvex.payments.create(
        {
          orderId,
          amount: '10',
          currency: 'USDT',
          network: 'TRON',
          customerId: String(ctx.from.id),
          metadata: { chatId: ctx.chat.id }, // повернеться у webhook
        },
        { idempotencyKey: orderId + '-1' },
      );
      await ctx.reply(
        'До сплати: ' + payment.amount + ' ' + payment.asset + ' (' + payment.network + ')',
        Markup.inlineKeyboard([Markup.button.url('Оплатити', payment.paymentUrl)]),
      );
    });
    
    bot.catch((err, ctx) => {
      console.error(err);
      return ctx.reply('Не вдалося створити рахунок, спробуйте пізніше');
    });
  3. 3.Надішліть кнопку оплати

    Markup.button.url відкриває paymentUrl — готову сторінку оплати із сумою, адресою і QR-кодом. У фрейм її вбудувати не можна, вона відкривається в браузері. Показуйте клієнтові amount із відповіді — це підсумок із комісією.

  4. 4.Прийміть webhook і перевірте підпис

    Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. verifyWebhook із SDK отримує Buffer від express.raw і кидає помилку за неправильного підпису або застарілої події.

    // server.js
    import express from 'express';
    import { verifyWebhook } from '@moonvex/node';
    import { bot } from './bot.js';
    import { claimEvent } from './db.js';
    
    const app = express();
    
    // express.raw: підпис рахується за сирим тілом
    app.post('/moonvex/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
      let event;
      try {
        event = verifyWebhook(req.body, req.get('Moonvex-Signature'), process.env.MOONVEX_WEBHOOK_SECRET);
      } catch {
        return res.status(400).send('bad signature');
      }
    
      if ((event.type === 'payment.confirmed' || event.type === 'payment.overpaid') && (await claimEvent(event.id))) {
        await bot.telegram.sendMessage(
          event.data.metadata.chatId,
          'Оплату отримано, дякуємо! Замовлення ' + event.data.orderId,
        );
      }
      res.sendStatus(200);
    });
    
    app.listen(3000); // за nginx з https
    bot.launch();
  5. 5.Позначте замовлення оплаченим рівно один раз

    Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. claimEvent повертає true лише під час першої вставки id події, тому повідомлення про оплату не піде двічі.

    // db.js
    import pg from 'pg';
    
    const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
    
    // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    
    /** true лише для першої доставки події */
    export async function claimEvent(eventId) {
      const r = await db.query('INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [eventId]);
      return r.rowCount === 1;
    }
  6. 6.Поповнення балансу через постійну адресу

    Для бота з внутрішнім балансом: moonvex.depositAddresses.create({ customerId, network }) повертає постійну адресу — ту саму під час кожного виклику. Кожне надходження приходить подією deposit.confirmed з customerId і receivedAmount; зараховуйте один раз на event.id. Ключу потрібне право wallets:write.

    // bot.js (продовження) — потрібне право ключа wallets:write
    bot.command('topup', async (ctx) => {
      // та сама адреса під час кожного виклику: клієнт поповнює її будь-коли
      const deposit = await moonvex.depositAddresses.create({ customerId: String(ctx.from.id), network: 'TRON' });
      await ctx.reply('Ваша адреса для поповнення (USDT або TRX, мережа TRON):\n' + deposit.address);
    });
    
    // server.js, в обробнику webhook: зарахування один раз на event.id
    if (event.type === 'deposit.confirmed' && (await claimEvent(event.id))) {
      const d = event.data;
      await creditBalance(d.customerId, d.asset, d.receivedAmount); // ваша функція
      await bot.telegram.sendMessage(Number(d.customerId), 'Баланс поповнено: ' + d.receivedAmount + ' ' + d.asset);
    }

Підводні камені

  • express.raw лише на маршруті webhook: глобальний express.json() зламає перевірку підпису.
  • bot.catch ловить помилки API (наприклад, MoonvexError) і не дає ботові впасти.
  • Не видавайте товар за натисканням кнопки — лише за payment.confirmed / payment.overpaid.
  • Тестової мережі немає — перевіряйте бота платежем на 1 USDT у TRON і кнопкою «Надіслати тест» у кабінеті.

Часті запитання

Чи можна запустити бота і webhook на одному сервері?

Так, як у прикладі: Telegraf працює через polling, Express слухає порт для Moonvex. Ззовні потрібна https-адреса — наприклад, nginx перед портом 3000.

Це Telegram Payments чи Stars?

Ні. Клієнт платить криптовалютою на сторінці оплати Moonvex, гроші надходять на баланс вашого проєкту в Moonvex. Платіжний провайдер Telegram не потрібен.

Що робити, якщо клієнт не встиг оплатити?

Платіж перейде в EXPIRED і надійде payment.expired. За тим самим orderId можна створити новий платіж — з новим Idempotency-Key (наприклад, збільште номер спроби в кінці ключа). Строк оплати задається в expiresInMinutes: від 5 до 1440 хвилин, за замовчуванням 30.

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис