Коротко
Python SDK — это один модуль qutpay.py. Работает на Python 3.8 и новее и использует только стандартную библиотеку (urllib, hmac, hashlib) — ни requests, ни других зависимостей не требуется.
Из модуля экспортируются три вещи:
| Имя | Для чего |
|---|---|
QutPay | Клиент API: создать счёт, прочитать, отменить, вернуть |
verify_webhook | Проверка подписи входящего уведомления |
QutPayError | Ошибка: .status (HTTP) и .code (машинный код) |
Архив: https://api.qut.kz/downloads/qutpay-sdk-python.zip
Установка
Распакуйте архив и поставьте из папки:
unzip qutpay-sdk-python.zip -d qutpay-sdk
pip install ./qutpay-sdk
Или просто положите qutpay.py рядом со своим кодом — внешних зависимостей нет, так тоже работает. Имя дистрибутива — qutpay, импортируемый модуль тоже qutpay.
Инициализация
import os
from qutpay import QutPay, QutPayError, verify_webhook
qp = QutPay(
api_key=os.environ["QUTPAY_API_KEY"], # qp_live_… или qp_test_…
base_url="https://api.qut.kz", # значение по умолчанию
timeout=20.0, # секунды
)
С пустым api_key будет ValueError. Ключ держите в переменной окружения — не в коде и тем более не в репозитории.
Создание счёта
Аргументы передаются в snake_case, SDK сам переводит их в camelCase, который ждёт API.
inv = qp.create_invoice(
amount=12500,
kind="qr", # "qr" (по умолчанию) или "phone"
description="Заказ №4471",
external_order_id="4471",
customer={"name": "Айгуль", "phone": "77011234567", "email": "a@b.kz"},
success_url="https://site.kz/ok",
fail_url="https://site.kz/fail",
metadata={"branch": "almaty-abay"},
idempotency_key="order-4471", # уходит в заголовок Idempotency-Key
)
print(inv["id"], inv["status"], inv["payUrl"], inv["expiresAt"])
amount — единственный позиционный аргумент, остальные только по имени. Ответ — обычный dict: id, status, payUrl, qrUrl, deepLink, qrImageUrl, expiresAt. Поля ответа остаются в camelCase.
Остальные методы
| Метод | Что делает |
|---|---|
qp.get_invoice(invoice_id) | Читает счёт. qp.get_invoice(invoice_id, live=True) дополнительно опрашивает Kaspi |
qp.list_invoices(status="paid", limit=50) | Список. Фильтры: status, external_order_id, search, from_, to, limit, offset |
qp.cancel_invoice(invoice_id) | Отменяет открытый счёт |
qp.refund_invoice(invoice_id, amount=None, reason=None) | Возврат. amount=None — полный |
qp.simulate_invoice(invoice_id, "paid") | Только песочница: имитация оплаты |
qp.account() | Краткая информация об организации — удобно для проверки ключа |
fresh = qp.get_invoice(inv["id"], live=True)
if fresh["status"] == "paid":
mark_order_paid("4471")
qp.refund_invoice(inv["id"], 5000, "Часть товара возвращена")
В SDK нет методов для подписок, bulk и форм-хуков. Вызывайте их напрямую по HTTP:
import json, urllib.request
req = urllib.request.Request(
"https://api.qut.kz/api/v1/invoices/bulk",
data=json.dumps({"invoices": [{"amount": 1000, "externalOrderId": "a-1"}]}).encode(),
headers={"X-API-Key": os.environ["QUTPAY_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read()) # HTTP 207, результат по каждому элементу
Проверка подписи вебхука
verify_webhook(secret, raw_body, headers, tolerance_sec=300) # → True / False
raw_body — нетронутые байты или строка. Объект из request.json не подойдёт: подпись считается ровно по пришедшим байтам. Функция читает заголовки X-Webhook-Signature и X-Webhook-Timestamp без учёта регистра, проверяет префикс sha256=, отбрасывает доставки старше 5 минут и сравнивает через hmac.compare_digest.
Пример на Flask
import json, os
from flask import Flask, request, abort, redirect
from qutpay import QutPay, QutPayError, verify_webhook
app = Flask(__name__)
qp = QutPay(api_key=os.environ["QUTPAY_API_KEY"])
SECRET = os.environ["QUTPAY_WEBHOOK_SECRET"]
@app.post("/buy")
def buy():
order = create_order(request.form)
try:
inv = qp.create_invoice(
amount=order.total,
description=f"Заказ №{order.id}",
external_order_id=str(order.id),
success_url=f"https://site.kz/orders/{order.id}",
idempotency_key=f"order-{order.id}",
)
except QutPayError as e:
app.logger.error("qutpay %s %s %s", e.status, e.code, e)
abort(502)
save_invoice_id(order.id, inv["id"])
return redirect(inv["payUrl"], code=303)
@app.post("/qutpay-webhook")
def hook():
raw = request.get_data() # bytes, нетронутые
if not verify_webhook(SECRET, raw, request.headers):
abort(401)
e = json.loads(raw)
invoice = e["invoice"]
# Идемпотентность: событие может прийти дважды
if not already_handled(invoice["id"], invoice["status"]):
if e["event"] == "invoice.paid":
mark_order_paid(invoice["externalOrderId"], invoice)
elif e["event"] == "invoice.refunded":
mark_order_refunded(invoice["externalOrderId"])
remember_handled(invoice["id"], invoice["status"])
return "", 200 # без 2xx доставка повторится 11 раз
Пример на Django
В Django тело лежит в request.body, заголовки — в request.headers. Маршрут обязательно надо освободить от проверки CSRF.
# views.py
import json, os
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from qutpay import verify_webhook
SECRET = os.environ["QUTPAY_WEBHOOK_SECRET"]
@csrf_exempt
@require_POST
def qutpay_webhook(request):
raw = request.body # bytes, нетронутые
if not verify_webhook(SECRET, raw, request.headers):
return HttpResponseForbidden(status=401)
e = json.loads(raw)
invoice = e["invoice"]
handle_event.delay(e["event"], invoice) # тяжёлую работу — в Celery
return HttpResponse(status=200)
# urls.py
from django.urls import path
from .views import qutpay_webhook
urlpatterns = [path("qutpay-webhook", qutpay_webhook)]
Если включён ATOMIC_REQUESTS, не делайте долгую работу внутри транзакции: когда обработчик отвечает дольше 8 секунд, доставка считается неуспешной и повторяется.
Обработка ошибок
На любой неуспешный ответ SDK бросает QutPayError:
| Поле | Значение |
|---|---|
.status | HTTP-статус. При сетевой ошибке и таймауте — 0 |
.code | Машинный код: invalid_amount, invoice_not_found, request_rate_limited… При таймауте timeout, при обрыве сети network_error |
str(e) | Текст для человека |
try:
inv = qp.create_invoice(amount=amount, external_order_id=order_id)
except QutPayError as e:
if e.code == "kaspi_session_expired":
notify_admin("Привязка кассира оборвалась")
elif e.code in ("tariff_limit_reached", "tariff_daily_burst"):
notify_admin("Лимит") # повторять бессмысленно
elif e.code in ("request_rate_limited", "timeout", "network_error"):
retry_later() # бэкофф 1, 2, 4, 8 секунд
else:
raise
Логику стройте по .code: текст может измениться, код — нет. Полный список: Каталог ошибок.
Вопросы и ответы
Можно поставить из PyPI? Нет, пакет не опубликован в публичном реестре. Скачайте архив и выполните pip install ./qutpay-sdk либо скопируйте файл к себе.
Есть ли async-версия? Нет, клиент синхронный. В FastAPI вызывайте его через run_in_threadpool или напишите свой клиент на httpx — verify_webhook при этом можно использовать как есть.
Как получить raw body в FastAPI? raw = await request.body(), заголовки — request.headers.
Почему поля ответа не в snake_case? Входные аргументы сделаны в snake_case для удобства, а ответ возвращается как есть от API: payUrl, externalOrderId, expiresAt.
С чего начать тестирование? Создайте счёт тестовым ключом и вызовите qp.simulate_invoice(inv["id"], "paid") — вебхук при этом уходит по-настоящему: Симуляция оплаты в песочнице.