Как интегрировать Kaspi Pay в интернет-магазин на PHP (2015)
Коротко: Зарегистрируйтесь как мерчант в Kaspi Bank, получите merchantId и apiKey. Создайте платёжный запрос через REST API (POST JSON), перенаправьте клиента на payment_url, обработайте webhook на вашем сервере после оплаты. Проверяйте подпись HMAC-SHA256 в каждом webhook.
Регистрация мерчанта
В 2015 году для подключения Kaspi Pay требовалось:
- ТОО или ИП с расчётным счётом в Kaspi Bank
- Договор на эквайринг (офисное посещение, 3-5 рабочих дней)
- Получить
merchantId,apiKey, тестовые данные
Комиссия: 1.2-1.8% в зависимости от оборота.
Класс для работы с Kaspi Pay API
<?php
// payment/KaspiPay.php
class KaspiPay {
private string $merchantId;
private string $apiKey;
private string $apiUrl;
private bool $testMode;
public function __construct(string $merchantId, string $apiKey, bool $testMode = false) {
$this->merchantId = $merchantId;
$this->apiKey = $apiKey;
$this->testMode = $testMode;
$this->apiUrl = $testMode
? 'https://api-test.kaspi.kz/pay/v1/'
: 'https://api.kaspi.kz/pay/v1/';
}
/**
* Создать платёжный запрос
* Возвращает URL для перенаправления клиента
*/
public function createPayment(array $order): array {
$payload = [
'merchantId' => $this->merchantId,
'orderId' => (string)$order['id'],
'amount' => (int)($order['total'] * 100), // В тиынах
'currency' => 'KZT',
'description' => 'Заказ #' . $order['id'],
'successUrl' => 'https://myshop.kz/order/' . $order['id'] . '/success',
'failUrl' => 'https://myshop.kz/order/' . $order['id'] . '/fail',
'callbackUrl' => 'https://myshop.kz/payment/kaspi/callback',
'customerPhone'=> $order['phone'] ?? '',
'expiresAt' => date('c', strtotime('+30 minutes')),
];
$payload['signature'] = $this->sign($payload);
$response = $this->request('POST', 'payments', $payload);
return [
'payment_id' => $response['paymentId'],
'payment_url' => $response['paymentUrl'],
'expires_at' => $response['expiresAt'],
];
}
/**
* Проверить статус платежа
*/
public function getPaymentStatus(string $paymentId): array {
$params = [
'merchantId' => $this->merchantId,
'timestamp' => time(),
];
$params['signature'] = $this->sign($params);
return $this->request('GET', 'payments/' . $paymentId . '?' . http_build_query($params));
}
/**
* Верификация подписи входящего webhook
*/
public function verifyWebhook(array $data, string $receivedSignature): bool {
$expectedSig = $this->sign($data);
return hash_equals($expectedSig, $receivedSignature);
}
private function sign(array $data): string {
// Подпись: HMAC-SHA256 от JSON + apiKey
ksort($data);
$payload = json_encode($data, JSON_UNESCAPED_UNICODE);
return hash_hmac('sha256', $payload, $this->apiKey);
}
private function request(string $method, string $endpoint, array $data = []): array {
$url = $this->apiUrl . $endpoint;
$ch = curl_init($url);
$headers = [
'Content-Type: application/json',
'X-Merchant-Id: ' . $this->merchantId,
'X-Timestamp: ' . time(),
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($response, true);
if ($httpCode >= 400 || isset($decoded['error'])) {
throw new RuntimeException(
'Kaspi Pay API error: ' . ($decoded['error']['message'] ?? "HTTP $httpCode")
);
}
return $decoded;
}
}
Контроллер: инициация оплаты
<?php
// PaymentController.php
class PaymentController {
public function initKaspi(int $orderId): void {
$order = OrderRepository::findForUser($orderId, currentUserId());
if (!$order || $order['status'] !== 'pending_payment') {
redirect('/cart?error=order_not_found');
return;
}
$kaspi = new KaspiPay(KASPI_MERCHANT_ID, KASPI_API_KEY, IS_PRODUCTION ? false : true);
try {
$payment = $kaspi->createPayment($order);
// Сохранить payment_id для последующей сверки
OrderRepository::update($orderId, [
'payment_provider' => 'kaspi',
'payment_id' => $payment['payment_id'],
'status' => 'payment_initiated',
]);
// Перенаправить на страницу Kaspi
header('Location: ' . $payment['payment_url']);
exit;
} catch (Exception $e) {
error_log('Kaspi payment error: ' . $e->getMessage());
redirect('/order/' . $orderId . '?error=payment_init_failed');
}
}
}
Обработчик Webhook
<?php
// public/payment/kaspi/callback.php
// Kaspi отправляет POST с JSON
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Логировать все webhook для отладки
file_put_contents(
'/var/log/kaspi_webhooks.log',
date('Y-m-d H:i:s') . ' ' . $rawBody . "\n",
FILE_APPEND
);
$kaspi = new KaspiPay(KASPI_MERCHANT_ID, KASPI_API_KEY);
// Проверить подпись
if (!$kaspi->verifyWebhook($data, $sig)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
exit;
}
$orderId = $data['orderId'] ?? null;
$paymentId = $data['paymentId'] ?? null;
$status = $data['status'] ?? null;
$amount = $data['amount'] ?? 0; // В тиынах
if (!$orderId || !$paymentId) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing fields']);
exit;
}
$order = OrderRepository::find((int)$orderId);
if (!$order) {
// Возвращаем 200 даже если заказ не найден - иначе Kaspi будет повторять webhook
echo json_encode(['status' => 'ok']);
exit;
}
if ($status === 'PAID' && $order['status'] === 'payment_initiated') {
// Дополнительная проверка суммы
$expectedTiyn = (int)($order['total'] * 100);
if (abs($expectedTiyn - (int)$amount) > 1) { // Допуск 1 тиын (округление)
error_log("Kaspi amount mismatch: expected $expectedTiyn, got $amount for order $orderId");
echo json_encode(['status' => 'ok']); // Не 400 - иначе повторные webhook
exit;
}
OrderRepository::markAsPaid($orderId, [
'payment_provider' => 'kaspi',
'payment_transaction_id'=> $paymentId,
'paid_amount' => $amount / 100, // Тиыны → тенге
'paid_at' => date('Y-m-d H:i:s'),
]);
// Уведомить менеджера и клиента
NotificationService::orderPaid($order);
}
// Всегда возвращаем 200 с JSON - Kaspi ожидает именно это
http_response_code(200);
echo json_encode(['status' => 'ok']);
Проверка статуса оплаты (polling для страницы успеха)
<?php
// Для страницы /order/{id}/success - проверяем что оплата действительно прошла
// Webhook может прийти позже чем пользователь вернулся на сайт
function waitForPayment(int $orderId, int $maxWaitSeconds = 10): bool {
$kaspi = new KaspiPay(KASPI_MERCHANT_ID, KASPI_API_KEY);
$start = time();
while (time() - $start < $maxWaitSeconds) {
$order = OrderRepository::find($orderId);
if ($order['status'] === 'paid') {
return true; // Webhook уже обработан
}
// Спросить у Kaspi напрямую
$paymentId = $order['payment_id'] ?? null;
if ($paymentId) {
$payment = $kaspi->getPaymentStatus($paymentId);
if ($payment['status'] === 'PAID') {
OrderRepository::markAsPaid($orderId, [/* ... */]);
return true;
}
}
sleep(1);
}
return false;
}
Почему Kaspi Pay вытеснил конкурентов в 2015
В 2015 году у Kaspi Bank было 3+ миллиона активных пользователей мобильного банка - больше чем у всех других банков Казахстана вместе. Для интернет-магазина, ориентированного на казахстанскую аудиторию, Kaspi Pay обеспечивал наибольший охват.
Конверсия на нашем тестовом проекте:
- Без Kaspi Pay: 22% успешных оплат (только VISA/MC)
- С Kaspi Pay: 41% успешных оплат (добавились Kaspi-пользователи)
Kaspi Pay оказался важнее всех остальных способов оплаты вместе взятых.