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

Інші гайди

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

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

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