Приём криптоплатежей в Next.js

Интеграция Moonvex в Next.js (App Router): серверный Route Handler создаёт платёж и делает редирект на страницу оплаты, второй Route Handler принимает webhook, проверяет подпись и отмечает заказ оплаченным.

Пошагово

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

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

    npm install @moonvex/node pg
    
    # .env — только на сервере, не в репозитории
    MOONVEX_SECRET_KEY=sk_live_...
    MOONVEX_WEBHOOK_SECRET=whsec_...
    DATABASE_URL=postgres://user:pass@localhost:5432/shop
  2. 2.Создайте платёж в Route Handler

    Обработчик POST в app/orders/[id]/pay/route.ts берёт заказ из базы и вызывает moonvex.payments.create. Ключ API живёт только на сервере, клиентский компонент просто отправляет форму с method="post".

    // lib/moonvex.ts
    import { Moonvex } from '@moonvex/node';
    
    export const moonvex = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY! });
    
    // app/orders/[id]/pay/route.ts
    import { NextResponse } from 'next/server';
    import { db } from '@/lib/db';
    import { moonvex } from '@/lib/moonvex';
    
    export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
      const { id } = await params;
      const { rows } = await db.query('SELECT id, total::text AS total FROM orders WHERE id = $1', [id]);
      const order = rows[0];
      if (!order) return new Response('not found', { status: 404 });
    
      const payment = await moonvex.payments.create(
        { orderId: String(order.id), amount: order.total, currency: 'USDT', network: 'TRON' },
        { idempotencyKey: 'order-' + order.id + '-1' },
      );
      // форма с method="post" → 303 на страницу оплаты
      return NextResponse.redirect(payment.paymentUrl, 303);
    }
  3. 3.Отправьте клиента на paymentUrl

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

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

    Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. В Route Handler тело читается через await req.arrayBuffer() — это исходные байты. Не вызывайте req.json() до проверки. Укажите runtime = 'nodejs': verifyWebhook использует node:crypto.

    // app/api/moonvex/webhook/route.ts
    import { verifyWebhook, type Payment, type WebhookEvent } from '@moonvex/node';
    import { markOrderPaidOnce } from '@/lib/orders';
    
    export const runtime = 'nodejs'; // нужен node:crypto, не Edge
    export const dynamic = 'force-dynamic';
    
    export async function POST(req: Request) {
      // читаем байты как есть — не req.json()
      const raw = Buffer.from(await req.arrayBuffer());
      let event: WebhookEvent<Payment>;
      try {
        event = verifyWebhook<Payment>(raw, req.headers.get('moonvex-signature'), process.env.MOONVEX_WEBHOOK_SECRET!);
      } catch {
        return new Response('bad signature', { status: 400 });
      }
    
      if (event.type === 'payment.confirmed' || event.type === 'payment.overpaid') {
        await markOrderPaidOnce(event.id, event.data.orderId!);
      }
      return new Response('ok');
    }
  5. 5.Отметьте заказ оплаченным ровно один раз

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

    // lib/db.ts
    import { Pool } from 'pg';
    
    export const db = new Pool({ connectionString: process.env.DATABASE_URL });
    
    // lib/orders.ts
    // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    import { db } from '@/lib/db';
    
    export async function markOrderPaidOnce(eventId: string, orderId: string): Promise<void> {
      const client = await db.connect();
      try {
        await client.query('BEGIN');
        const fresh = await client.query(
          'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
          [eventId],
        );
        if (fresh.rowCount === 1) {
          // первая доставка события — выдаём заказ
          await client.query(
            "UPDATE orders SET status = 'paid', paid_at = now() WHERE id = $1 AND status <> 'paid'",
            [orderId],
          );
        }
        await client.query('COMMIT');
      } catch (err) {
        await client.query('ROLLBACK');
        throw err;
      } finally {
        client.release();
      }
    }
  6. 6.Проверьте интеграцию

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

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

  • Ключ создавайте и используйте только в серверном коде (Route Handlers, Server Actions). Переменная с префиксом NEXT_PUBLIC_ попадает в клиентский бандл.
  • Если middleware проверяет авторизацию, исключите /api/moonvex/webhook в matcher — иначе Moonvex получит редирект на логин.
  • Edge runtime не подходит для webhook из примера: нужен node:crypto и pg. Оставьте runtime = 'nodejs'.
  • Не кэшируйте обработчик: dynamic = 'force-dynamic' и POST-метод гарантируют, что каждый webhook дойдёт до кода.

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

Можно ли создать платёж в Server Action?

Да: вызовите moonvex.payments.create в функции с 'use server' и сделайте redirect(payment.paymentUrl) из next/navigation. Webhook всё равно нужен отдельным Route Handler.

Почему amount в ответе больше цены заказа?

Комиссию Moonvex платит клиент сверху, поэтому amount — итог к оплате. Ваша цена остаётся в pricing.amount, ваш доход при полной оплате — в merchantAmount. Сверяйте заказ по orderId и pricing.amount, а клиенту показывайте amount.

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

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

Другие гайды

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

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

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