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

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт