Приймання криптоплатежів на PHP
Інтеграція Moonvex на чистому PHP: cURL створює платіж, клієнт платить на готовій сторінці, webhook читає php://input, перевіряє підпис hash_hmac і позначає замовлення в MySQL рівно один раз.
Покроково
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.Створіть платіж через 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.Надішліть клієнта на paymentUrl
paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. У PHP — header('Location: …', true, 303).
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.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки 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.Перевірте інтеграцію
Тестової мережі в 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; не відрізайте його в проксі.