Приём криптоплатежей в 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 дойдёт до кода.