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