Коротко
PHP SDK — это один файл: src/QutPay.php. Работает на PHP 7.4 и новее с расширениями curl и json, других зависимостей нет. Подключается и через Composer, и обычным require.
Внутри три класса:
| Класс | Для чего |
|---|---|
\QutPay\Client | Вызовы API: создать счёт, прочитать, отменить, вернуть |
\QutPay\Webhook | Проверка подписи входящего уведомления |
\QutPay\ApiException | Ошибка: ->status (HTTP) и ->errorCode (машинный код) |
Архив: https://api.qut.kz/downloads/qutpay-sdk-php.zip
Установка
Самый простой путь — скопировать src/QutPay.php из архива к себе и подключить напрямую:
require __DIR__ . '/vendor/qutpay/QutPay.php';
Если пользуетесь Composer, распакуйте архив в отдельную папку и подключите как локальный репозиторий:
{
"repositories": [{ "type": "path", "url": "./vendor-local/qutpay-sdk" }],
"require": { "qutpay/sdk": "*" }
}
Имя пакета — qutpay/sdk, автозагрузка PSR-4 настроена на пространство имён QutPay\.
Инициализация
$qp = new \QutPay\Client(getenv('QUTPAY_API_KEY')); // baseUrl по умолчанию https://api.qut.kz
// При необходимости: new \QutPay\Client($key, 'https://api.qut.kz', 20); // третий аргумент — таймаут в секундах
С пустым ключом конструктор бросит InvalidArgumentException. Не держите ключ в коде — читайте его из переменной окружения или конфига, который не попадает в репозиторий.
Создание счёта
$inv = $qp->createInvoice([
'amount' => 12500,
'kind' => 'qr', // 'qr' (по умолчанию) или 'phone'
'description' => 'Заказ №4471',
'externalOrderId' => '4471',
'customer' => ['name' => 'Айгуль', 'phone' => '77011234567', 'email' => 'a@b.kz'],
'successUrl' => 'https://site.kz/ok',
'failUrl' => 'https://site.kz/fail',
'metadata' => ['branch' => 'almaty-abay'],
'idempotencyKey' => 'order-4471', // уходит в заголовок Idempotency-Key
]);
header('Location: ' . $inv['payUrl']);
exit;
Ответ — ассоциативный массив: id, status, payUrl, qrUrl, deepLink, qrImageUrl, expiresAt.
Остальные методы
| Метод | Что делает |
|---|---|
$qp->getInvoice($id) | Читает счёт. $qp->getInvoice($id, true) дополнительно опрашивает Kaspi и обновляет статус |
$qp->listInvoices(['status' => 'paid', 'limit' => 50]) | Список. Фильтры: status, externalOrderId, search, from, to, limit, offset |
$qp->cancelInvoice($id) | Отменяет открытый счёт |
$qp->refundInvoice($id, $amount = null, $reason = null) | Возврат. $amount = null — полный |
$qp->simulateInvoice($id, 'paid') | Только песочница: имитация оплаты |
$qp->account() | Краткая информация об организации — удобно для проверки ключа |
$fresh = $qp->getInvoice($inv['id'], true);
if ($fresh['status'] === 'paid') {
markOrderPaid('4471');
}
$qp->refundInvoice($inv['id'], 5000, 'Часть товара возвращена');
В SDK нет методов для подписок, bulk и форм-хуков. Вызывайте их напрямую через curl — эндпоинты описаны на api.qut.kz/docs.
Вебхук: чистый PHP
<?php
require __DIR__ . '/QutPay.php';
$secret = getenv('QUTPAY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // нетронутое тело
if (!\QutPay\Webhook::verify($secret, $raw, getallheaders())) {
http_response_code(401);
exit;
}
$e = json_decode($raw, true);
$event = $e['event'] ?? '';
$invoice = $e['invoice'] ?? [];
// Идемпотентность: событие может прийти дважды
if (!alreadyHandled($invoice['id'], $invoice['status'])) {
if ($event === 'invoice.paid') markOrderPaid($invoice['externalOrderId'], $invoice);
if ($event === 'invoice.refunded') markOrderRefunded($invoice['externalOrderId']);
rememberHandled($invoice['id'], $invoice['status']);
}
http_response_code(200); // без 2xx доставка повторится 11 раз
Webhook::verify($secret, $rawBody, $headers, $toleranceSec = 300) ищет заголовки без учёта регистра, проверяет префикс sha256=, отбрасывает доставки старше 5 минут и сравнивает через hash_equals.
В некоторых конфигурациях FastCGI функции getallheaders() нет. Тогда соберите заголовки вручную:
$headers = [
'X-Webhook-Signature' => $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
'X-Webhook-Timestamp' => $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
];
Вебхук: Laravel
В Laravel тело берите через $request->getContent() — он возвращает исходную строку. $request->all() не подходит.
// routes/api.php
Route::post('/qutpay-webhook', [QutPayController::class, 'handle']);
// app/Http/Controllers/QutPayController.php
use Illuminate\Http\Request;
class QutPayController extends Controller
{
public function handle(Request $request)
{
$raw = $request->getContent();
$ok = \QutPay\Webhook::verify(
config('services.qutpay.webhook_secret'),
$raw,
$request->headers->all() // значения массивами — verify это понимает
);
if (!$ok) {
return response()->noContent(401);
}
$e = json_decode($raw, true);
ProcessQutPayEvent::dispatch($e); // тяжёлую работу — в очередь
return response()->noContent(200);
}
}
Не забудьте про две вещи:
- CSRF. Держите маршрут в
routes/api.phpлибо добавьте его в$exceptуVerifyCsrfToken— иначе Laravel вернёт 419, а мы посчитаем доставку неуспешной и начнём повторять. - Быстрый ответ. Тяжёлую логику (письма, пересчёт отчётов) уводите в очередь, а 2xx возвращайте сразу.
В контроллере создания счёта:
$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}",
]);
return redirect()->away($inv['payUrl']);
Обработка ошибок
На любой неуспешный ответ SDK бросает \QutPay\ApiException:
| Поле | Значение |
|---|---|
->status | HTTP-статус. При сетевой ошибке — 0 |
->errorCode | Машинный код: invalid_amount, invoice_not_found, request_rate_limited… При обрыве сети network_error |
->getMessage() | Текст для человека |
try {
$inv = $qp->createInvoice([...]);
} catch (\QutPay\ApiException $e) {
switch ($e->errorCode) {
case 'kaspi_session_expired':
notifyAdmin('Привязка кассира оборвалась');
break;
case 'tariff_limit_reached':
case 'tariff_daily_burst':
notifyAdmin('Лимит'); // повторять бессмысленно
break;
case 'request_rate_limited':
case 'network_error':
retryLater(); // бэкофф 1, 2, 4, 8 секунд
break;
default:
report($e);
}
abort(502);
}
Решение принимайте по errorCode: текст может измениться, код — нет. Полный список: Каталог ошибок.
Вопросы и ответы
Можно поставить из Packagist? Нет, пакет не опубликован в публичном реестре. Скачайте архив и подключите как репозиторий типа path либо просто скопируйте файл.
Нужен ли Guzzle? Нет. SDK использует только расширение curl.
У меня магазин на WordPress, нужен ли этот SDK? Нет. Для WooCommerce есть готовый плагин: Плагин WooCommerce.
Как получить raw body в Symfony? Так же, как в Laravel: $request->getContent() и $request->headers->all().
Подпись не сходится. Значит тело где-то изменилось: не пересобирайте его через json_decode/json_encode, проверьте, что кеш и защитные плагины не трогают тело запроса. Подробнее: Подпись вебхука не сходится.