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

Покрокова інтеграція Moonvex на чистому Node.js з офіційним SDK @moonvex/node: сервер створює рахунок, клієнт платить на готовій сторінці, webhook із підписом HMAC позначає замовлення оплаченим. Без фреймворку — лише node:http і PostgreSQL.

Покроково

  1. 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/shop
  2. 2.Створіть платіж через 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. 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. 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. 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. 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 секунд, вважається невдалою доставкою.

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

Чи потрібен SDK, чи можна обійтися fetch?

Можна й без SDK: API — звичайний HTTPS із JSON і заголовком Authorization: Bearer. SDK додає типи TypeScript, повтори з тим самим Idempotency-Key і готову verifyWebhook. Приклад без SDK — у гайді для Express.

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

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

Що робити, якщо клієнт не встиг оплатити?

Платіж перейде в EXPIRED і надійде payment.expired. За тим самим orderId можна створити новий платіж — з новим Idempotency-Key (наприклад, збільште номер спроби в кінці ключа). Строк оплати задається в expiresInMinutes: від 5 до 1440 хвилин, за замовчуванням 30.

Інші гайди

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

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

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