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