Приймання криптоплатежів на Node.js
Покрокова інтеграція Moonvex на чистому Node.js з офіційним SDK @moonvex/node: сервер створює рахунок, клієнт платить на готовій сторінці, webhook із підписом HMAC позначає замовлення оплаченим. Без фреймворку — лише node:http і PostgreSQL.
Покроково
1.Отримайте ключ API і секрет webhook
У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. SDK @moonvex/node — без залежностей, потрібен Node.js 18+.
npm install @moonvex/node pg # .env — лише на сервері, не в репозиторії MOONVEX_SECRET_KEY=sk_live_... MOONVEX_WEBHOOK_SECRET=whsec_... DATABASE_URL=postgres://user:pass@localhost:5432/shop2.Створіть платіж через SDK
payments.create приймає orderId, суму рядком, currency і network. Другий аргумент — idempotencyKey: повтор після таймауту поверне той самий платіж, а не створить другий. SDK сам повторює запити в разі збоїв мережі, 429 і 5xx із тим самим ключем. Помилки надходять як MoonvexError з полями status, code і detailsCode — наприклад, DUPLICATE_ORDER, якщо за замовленням уже є активний платіж.
// moonvex.js import { Moonvex, MoonvexError } from '@moonvex/node'; export const moonvex = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY }); /** Створює рахунок для замовлення; повтор із тим самим ключем поверне той самий платіж */ export async function createPaymentForOrder(order, attempt = 1) { try { return await moonvex.payments.create( { orderId: String(order.id), amount: order.total, // рядок, наприклад "25.00", а не число currency: 'USDT', network: 'TRON', customerId: String(order.userId), expiresInMinutes: 30, }, { idempotencyKey: 'order-' + order.id + '-' + attempt }, ); } catch (err) { if (err instanceof MoonvexError && err.detailsCode === 'DUPLICATE_ORDER') { // за замовленням уже є активний платіж — повернемо його const page = await moonvex.payments.list({ orderId: String(order.id), limit: 1 }); if (page.data[0]) return page.data[0]; } throw err; } }3.Надішліть клієнта на paymentUrl
paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. Відповідь 303 після POST-форми змусить браузер відкрити сторінку оплати звичайним GET.
// server.js import http from 'node:http'; import { db } from './db.js'; import { createPaymentForOrder } from './moonvex.js'; const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); const pay = url.pathname.match(/^\/orders\/(\d+)\/pay$/); if (req.method === 'POST' && pay) { const { rows } = await db.query( 'SELECT id, user_id AS "userId", total::text AS total FROM orders WHERE id = $1', [pay[1]], ); if (!rows[0]) return res.writeHead(404).end(); const payment = await createPaymentForOrder(rows[0]); // 303: браузер відкриє сторінку оплати GET-запитом return res.writeHead(303, { Location: payment.paymentUrl }).end(); } if (req.method === 'POST' && url.pathname === '/moonvex/webhook') { return handleWebhook(req, res); // крок 4 } res.writeHead(404).end(); }); server.listen(3000);4.Прийміть webhook і перевірте підпис
Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. verifyWebhook із SDK робить усе це сам і кидає WebhookVerificationError, якщо щось не так. Йому потрібне сире тіло — Buffer, а не об’єкт.
// server.js (продовження) import { verifyWebhook, WebhookVerificationError } from '@moonvex/node'; import { markOrderPaidOnce } from './db.js'; /** Сире тіло запиту: підпис рахується за байтами, а не за розібраним JSON */ async function readRawBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); return Buffer.concat(chunks); } async function handleWebhook(req, res) { const raw = await readRawBody(req); let event; try { // перевіряє HMAC-SHA256, обидва v1 під час зміни секрету і вік події (5 хвилин) event = verifyWebhook(raw, req.headers['moonvex-signature'], process.env.MOONVEX_WEBHOOK_SECRET); } catch (err) { if (err instanceof WebhookVerificationError) return res.writeHead(400).end('bad signature'); throw err; } if (event.type === 'payment.confirmed' || event.type === 'payment.overpaid') { await markOrderPaidOnce(event.id, event.data.orderId); } // будь-який 2xx протягом 10 секунд, інакше Moonvex повторить доставку res.writeHead(200).end('ok'); }5.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. Відповідайте 2xx протягом 10 секунд; у разі помилки (5xx, таймаут) Moonvex повторить доставку — близько трьох діб.
// db.js import pg from 'pg'; export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL }); // CREATE TABLE processed_events ( // event_id text PRIMARY KEY, // created_at timestamptz NOT NULL DEFAULT now() // ); /** Позначає замовлення оплаченим рівно один раз, навіть під час повторної доставки події */ export async function markOrderPaidOnce(eventId, orderId) { 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: це звичайний платіж, гроші надійдуть на баланс. Журнал доставок із кодами відповідей і кнопкою «Повторити» — там само.
Підводні камені
- Передавайте у verifyWebhook початковий Buffer. JSON.stringify(JSON.parse(body)) дає інші байти, і підпис не збіжиться.
- Будуйте idempotencyKey з номера замовлення і спроби (order-1001-1), а не з випадкового UUID: тоді повтор після перезапуску процесу теж безпечний.
- Суми — рядки. Беріть total з PostgreSQL як numeric::text, а не через Number(): float втрачає центи.
- Довгу роботу (листи, видачу доступу) виконуйте після відповіді 200 — у черзі. Відповідь, довша за 10 секунд, вважається невдалою доставкою.