Приём криптоплатежей на PHP

Интеграция Moonvex на чистом PHP: cURL создаёт платёж, клиент платит на готовой странице, webhook читает php://input, проверяет подпись hash_hmac и отмечает заказ в MySQL ровно один раз.

Пошагово

  1. 1.Получите ключ API и секрет webhook

    В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET.

    # переменные окружения веб-сервера (php-fpm: env[...] в пуле)
    MOONVEX_SECRET_KEY=sk_live_...
    MOONVEX_WEBHOOK_SECRET=whsec_...
  2. 2.Создайте платёж через cURL

    POST /v1/payments с Authorization: Bearer, Idempotency-Key и JSON-телом. Колонка DECIMAL через PDO уже приходит строкой — передавайте её как есть. Обязательно задайте CURLOPT_TIMEOUT.

    <?php
    // pay.php
    $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
    
    $stmt = $pdo->prepare('SELECT id, total FROM orders WHERE id = ?');
    $stmt->execute([(int) ($_POST['order_id'] ?? 0)]);
    $order = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$order) { http_response_code(404); exit; }
    
    $ch = curl_init('https://api.moonvex.org/v1/payments');
    curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_TIMEOUT => 15,
      CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('MOONVEX_SECRET_KEY'),
        'Idempotency-Key: order-' . $order['id'] . '-1',
        'Content-Type: application/json',
      ],
      CURLOPT_POSTFIELDS => json_encode([
        'orderId' => (string) $order['id'],
        'amount' => (string) $order['total'], // DECIMAL из PDO — уже строка "25.00"
        'currency' => 'USDT',
        'network' => 'TRON',
      ]),
    ]);
    $payment = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($status >= 400 || !$payment) {
      http_response_code(502);
      exit('Moonvex: ' . ($payment['error']['code'] ?? 'network error'));
    }
    
    header('Location: ' . $payment['paymentUrl'], true, 303);
  3. 3.Отправьте клиента на paymentUrl

    paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. В PHP — header('Location: …', true, 303).

  4. 4.Примите webhook и проверьте подпись

    Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. Сырое тело — file_get_contents('php://input'), заголовок — $_SERVER['HTTP_MOONVEX_SIGNATURE']. Сравнение — только hash_equals.

    <?php
    // webhook.php
    require __DIR__ . '/orders.php'; // mark_paid_once() — шаг 5
    
    function moonvex_verify(string $raw, string $header, string $secret, int $tolerance = 300): bool {
      $t = null; $sigs = [];
      foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
        if ($k === 't' && ctype_digit($v)) { $t = (int) $v; }
        if ($k === 'v1') { $sigs[] = $v; }
      }
      if (!$t || abs(time() - $t) > $tolerance) { return false; }
      $expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
      foreach ($sigs as $s) {
        if (hash_equals($expected, $s)) { return true; } // сравнение за постоянное время
      }
      return false;
    }
    
    $raw = file_get_contents('php://input'); // сырое тело, не $_POST
    if (!moonvex_verify($raw, $_SERVER['HTTP_MOONVEX_SIGNATURE'] ?? '', getenv('MOONVEX_WEBHOOK_SECRET'))) {
      http_response_code(400);
      exit('bad signature');
    }
    
    $event = json_decode($raw, true);
    if (in_array($event['type'], ['payment.confirmed', 'payment.overpaid'], true)) {
      mark_paid_once($event['id'], $event['data']['orderId']);
    }
    http_response_code(200);
    echo 'ok';
  5. 5.Отметьте заказ оплаченным ровно один раз

    При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. В MySQL удобно INSERT IGNORE: rowCount() вернёт 0, если событие уже было.

    <?php
    // orders.php (MySQL)
    // CREATE TABLE processed_events (event_id VARCHAR(64) PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
    
    function mark_paid_once(string $eventId, string $orderId): void {
      $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
      $pdo->beginTransaction();
      try {
        $ins = $pdo->prepare('INSERT IGNORE INTO processed_events (event_id) VALUES (?)');
        $ins->execute([$eventId]);
        if ($ins->rowCount() === 1) { // 0 — событие уже было
          $pdo->prepare("UPDATE orders SET status = 'paid' WHERE id = ? AND status <> 'paid'")->execute([(int) $orderId]);
        }
        $pdo->commit();
      } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
      }
    }
  6. 6.Проверьте интеграцию

    Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.

Подводные камни

  • $_POST для application/json пуст — читайте php://input.
  • Не сравнивайте подписи через == или ===: hash_equals работает за постоянное время.
  • Не приводите суммы к float: (string) из DECIMAL или number_format с точкой, без разделителя тысяч.
  • За nginx и php-fpm заголовок Moonvex-Signature доступен как HTTP_MOONVEX_SIGNATURE; не отрезайте его в прокси.

Частые вопросы

Какая версия PHP нужна?

Код из гайда работает на PHP 7.4+ с расширениями curl и pdo. Ничего, кроме стандартной библиотеки, не требуется.

Почему amount в ответе больше цены заказа?

Комиссию Moonvex платит клиент сверху, поэтому amount — итог к оплате. Ваша цена остаётся в pricing.amount, ваш доход при полной оплате — в merchantAmount. Сверяйте заказ по orderId и pricing.amount, а клиенту показывайте amount.

Есть ли тестовый режим или sandbox?

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

Другие гайды

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

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

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