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

Другие гайды

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

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

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