Приём криптоплатежей в Express
Интеграция Moonvex в приложение на Express без дополнительных зависимостей: платёж создаётся встроенным fetch, подпись webhook проверяется через node:crypto, заказ отмечается оплаченным один раз в транзакции PostgreSQL.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET.
npm install express pg # .env — только на сервере, не в репозитории MOONVEX_SECRET_KEY=sk_live_... MOONVEX_WEBHOOK_SECRET=whsec_... DATABASE_URL=postgres://user:pass@localhost:5432/shop2.Создайте платёж
POST /v1/payments с заголовками Authorization: Bearer и Idempotency-Key. Обязательные поля — orderId, amount (строкой), currency и network; для цены в USD добавьте asset — монету оплаты. В ответе 201 — объект платежа с id, amount и paymentUrl. Ошибки приходят в одном формате: error.code, error.message и requestId.
// server.js import express from 'express'; import { db } from './db.js'; const app = express(); async function createPayment(order, attempt = 1) { const res = await fetch('https://api.moonvex.org/v1/payments', { method: 'POST', headers: { Authorization: 'Bearer ' + process.env.MOONVEX_SECRET_KEY, 'Idempotency-Key': 'order-' + order.id + '-' + attempt, 'Content-Type': 'application/json', }, body: JSON.stringify({ orderId: String(order.id), amount: order.total, // строка "25.00" из numeric, без float currency: 'USDT', network: 'TRON', }), signal: AbortSignal.timeout(15000), }); const payment = await res.json(); if (!res.ok) throw new Error(payment.error.code + ': ' + payment.error.message); return payment; } app.post('/orders/:id/pay', async (req, res, next) => { try { const { rows } = await db.query('SELECT id, total::text AS total FROM orders WHERE id = $1', [req.params.id]); if (!rows[0]) return res.sendStatus(404); const payment = await createPayment(rows[0]); res.redirect(303, payment.paymentUrl); } catch (err) { next(err); } });3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. В Express это res.redirect(303, payment.paymentUrl).
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. Главное в Express — express.raw({ type: 'application/json' }) именно на маршруте webhook и до глобального express.json(): тогда req.body — Buffer с исходными байтами.
// server.js (продолжение) import crypto from 'node:crypto'; import { markOrderPaidOnce } from './db.js'; /** true, если подпись верна и событию не больше 5 минут */ function verifyMoonvex(rawBody, header, secret, toleranceSec = 300) { const parts = (header || '').split(',').map((p) => p.split('=')); const t = Number((parts.find(([k]) => k === 't') || [])[1]); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest(); // при смене секрета 24 часа приходят два v1 — подходит любой return parts .filter(([k]) => k === 'v1') .some(([, sig]) => { const got = Buffer.from(sig || '', 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); } // express.raw ДО express.json: нужен Buffer с исходными байтами app.post('/moonvex/webhook', express.raw({ type: 'application/json' }), async (req, res, next) => { try { if (!verifyMoonvex(req.body, req.get('Moonvex-Signature'), process.env.MOONVEX_WEBHOOK_SECRET)) { return res.status(400).send('bad signature'); } const event = JSON.parse(req.body.toString('utf8')); if (event.type === 'payment.confirmed' || event.type === 'payment.overpaid') { await markOrderPaidOnce(event.id, event.data.orderId); } res.sendStatus(200); } catch (err) { next(err); // 5xx — Moonvex повторит доставку позже } }); // глобальный JSON-парсер — после маршрута webhook app.use(express.json()); app.listen(3000);5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. Если обработка упала, передайте ошибку в next — ответ 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: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Если app.use(express.json()) стоит выше маршрута webhook, тело уже разобрано и подпись не проверить. Объявите webhook раньше или исключите его путь из JSON-парсера.
- Не используйте === для сравнения подписей — только crypto.timingSafeEqual, предварительно сверив длину буферов.
- Ответ 4xx (кроме 408 и 429) Moonvex повторяет только 3 раза. Для временных сбоев возвращайте 5xx, а 400 — только при неверной подписи.
- Защищайте маршрут оплаты своей CSRF-защитой как обычно, а webhook — только подписью: у Moonvex нет ваших cookie и токенов.