Приём криптоплатежей в NestJS

Как подключить приём USDT, BTC и других монет в приложение на NestJS: провайдер с SDK, контроллер с редиректом на страницу оплаты и контроллер webhook, который читает req.rawBody и проверяет подпись HMAC.

Пошагово

  1. 1.Получите ключ API и секрет webhook

    В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. В NestJS удобно читать их через ConfigModule; в примере для краткости — process.env.

    npm install @moonvex/node
    
    # .env
    MOONVEX_SECRET_KEY=sk_live_...
    MOONVEX_WEBHOOK_SECRET=whsec_...
  2. 2.Создайте сервис и контроллер оплаты

    MoonvexService оборачивает клиент SDK, контроллер находит заказ и создаёт платёж с idempotencyKey из номера заказа. Декоратор @Redirect() с возвращаемым { url, statusCode: 303 } отправит клиента на страницу оплаты.

    // moonvex.service.ts
    import { Injectable } from '@nestjs/common';
    import { Moonvex, type Payment } from '@moonvex/node';
    
    @Injectable()
    export class MoonvexService {
      readonly client = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY! });
    
      createPayment(orderId: string, amount: string, attempt = 1): Promise<Payment> {
        return this.client.payments.create(
          { orderId, amount, currency: 'USDT', network: 'TRON' },
          { idempotencyKey: 'order-' + orderId + '-' + attempt },
        );
      }
    }
    
    // orders.controller.ts
    import { Controller, Param, Post, Redirect } from '@nestjs/common';
    import { MoonvexService } from './moonvex.service';
    import { OrdersService } from './orders.service';
    
    @Controller('orders')
    export class OrdersController {
      constructor(
        private readonly orders: OrdersService,
        private readonly moonvex: MoonvexService,
      ) {}
    
      @Post(':id/pay')
      @Redirect()
      async pay(@Param('id') id: string) {
        const order = await this.orders.findOne(id); // ваш сервис заказов; total — строка
        const payment = await this.moonvex.createPayment(String(order.id), order.total);
        return { url: payment.paymentUrl, statusCode: 303 };
      }
    }
  3. 3.Отправьте клиента на paymentUrl

    paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id.

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

    Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. В NestJS включите rawBody: true в NestFactory.create — тогда исходные байты будут в req.rawBody, а обычный парсинг JSON продолжит работать для остальных маршрутов.

    // main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      // rawBody: true — Nest сохранит исходные байты в req.rawBody
      const app = await NestFactory.create(AppModule, { rawBody: true });
      await app.listen(3000);
    }
    bootstrap();
    
    // moonvex-webhook.controller.ts
    import { BadRequestException, Controller, Headers, HttpCode, Post, Req, type RawBodyRequest } from '@nestjs/common';
    import type { Request } from 'express';
    import { verifyWebhook, type Payment, type WebhookEvent } from '@moonvex/node';
    import { OrdersService } from './orders.service';
    
    @Controller('moonvex')
    export class MoonvexWebhookController {
      constructor(private readonly orders: OrdersService) {}
    
      @Post('webhook')
      @HttpCode(200)
      async handle(@Req() req: RawBodyRequest<Request>, @Headers('moonvex-signature') signature?: string) {
        let event: WebhookEvent<Payment>;
        try {
          event = verifyWebhook<Payment>(req.rawBody!, signature, process.env.MOONVEX_WEBHOOK_SECRET!);
        } catch {
          throw new BadRequestException('bad signature');
        }
        if (event.type === 'payment.confirmed' || event.type === 'payment.overpaid') {
          await this.orders.markPaidOnce(event.id, event.data.orderId!);
        }
        return { received: true };
      }
    }
  5. 5.Отметьте заказ оплаченным ровно один раз

    При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. В примере — транзакция TypeORM: INSERT … ON CONFLICT DO NOTHING RETURNING и обновление заказа. С Prisma то же делается через $transaction и уникальный ключ.

    // orders.service.ts
    import { Injectable } from '@nestjs/common';
    import { DataSource } from 'typeorm';
    
    @Injectable()
    export class OrdersService {
      constructor(private readonly dataSource: DataSource) {}
    
      // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
      async markPaidOnce(eventId: string, orderId: string): Promise<void> {
        await this.dataSource.transaction(async (m) => {
          const fresh: unknown[] = await m.query(
            'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id',
            [eventId],
          );
          if (fresh.length === 0) return; // событие уже обработано
          await m.query(
            "UPDATE orders SET status = 'paid', paid_at = now() WHERE id = $1 AND status <> 'paid'",
            [orderId],
          );
        });
      }
    }
  6. 6.Проверьте интеграцию

    Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.

Подводные камни

  • Без rawBody: true поле req.rawBody будет undefined, а @Body() уже содержит разобранный объект — по нему подпись не проверить.
  • Если у вас глобальный AuthGuard, пометьте контроллер webhook публичным (например, своим декоратором @Public()) — Moonvex не передаёт ваш JWT.
  • Nest по умолчанию отвечает на POST кодом 201 — это тоже успешный 2xx, но @HttpCode(200) делает ответ явным.
  • Тип WebhookEvent<Payment> из SDK даёт автодополнение по event.data: orderId, status, amount, pricing.

Частые вопросы

Можно ли проверить подпись в Guard или Interceptor?

Да. Логика та же: взять req.rawBody и заголовок moonvex-signature и вызвать verifyWebhook. Guard удобен, если webhook-маршрутов несколько.

Что делать, если клиент не успел оплатить?

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

Есть ли тестовый режим или sandbox?

Нет, тестовой сети нет — Moonvex работает только в основных сетях. Подпись проверяется кнопкой «Отправить тест» (событие webhook.test), а весь путь оплаты — реальным платежом на небольшую сумму, например 1 USDT в TRON.

Другие гайды

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

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

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