# Python SDK

> Установка Python-клиента Qut Pay, создание счёта, запрос статуса, возврат и проверка подписи вебхука. Примеры на Django и Flask, правильное получение raw body и обработка QutPayError.

## Коротко

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**

## Установка

Распакуйте архив и поставьте из папки:

```bash
unzip qutpay-sdk-python.zip -d qutpay-sdk
pip install ./qutpay-sdk
```

Или просто положите `qutpay.py` рядом со своим кодом — внешних зависимостей нет, так тоже работает. Имя дистрибутива — `qutpay`, импортируемый модуль тоже `qutpay`.

## Инициализация

```python
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.

```python
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()` | Краткая информация об организации — удобно для проверки ключа |

```python
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:

```python
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, результат по каждому элементу
```

## Проверка подписи вебхука

```python
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

```python
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.

```python
# 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)
```

```python
# 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)` | Текст для человека |

```python
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`: текст может измениться, код — нет. Полный список: [Каталог ошибок](/kb/ru/error-catalog).

## Вопросы и ответы

**Можно поставить из 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")` — вебхук при этом уходит по-настоящему: [Симуляция оплаты в песочнице](/kb/ru/sandbox-simulate).
