Коротко
На 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_….
Подготовка
- В кабинете https://qut.kz/app создайте API-ключ (
qp_test_…для песочницы,qp_live_…для боевого режима) и в разделе Интеграции добавьте адрес вебхукаhttps://site.kz/api/qutpay/webhook; секрет показывается один раз — настройка вебхуков. - В
.env:QUTPAY_API_KEY=…иQUTPAY_WEBHOOK_SECRET=…; вconfig/services.php:'qutpay' => ['key' => env('QUTPAY_API_KEY'), 'webhook_secret' => env('QUTPAY_WEBHOOK_SECRET')]. Ключ не должен попадать в Blade-шаблоны, JS и репозиторий. - Скачайте PHP SDK (https://api.qut.kz/downloads/qutpay-sdk-php.zip) — один файл
src/QutPay.php, PHP 7.4+, зависимостей нет. Без SDK всё то же делается черезHttp::withHeaders(['X-API-Key' => …])->post(…). - В таблицу заказов добавьте поля
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 …) — каталог ошибок.
Проверка в песочнице
- С ключом
qp_test_…откройте/orders/1/pay— вас перебросит на страницу оплаты. - Имитируйте оплату:
$qp->simulateInvoice($id, 'paid')илиPOST /api/v1/invoices/{id}/simulateс телом{"status":"paid"}— вебхук уходит по-настоящему, с подписью — песочница. - Убедитесь, что у заказа появился
paid_at, а повторная доставка (кнопка проверки в кабинете или повтор запроса) ничего не меняет. - Переключитесь на
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}; это удобство для покупателя, источник истины остаётся вебхук — вебхук или опрос.