Приём криптоплатежей в Laravel
Как подключить Moonvex к Laravel: invokable-контроллер создаёт платёж через фасад Http, второй контроллер принимает webhook по $request->getContent(), проверяет подпись и отмечает заказ в транзакции.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. В Laravel вынесите их в config/services.php, чтобы работал config:cache.
# .env MOONVEX_SECRET_KEY=sk_live_... MOONVEX_WEBHOOK_SECRET=whsec_... // config/services.php 'moonvex' => [ 'secret' => env('MOONVEX_SECRET_KEY'), 'webhook_secret' => env('MOONVEX_WEBHOOK_SECRET'), ],2.Создайте платёж через Http
Http::withToken(…)->withHeaders(['Idempotency-Key' => …])->post(…) отправляет JSON. Каст decimal:2 у модели отдаёт сумму строкой. Ошибку API видно по $response->failed() и $response->json('error.code').
<?php // app/Http/Controllers/PayController.php namespace App\Http\Controllers; use App\Models\Order; use Illuminate\Support\Facades\Http; class PayController extends Controller { public function __invoke(Order $order) { abort_unless($order->user_id === auth()->id() && $order->status === 'new', 404); $response = Http::withToken(config('services.moonvex.secret')) ->withHeaders(['Idempotency-Key' => 'order-' . $order->id . '-1']) ->timeout(15) ->post('https://api.moonvex.org/v1/payments', [ 'orderId' => (string) $order->id, 'amount' => (string) $order->total, // каст decimal:2 → строка "25.00" 'currency' => 'USDT', 'network' => 'TRON', ]); if ($response->failed()) { abort(502, 'Moonvex: ' . $response->json('error.code')); } $order->update(['moonvex_payment_id' => $response->json('id')]); return redirect()->away($response->json('paymentUrl')); } } // routes/web.php Route::post('/orders/{order}/pay', PayController::class)->middleware('auth');3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. redirect()->away($url) — внешний редирект в Laravel.
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. Маршрут webhook нужно исключить из проверки CSRF — в Laravel 11+ это validateCsrfTokens(except: …) в bootstrap/app.php. Сырое тело — $request->getContent().
<?php // app/Http/Controllers/MoonvexWebhookController.php namespace App\Http\Controllers; use App\Models\Order; use Illuminate\Http\Request; class MoonvexWebhookController extends Controller { public function __invoke(Request $request) { $raw = $request->getContent(); // сырое тело, а не $request->all() if (! $this->verify($raw, (string) $request->header('Moonvex-Signature'), config('services.moonvex.webhook_secret'))) { return response('bad signature', 400); } $event = json_decode($raw, true); if (in_array($event['type'], ['payment.confirmed', 'payment.overpaid'], true)) { Order::markPaidOnce($event['id'], $event['data']['orderId']); } return response('ok'); } private function 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); return collect($sigs)->contains(fn ($s) => hash_equals($expected, $s)); } } // routes/web.php Route::post('/moonvex/webhook', MoonvexWebhookController::class); // bootstrap/app.php (Laravel 11+): webhook без CSRF-токена ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['moonvex/webhook']); })5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. DB::table()->insertOrIgnore() возвращает число вставленных строк: 0 — событие уже обработано.
<?php // app/Models/Order.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; class Order extends Model { protected $fillable = ['moonvex_payment_id', 'status']; protected $casts = ['total' => 'decimal:2']; // миграция processed_events: $table->string('event_id')->primary(); $table->timestamps(); public static function markPaidOnce(string $eventId, string $orderId): void { DB::transaction(function () use ($eventId, $orderId) { $fresh = DB::table('processed_events')->insertOrIgnore([ 'event_id' => $eventId, 'created_at' => now(), 'updated_at' => now(), ]); if ($fresh === 0) { return; // повторная доставка } static::whereKey($orderId)->where('status', '!=', 'paid')->update(['status' => 'paid']); }); } }6.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Без исключения из CSRF Laravel ответит 419 на каждый webhook. Альтернатива — маршрут в routes/api.php, где CSRF нет.
- Не вызывайте env() вне файлов config: после php artisan config:cache он вернёт null.
- Обработку после webhook можно вынести в очередь (dispatch(...)->afterResponse() или Job), но запись event_id — сразу.
- Не используйте $request->all() для проверки подписи — это уже разобранные данные, байты другие.