Kaspi Pay API Integration Guide for Web and Mobile Apps (2026)
Kaspi Pay is the dominant payment method in Kazakhstan - used by over 13 million active users (85%+ of the adult population). If you're building anything for the Kazakh market, Kaspi Pay integration is not optional. This is the most comprehensive English-language guide to integrating it.
Overview of Kaspi Pay Payment Methods
Kaspi offers several payment flows depending on your use case:
| Method | Use Case | User Action |
|---|---|---|
| QR Code | In-store, invoices | User scans QR in Kaspi app |
| Deep Link | Mobile apps | Redirects to Kaspi app |
| Payment Link | Web, WhatsApp, SMS | User taps link → Kaspi app |
| eCommerce API | Web checkout | Redirect to Kaspi payment page |
For most web/mobile integrations: use the eCommerce API (redirect flow) or Payment Link.
Getting API Access
- Register as a merchant at kaspi.kz/merchantapi
- Pass business verification (BIN/IIN, bank account in KZ)
- Receive:
TradePointId, API credentials, test environment access
Test environment: https://testpay.kaspi.kz
Production: https://pay.kaspi.kz
Payment Flow (eCommerce API)
1. Your server creates an order → sends to Kaspi API
2. Kaspi returns a payment URL
3. You redirect the user to that URL (or open in WebView on mobile)
4. User pays in Kaspi app / web interface
5. Kaspi sends webhook to your server
6. Your server confirms payment → updates order
Step 1: Create a Payment Order
// Node.js example
const axios = require('axios');
const crypto = require('crypto');
const KASPI_TRADE_POINT_ID = process.env.KASPI_TRADE_POINT_ID;
const KASPI_API_KEY = process.env.KASPI_API_KEY;
const BASE_URL = 'https://pay.kaspi.kz'; // or testpay.kaspi.kz
async function createKaspiOrder(orderData) {
const payload = {
amount: orderData.amount, // In tenge, integer (e.g. 5000 = 5000 KZT)
orderId: orderData.orderId, // Your unique order ID
returnUrl: orderData.returnUrl, // Redirect after payment
failUrl: orderData.failUrl, // Redirect on failure
tradePointId: KASPI_TRADE_POINT_ID,
description: orderData.description,
};
// Generate signature
const signatureString = `${payload.amount}${payload.orderId}${KASPI_API_KEY}`;
const signature = crypto
.createHash('sha256')
.update(signatureString)
.digest('hex');
const response = await axios.post(
`${BASE_URL}/api/v1/orders/create`,
{ ...payload, signature },
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${KASPI_API_KEY}`,
},
}
);
return response.data; // Contains { paymentUrl, orderId }
}
// Usage in your checkout handler
app.post('/checkout', async (req, res) => {
const { cartTotal, orderId } = req.body;
const kaspiOrder = await createKaspiOrder({
amount: Math.round(cartTotal),
orderId: `ORDER-${orderId}`,
returnUrl: `https://yoursite.kz/payment/success?orderId=${orderId}`,
failUrl: `https://yoursite.kz/payment/fail?orderId=${orderId}`,
description: `Order #${orderId}`,
});
// Redirect user to Kaspi payment page
res.json({ paymentUrl: kaspiOrder.paymentUrl });
});
Step 2: Handle the Webhook
Kaspi sends a POST request to your webhook URL when payment status changes.
// Webhook handler
app.post('/webhooks/kaspi', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = JSON.parse(req.body);
// Verify signature
const expectedSignature = crypto
.createHash('sha256')
.update(`${payload.orderId}${payload.amount}${KASPI_API_KEY}`)
.digest('hex');
if (payload.signature !== expectedSignature) {
return res.status(400).json({ error: 'Invalid signature' });
}
switch (payload.status) {
case 'APPROVED':
await db.orders.update({
where: { kaspiOrderId: payload.orderId },
data: {
status: 'paid',
paidAt: new Date(),
kaspiTransactionId: payload.transactionId,
}
});
await fulfillOrder(payload.orderId);
break;
case 'DECLINED':
case 'CANCELLED':
await db.orders.update({
where: { kaspiOrderId: payload.orderId },
data: { status: 'cancelled' }
});
break;
}
// Always respond 200 to acknowledge receipt
res.status(200).json({ received: true });
});
Step 3: Verify Payment Status (Polling Fallback)
Don't rely on webhooks alone - they can fail. Check status on your success/return URL:
async function checkKaspiOrderStatus(kaspiOrderId) {
const response = await axios.get(
`${BASE_URL}/api/v1/orders/${kaspiOrderId}/status`,
{
headers: { 'Authorization': `Bearer ${KASPI_API_KEY}` }
}
);
return response.data.status; // 'APPROVED' | 'PENDING' | 'DECLINED' | 'CANCELLED'
}
// On return URL handler
app.get('/payment/success', async (req, res) => {
const { orderId } = req.query;
const order = await db.orders.findOne({ id: orderId });
// Always verify - don't trust the URL parameter alone
const kaspiStatus = await checkKaspiOrderStatus(order.kaspiOrderId);
if (kaspiStatus === 'APPROVED') {
res.render('success', { order });
} else {
res.render('pending', { order }); // Payment may still be processing
}
});
Mobile Integration: Flutter Deep Link
For mobile apps, use deep links to open the Kaspi app directly:
// pubspec.yaml
dependencies:
url_launcher: ^6.2.0
// payment_service.dart
import 'package:url_launcher/url_launcher.dart';
class KaspiPaymentService {
/// Opens Kaspi app for payment. Returns to your app via deeplink callback.
Future<void> openKaspiPayment(String paymentUrl) async {
final uri = Uri.parse(paymentUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// Kaspi app not installed - open web fallback
await launchUrl(
Uri.parse(paymentUrl),
mode: LaunchMode.inAppWebView,
);
}
}
}
Handle return deep link in your app:
// In AndroidManifest.xml
// <intent-filter>
// <action android:name="android.intent.action.VIEW" />
// <data android:scheme="yourapp" android:host="payment" />
// </intent-filter>
// In your Flutter app
class PaymentResultScreen extends StatefulWidget {
@override
void initState() {
super.initState();
// Parse result from deep link
_handleIncomingLink();
}
void _handleIncomingLink() {
// yourapp://payment?status=success&orderId=ORDER-123
final uri = Uri.parse(widget.deepLink);
final status = uri.queryParameters['status'];
final orderId = uri.queryParameters['orderId'];
if (status == 'success') {
// Verify server-side before showing success screen
_verifyPayment(orderId);
}
}
}
QR Code Payment Flow
For physical retail or invoice-based payments:
async function generateKaspiQR(amount, orderId) {
const response = await axios.post(`${BASE_URL}/api/v1/qr/create`, {
amount,
orderId,
tradePointId: KASPI_TRADE_POINT_ID,
signature: generateSignature(amount, orderId),
});
// response.data.qrCode - base64 encoded QR image
// response.data.qrToken - token to check payment status
return response.data;
}
// Poll for QR payment status (user hasn't confirmed yet)
async function pollQRStatus(qrToken, maxAttempts = 60) {
for (let i = 0; i < maxAttempts; i++) {
await sleep(3000); // Poll every 3 seconds
const status = await axios.get(`${BASE_URL}/api/v1/qr/${qrToken}/status`, {
headers: { Authorization: `Bearer ${KASPI_API_KEY}` }
});
if (status.data.status === 'APPROVED') return 'paid';
if (status.data.status === 'DECLINED') return 'failed';
// 'PENDING' - continue polling
}
return 'timeout';
}
Common Errors and Solutions
| Error Code | Meaning | Fix |
|---|---|---|
INVALID_SIGNATURE |
Signature mismatch | Check field order in signature string |
ORDER_ALREADY_EXISTS |
Duplicate orderId | Use unique IDs (UUID or timestamp) |
TRADE_POINT_NOT_FOUND |
Wrong TradePointId | Verify env variable |
AMOUNT_TOO_SMALL |
Below minimum (100 KZT) | Validate before sending |
INVALID_RETURN_URL |
URL not whitelisted | Register return URLs in merchant portal |
Testing
Use test card in Kaspi test environment:
- Any amount accepted in test mode
- Use
testpay.kaspi.kzendpoint - Kaspi test app available separately (request from Kaspi merchant support)
Notes for Production
- Register all
returnUrlandfailUrldomains in the Kaspi merchant portal before going live - Store
kaspiTransactionIdfrom webhook - needed for refunds - Refund API:
POST /api/v1/orders/{orderId}/refundwith amount and signature - Rate limits: 100 requests/minute per TradePointId