Приймання криптоплатежів у 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() для перевірки підпису — це вже розібрані дані, байти інші.