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

Інші гайди

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

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

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