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