Приём криптоплатежей в 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.

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт