Qut Pay Сайт Кабинет База знаний Инструкции Документация API ҚАЗРУС
Главная → База знаний → Справочник

Kaspi QR для сайта на Laravel

Обновлено: 2026-10-25 · Версия в Markdown

Коротко

На Laravel интеграция Kaspi QR через Qut Pay — это два маршрута. Первый создаёт счёт запросом POST https://api.qut.kz/api/v1/invoices с заголовком X-API-Key и делает redirect()->away($inv['payUrl']). Второй — POST /qutpay/webhook в routes/api.php (без CSRF): проверяет подпись X-Webhook-Signature по сырому телу $request->getContent(), находит заказ по externalOrderId, отмечает его оплаченным один раз и отвечает 200. Ключ и секрет живут в .env, первый прогон — в песочнице с ключом qp_test_….

Подготовка

  1. В кабинете https://qut.kz/app создайте API-ключ (qp_test_… для песочницы, qp_live_… для боевого режима) и в разделе Интеграции добавьте адрес вебхука https://site.kz/api/qutpay/webhook; секрет показывается один раз — настройка вебхуков.
  2. В .env: QUTPAY_API_KEY=… и QUTPAY_WEBHOOK_SECRET=…; в config/services.php: 'qutpay' => ['key' => env('QUTPAY_API_KEY'), 'webhook_secret' => env('QUTPAY_WEBHOOK_SECRET')]. Ключ не должен попадать в Blade-шаблоны, JS и репозиторий.
  3. Скачайте PHP SDK (https://api.qut.kz/downloads/qutpay-sdk-php.zip) — один файл src/QutPay.php, PHP 7.4+, зависимостей нет. Без SDK всё то же делается через Http::withHeaders(['X-API-Key' => …])->post(…).
  4. В таблицу заказов добавьте поля qutpay_invoice_id и paid_at.

Код: контроллер оплаты и вебхук

// routes/web.php:  Route::post('/orders/{order}/pay', [PayController::class, 'pay']);
// routes/api.php:  Route::post('/qutpay/webhook', [PayController::class, 'webhook']);  // api-группа без CSRF

class PayController extends Controller
{
    public function pay(Order $order)
    {
        $qp  = new \QutPay\Client(config('services.qutpay.key'));
        $inv = $qp->createInvoice([
            'amount'          => $order->total,
            'description'     => "Заказ №{$order->id}",
            'externalOrderId' => (string) $order->id,
            'successUrl'      => route('orders.show', $order),
            'idempotencyKey'  => "order-{$order->id}",   // повтор клика не создаст второй счёт
        ]);
        $order->update(['qutpay_invoice_id' => $inv['id']]);

        return redirect()->away($inv['payUrl']);         // страница с QR и кнопкой «Оплатить в Kaspi»
    }

    public function webhook(Request $request)
    {
        $raw = $request->getContent();                   // сырое тело, НЕ $request->all()
        if (!\QutPay\Webhook::verify(config('services.qutpay.webhook_secret'), $raw, $request->headers->all())) {
            return response()->noContent(401);
        }
        $e = json_decode($raw, true);
        if (($e['event'] ?? '') === 'invoice.paid') {
            $inv = $e['invoice'];
            DB::transaction(function () use ($inv) {
                $order = Order::whereKey($inv['externalOrderId'])->lockForUpdate()->first();
                if ($order && $order->paid_at === null && (float) $inv['amount'] === (float) $order->total) {
                    $order->update(['paid_at' => now(), 'qutpay_invoice_id' => $inv['id']]);
                }
            });
        }
        return response()->noContent(200);                // в течение 8 секунд, иначе повтор
    }
}

Без SDK подпись считается так: hash_equals('sha256=' . hash_hmac('sha256', $request->header('X-Webhook-Timestamp') . '.' . $raw, $secret), $request->header('X-Webhook-Signature', '')), плюс отказ, если X-Webhook-Timestamp старше 5 минут.

На что обратить внимание

МоментПочему важно
Маршрут вебхука в routes/api.phpВ web-группе сработает CSRF и Laravel ответит 419; мы сочтём доставку неуспешной и повторим до 11 раз за ~2 часа. Если маршрут всё же в web, добавьте его в исключения VerifyCsrfToken
$request->getContent(), а не $request->all()HMAC считается по байтам; пересобранный JSON не совпадёт никогда — подпись не сходится
ИдемпотентностьОдно и то же событие может прийти дважды; проверка paid_at === null под блокировкой строки делает повтор безвредным — идемпотентность
Страница «спасибо»Возврат на successUrl — не доказательство оплаты; показывайте «оплачено» по paid_at из базы или по $qp->getInvoice($id, true)

Ошибки SDK приходят как \QutPay\ApiException с полями ->status и ->errorCode (invalid_amount, tariff_limit_reached, kaspi_session_expired …) — каталог ошибок.

Проверка в песочнице

  1. С ключом qp_test_… откройте /orders/1/pay — вас перебросит на страницу оплаты.
  2. Имитируйте оплату: $qp->simulateInvoice($id, 'paid') или POST /api/v1/invoices/{id}/simulate с телом {"status":"paid"} — вебхук уходит по-настоящему, с подписью — песочница.
  3. Убедитесь, что у заказа появился paid_at, а повторная доставка (кнопка проверки в кабинете или повтор запроса) ничего не меняет.
  4. Переключитесь на qp_live_… после подключения кассира — чек-лист перед запуском.

Вопросы и ответы

Можно без SDK, только Http::post? Да: Http::withHeaders(['X-API-Key' => config('services.qutpay.key'), 'Idempotency-Key' => "order-{$order->id}"])->post('https://api.qut.kz/api/v1/invoices', [...])->json() — поля те же: amount, externalOrderId, description, successUrl, failUrl, metadata — все поля счёта.

Локально вебхук не доходит. В боевом режиме адрес должен быть https на настоящем домене; в песочнице допустимы http и адреса туннелей. Журнал доставок — в кабинете, раздел Интеграции.

Нужен ли polling, если есть вебхук? На странице ожидания оплаты можно раз в 5–10 секунд спрашивать свой сервер, а он — GET /invoices/{id}; это удобство для покупателя, источник истины остаётся вебхук — вебхук или опрос.

Связанные статьи

PHP SDKКак подключить PHP-клиент Qut Pay, создать счёт, сделать возврат и проверить подпись вебхука. Примеры на чистом PHP и на Laravel, правильное получение raw body и обработка ApiException.Подпись вебхука не сходитсяПодпись считается как HMAC-SHA256(secret, timestamp + "." + rawBody). Самая частая ошибка — разобрать тело в JSON и собрать обратно в строку. Примеры получения raw body для Express, Laravel, Django.Настройка вебхуковКак добавить адрес вебхука в кабинете, выбрать события и сохранить секрет, какие приходят заголовки и тело, как устроены 11 повторов, как читать журнал, протестировать адрес и что с редиректами.Симуляция оплаты в песочницеЭндпоинт simulate меняет статус счёта в песочнице: paid, failed, expired. Kaspi не вызывается, вебхук приходит как при настоящей оплате. Цикл проверки и написание автотестов.Идемпотентность: защита от дублейКак работает заголовок Idempotency-Key, как правильно составить ключ, какова роль externalOrderId, и как защититься от повторов при обработке вебхуков и при возвратах.Создание счёта: все поляПолный справочник по POST /api/v1/invoices — тип и ограничение каждого поля, все поля ответа, примеры на curl и Node, разница между qr и phone и список частых ошибок с решениями.

Остались вопросы? WhatsApp +77788813333 · kazprose@gmail.com
Написать можно и из кабинета: Поддержка.

Qut Pay — независимый сервис, не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.