Приймання криптоплатежів в Express

Інтеграція Moonvex у застосунок на Express без додаткових залежностей: платіж створюється вбудованим fetch, підпис webhook перевіряється через node:crypto, замовлення позначається оплаченим один раз у транзакції PostgreSQL.

Покроково

  1. 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/shop
  2. 2.Створіть платіж

    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. 3.Надішліть клієнта на paymentUrl

    paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. В Express це res.redirect(303, payment.paymentUrl).

  4. 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. 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. 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 і токенів.

Часті запитання

Чим варіант для Express відрізняється від SDK?

За протоколом нічим: той самий POST /v1/payments і та сама схема підпису. Тут усе написано вручну — зручно, якщо не хочете зайвих пакетів. Із SDK @moonvex/node перевірка зводиться до одного виклику verifyWebhook.

Чому amount у відповіді більший за ціну замовлення?

Комісію Moonvex клієнт сплачує зверху, тому amount — підсумок до сплати. Ваша ціна залишається в pricing.amount, ваш дохід за повної оплати — у merchantAmount. Звіряйте замовлення за orderId і pricing.amount, а клієнтові показуйте amount.

Чи є тестовий режим або sandbox?

Ні, тестової мережі немає — Moonvex працює лише в основних мережах. Підпис перевіряється кнопкою «Надіслати тест» (подія webhook.test), а весь шлях оплати — реальним платежем на невелику суму, наприклад 1 USDT у TRON.

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис