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

Інші гайди

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

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

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