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

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт