Приймання криптоплатежів у Laravel

Як підключити Moonvex до Laravel: invokable-контролер створює платіж через фасад Http, другий контролер приймає webhook за $request->getContent(), перевіряє підпис і позначає замовлення в транзакції.

Покроково

  1. 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. 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. 3.Надішліть клієнта на paymentUrl

    paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. redirect()->away($url) — зовнішній редирект у Laravel.

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

Часті запитання

Чи працює це в Laravel 10?

Так. Відмінність лише в CSRF: у Laravel 10 і раніше шлях moonvex/webhook додається в $except в app/Http/Middleware/VerifyCsrfToken.php.

Що робити, якщо клієнт не встиг оплатити?

Платіж перейде в EXPIRED і надійде payment.expired. За тим самим orderId можна створити новий платіж — з новим Idempotency-Key (наприклад, збільште номер спроби в кінці ключа). Строк оплати задається в expiresInMinutes: від 5 до 1440 хвилин, за замовчуванням 30.

Чи є тестовий режим або sandbox?

Ні, тестової мережі немає — Moonvex працює лише в основних мережах. Підпис перевіряється кнопкою «Надіслати тест» (подія webhook.test), а весь шлях оплати — реальним платежем на невелику суму, наприклад 1 USDT у TRON.

Інші гайди

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

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

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