Приймання криптоплатежів у Next.js
Інтеграція Moonvex у Next.js (App Router): серверний Route Handler створює платіж і робить редирект на сторінку оплати, другий Route Handler приймає webhook, перевіряє підпис і позначає замовлення оплаченим.
Покроково
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/shop2.Створіть платіж у 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.Надішліть клієнта на paymentUrl
paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. NextResponse.redirect(url, 303) після форми відкриє сторінку оплати.
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.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки 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.Перевірте інтеграцію
Тестової мережі в 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 дійде до коду.