Главная › Документация › Fallback-комбо
Pattern · Production · KK17 BEAUTYFallback-сценарий: invoice push + PDF-чек
Готовый паттерн: основной поток — Kaspi Pay invoice через
pay.proverkacheka.kz, страховка — загрузка PDF-чека
через api.proverkacheka.kz. Когда invoice push не доходит
(отключены уведомления, нет сети, таймаут API, webhook потерялся) —
пользователь всё равно может оплатить по обычной ссылке Kaspi и
прислать PDF, а бот его автоматически верифицирует.
Показать содержимое
Зачем нужна комбинация
Invoice push — самый удобный способ оплаты: клиент жмёт одну кнопку в Kaspi.kz и сделка готова. Но он завязан на инфраструктуру push-уведомлений Kaspi и доставку нашего webhook'а к вам. Любое из звеньев иногда падает:
- У клиента выключены push-уведомления Kaspi → инвойс висит в фоне, клиент не понимает, что что-то ему пришло.
- Клиент авторизован в Kaspi на другом номере — phoneNumber попал в нерабочий аккаунт.
- Сетевой таймаут при создании счёта:
POST /v1/invoice/createвернул 504/timeout, но invoice уже мог уехать в Kaspi. - Webhook потерялся — клиент оплатил, но ваш
POST-приёмник в момент доставки был недоступен (и retry ещё не докрутил). - Клиент захотел оплатить с другой карты / другого аккаунта Kaspi — то есть в принципе не через тот номер, что в invoice.
В каждом из этих случаев у Kaspi после оплаты всё равно есть PDF-чек
— он приходит клиенту по почте и доступен в истории Kaspi. Поэтому
разумный fallback — попросить клиента прислать PDF-файл этого чека и
верифицировать его через api.proverkacheka.kz/upload:
сервис проверяет QR-код, сверяет ИИН/БИН продавца и сумму, и подтверждает
сделку без участия webhook'а.
Поток оплаты — диаграмма
Когда срабатывает fallback
Боевая логика (см. ниже) переключается с invoice-flow на PDF-flow в одном из четырёх случаев:
| Триггер | Что показываем клиенту |
|---|---|
Бот не знает каноничный номер клиента (нормализация вернула None) | Сразу legacy-экран Kaspi-link + PDF |
POST /v1/invoice/create вернул не-200 / timeout / connect error | Молча legacy-экран. Админу уходит DM с reason для отладки |
В ответе 200 — но нет paymentId | То же: legacy-экран + DM админу |
| Локальная БД не сохранила запись об invoice (диск, lock) → нечем сверять webhook | Best-effort POST /v1/invoice/{id}/cancel, затем legacy-экран |
Реализация: код из боевого бота
Ниже — выжимка из @KarinaKunuspekovaBot
(онлайн-курс KK17 BEAUTY). Python 3.11, aiohttp,
python-telegram-bot v21.
1. Тонкий клиент для pay.proverkacheka.kz
import aiohttp, asyncio, logging
_TIMEOUT = aiohttp.ClientTimeout(total=8) # короче спиннера callback'а
async def create_invoice(phone, amount, *, user_id, comment=None,
item_name="Курс KK17 BEAUTY"):
"""Push Kaspi Pay invoice. Returns (payload, None) on success,
(None, reason) on any failure — caller falls back silently."""
body = {
"phoneNumber": phone,
"items": [{"name": item_name, "price": amount, "count": 1}],
}
if comment:
body["comment"] = comment
headers = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
try:
async with aiohttp.ClientSession(timeout=_TIMEOUT) as s:
async with s.post(f"{API_URL}/v1/invoice/create",
headers=headers, json=body) as r:
payload = await r.json(content_type=None)
if r.status != 200 or not payload.get("paymentId"):
return None, f"HTTP {r.status} — {payload}"
return payload, None
except asyncio.TimeoutError:
return None, "timeout — webhook still may reconcile"
except Exception as e:
return None, f"{type(e).__name__}: {e}"
2. Решение «push или fallback»
async def start_kaspi_pay(user_id, phone, amount, ctx, *, tier_key):
canonical = normalize_phone(phone)
if not canonical:
await show_legacy_kaspi_screen(ctx, user_id) # ← fallback #1
return
# Tier-change protection: отмени все pending счета этого юзера ПЕРЕД тем,
# как пушить новый. Иначе клиент может оплатить старый счёт на другой
# тариф — выдадите доступ не туда.
for old in db.list_pending_invoices_for_user(user_id):
await cancel_invoice(old["payment_id"]) # best-effort
db.set_invoice_status(old["payment_id"], "failed")
payload, error = await create_invoice(
canonical, amount, user_id=user_id,
comment=f"user {user_id} · {tier_key}",
)
if not payload:
# HTTP 412 (kaspi_session_required) — мерчант перелогинился в Kaspi
# у себя на телефоне, наша сессия выселена. Никакие retry не помогут —
# оператор должен пройти /login в @PayProverkaBot. Прокидывайте эту
# строку в DM админу как есть, чтобы он не гадал, что сломалось.
await notify_admin_html(f"Kaspi Pay failed: {html_escape(error)}")
await show_legacy_kaspi_screen(ctx, user_id) # ← fallback #2
return
if not db.save_invoice(payload["paymentId"], user_id,
canonical, amount, tier_key=tier_key):
await cancel_invoice(payload["paymentId"]) # не оставляем ghost
await show_legacy_kaspi_screen(ctx, user_id) # ← fallback #3
return
await show_pending_invoice_screen(ctx, user_id, canonical, amount)
kaspi_session_required.
Это единственный код ответа от POST /v1/invoice/create, который
нельзя молча класть в общий fallback: 412 означает, что наша
сессия Kaspi выселена (мерчант перелогинился в приложении Kaspi Pay для
бизнеса у себя на телефоне). Никакие retry это не починят — нужна ручная
команда /login в @PayProverkaBot.
В DM админу пишите эту строку дословно, чтобы оператор не гадал, что
именно сломалось.
cancel_invoice по
pending-инвойсам перед созданием нового — это страховка от «двойного счёта»
в Kaspi у клиента: если он переключил тариф в боте, старый push не должен
остаться pay-able. Иначе клиент случайно оплатит счёт на старую сумму, а
grant-logic выдаст не тот тариф.
3. Обработка PDF — второй путь
Когда клиент шлёт PDF-чек (Telegram-документ с mime/pdf) —
бот скачивает байты и отправляет в api.proverkacheka.kz/upload:
async def verify_receipt(pdf_bytes, user_id, amount, iin, ip_name=None):
"""POST /upload to api.proverkacheka.kz. Returns 'paid', 'partial',
or 'invalid'."""
data = aiohttp.FormData()
data.add_field("api_key", PROVERKACHEKA_API_KEY)
data.add_field("user_id", str(user_id))
data.add_field("price", str(amount))
data.add_field("iin", iin) # ИИН/БИН продавца
if ip_name: # опционально — страховка к ИИН
data.add_field("ip_name", ip_name)
data.add_field("use_balance", "true") # копим частичные оплаты
data.add_field("max_overpay", "1000") # терпим до 1000 ₸ переплаты
data.add_field("max_age", "7") # чек старше 7 дней — отказ (анти-replay)
data.add_field("file", pdf_bytes,
filename="receipt.pdf",
content_type="application/pdf")
async with aiohttp.ClientSession() as s:
async with s.post("https://api.proverkacheka.kz/upload",
data=data) as r:
j = await r.json(content_type=None)
if r.status != 200:
return "invalid", j
# due — число (0.0 при use_balance=true и полной оплате); без
# use_balance Kaspi API возвращает null. Сравнение с 0 покрывает оба.
if (j.get("due") or 0) == 0:
return "paid", j
return "partial", j
use_balance=true.
Без этого флага недоплата (например, клиент перевёл 9 800 ₸ вместо 10 000 ₸)
возвращается полным отказом. С use_balance=true разница уходит
на per-user баланс — и бот может сказать «доплатите 200 ₸», вместо «не оплачено».
Подробнее в /docs/#balance.
- повторы при transient-ошибках (
retry_after, 429/5xx) до 3×; - четвёртая ветка ответа
duplicate— для уже использованного чека; - state-machine на
kaspi_pay_invoices.status— отказ отpaid → paid; - throttling админских алертов на webhook'е (1 DM/час);
- полная схема SQL-таблиц, env-вары, normalize_phone, helpers БД — ничего не нужно «угадывать».
Markdown-блок задуман специально под вставку в AI-агента (Claude Code, Cursor, …) — клиент копирует и получает рабочую интеграцию.
Сверка: webhook + PDF не должны дать двойного гранта
Оба канала могут сработать одновременно: клиент заплатил по invoice push, webhook доставился, бот выдал доступ — а через 30 секунд клиент от усердия ещё и прислал PDF. PDF-верификатор не должен второй раз «купить» курс. Решение строится из девяти слоёв — каждый страхует от своего сценария:
-
Server-side cross-service dedup (бесплатно, прозрачно).
При переходе invoice в
Processed,pay.proverkacheka.kzавтоматически резервируетcheck_number = QR<paymentId>наapi.proverkacheka.kz. Если клиент потом всё-таки загрузит PDF от того же платежа в/upload— upstream вернёт«Чек уже обработан ранее», а вашverify_receipt()отправит это в веткуduplicate. Это первая линия защиты, работает без вашего кода и страхует даже если оба канала сработали независимо. -
Идемпотентный grant. Функция
mark_bought(user_id)в БД возвращаетTrueтолько при первом переходе0 → 1; повторные вызовы —False. Любая ветка (webhook / PDF / админский/mark_paid) дёргает одну и ту же функцию. (Для подписочной модели ключ другой —(source, external_id), см. Idempotency models.) -
Регистрация по
paymentId. Webhook отpay.proverkacheka.kzприносит тот жеpaymentId, который мы сохранили приcreate_invoice. СверкаWHERE payment_id = ?однозначно связывает webhook с конкретным юзером. -
State-machine на
kaspi_pay_invoices.status. Функцияset_invoice_status()разрешает толькоpending → {paid, failed, expired, refunded}иpaid → refunded. Если webhook'ом прилетит «опоздавший»payment.successпослеpayment.failed(такое случается — Kaspi иногда дублирует события), переход отклоняется и второй grant не происходит. -
Orphan reconciliation по
clientMobile. Самый частый «потерянный» платёж:create_invoiceупал в клиентский timeout, но сервер всё-таки создал счёт и доставил его клиенту. Клиент платит, webhook прилетает сpaymentId, которого у вас в БД нет. Не отвечайте503наpayment.successбезусловно — сначала попробуйте сопоставить поclientMobile(это поле в теле webhook'а, неphoneNumber) и сумме. Если ровно один пользователь с этим телефоном и сумма совпала с одной из ваших цен — авто-grant + админский алерт «orphan reconciled». Несколько совпадений или непонятная сумма — алерт без действия,/grant <user_id> <tier>руками. На orphan-событияpayment.failed/expired/refundedотвечайте200+ алерт (никакого стейт-перехода).503оставляйте только когда сам бот не подцеплен к webhook-роутеру (misconfig) — тогда есть смысл, чтобы отправитель ретраил с backoff'ом, пока вы чините. -
Уже куплено → DM клиенту, а не молчаливый no-op. Дедуп-страж
правильно блокирует двойной грант, но если клиент от усердия
оплатил повторно — 10 000 ₸ ушло, доступ уже был, никакого подтверждения не пришло.
Это выглядит как баг. Скажите ему: «Платёж получен, доступ уже активен.»
Опционально — сразу
POST /v1/invoice/{id}/refund. -
Локальный lock на одновременную проверку PDF. Set
_checking_receiptв памяти бота не пускает второй/uploadпо тому жеuser_id, пока первый не завершился. Это страхует от двойного нажатия «отправить» в Telegram. -
HTML-escape + truncate в админских DM. Тело ошибки от Kaspi
может содержать
</>/&— без escape Telegram-парсер HTML молча уронит сообщение, и вы не узнаете об инциденте. Дополнительно режьте на 400 символов: Kaspi иногда возвращает многокилобайтные тела, упирающиеся в лимит 4096 символов Telegram. -
check_numberсUNIQUE-ограничением — физическая защита от replay. В ответе/uploadприходит уникальныйcheck_numberдля каждого чека Kaspi. Положите его в отдельную таблицуreceiptsсPRIMARY KEY (check_number)и оборачивайте grant в try/except: при исключении — откатывайте claim, иначе transient-ошибка в активации навсегда «съест» чек, и клиент не сможет переотправить.
X-Webhook-Signature, каждый mismatch без throttling'а
выльется в DM админу. В production-receiver'е (см. Markdown-блок) стоит
1 DM/час ceiling — после первого алерта остальные подавляются.
type в теле
всегда равно строке "invoice" (это resource discriminator, не имя
события). Имя события лежит в event. Маршрутизация по type ==
"payment.success" никогда не срабатывает — каждый оплативший клиент остаётся
без доступа. Полный пример рабочего receiver'а — в
Markdown-гайде (Step 7: «What your webhook will actually receive»).
clientMobile, не phoneNumber.
В запросе POST /v1/invoice/create вы отправляете
phoneNumber. А в теле webhook'а и в ответе GET /v1/invoice/{id}
то же значение возвращается под именем clientMobile. Если вы копируете
имя поля из запроса в обработчик webhook'а — оно всегда будет None, и
orphan-reconciliation по телефону (слой 5) молча сломается.
Idempotency models: выберите одну
Главная развилка при копировании этого паттерна в новый бот. Решите ДО написания grant-кода:
| Модель | Ключ идемпотентности | Когда применять |
|---|---|---|
| A — one-time покупка (курс, e-book, разовый доступ) |
user_idmark_bought() делает 0 → 1 один раз |
Юзера нельзя «купить» дважды. Повторный платёж — это сигнал «спросите, не вернуть ли деньги». |
| B — стэкуемая подписка (абонемент, продление) |
(source, external_id)source = "kaspi_pay" для webhook'а, "receipt" для PDF |
Юзер легитимно может оплатить много раз — каждый раз +30 дней. bought-флага нет, есть starts_at = max(now, prior_expires_at). |
Разные source у push и PDF означают, что для физического
одного и того же платежа idempotency-ключи разные — и физический
cross-source guard в этой модели даёт именно claim_receipt(check_number)
(слой 9). Полная реализация обеих моделей — в Markdown-гайде, Step 9.
UX-нюансы
- Никогда не сообщайте клиенту, что invoice не создался. С его точки зрения это «системная техническая хрень». Просто покажите ту же оплату через обычную ссылку — он не заметит разницы.
-
Кэшируйте номер после первого
request_contact. Telegram не запоминает, какой контакт пользователь шарил с конкретным ботом — это надо делать самим. На первой покупке проверьтеcontact.user_id == update.effective_user.id(контакты из адресной книги user_id не приносят), сохраните номер заtg_user_id, и на всех последующих покупках сразу пушьте — без повторного запроса контакта. Trust-assertion остаётся валидной, потому что вы её проверили при кэшировании. -
Auto-trigger вместо picker'а. Если push сконфигурирован и номер уже
закэширован — после выбора тарифа сразу пушьте invoice (или просите контакт,
если номера нет). Не показывайте параллельно кнопки «📲 push» и «💳 Kaspi-ссылка»
— клиенты случайно жмут на ссылку, проваливаются в более медленный PDF-flow.
Legacy-ссылка должна всплывать только когда
POST /v1/invoice/createвернул не-2xx или таймаут (см. fallback в разделе «Реализация»). -
Security: строгая проверка владельца контакта. Без неё атакующий
может pushить invoice'ы на произвольные номера, отправив вам чужой
vCard. Telegram заполняет
contact.user_idтолько когда пользователь шарит свой собственный контакт через кнопкуrequest_contact. Любой контакт из адресной книги —user_id == null. Отклоняйте и просите нажать именно кнопку. - Отдельная кнопка «Прислать чек PDF» на экране ожидания invoice — на случай, если push не пришёл. Не делайте её главной (главная — «оплачу по push»), но и не прячьте.
- Принимайте и фото-скриншот, но в этом случае сразу отвечайте «откройте чек в Kaspi → Поделиться → PDF» — скриншоты не валидируются по QR.
- Учитывайте формат номера на входе. Telegram-контакт даёт
+7…, ручной ввод —8…, иногда без кода. Нормализуйте до канонического8XXXXXXXXXXодин раз — и используйте только его для invoice и для поиска в БД. - Лог всех ошибок invoice идёт админу в DM. Это критично: pay-сервис может временно сломаться, а вы об этом узнаете только из админских жалоб «у меня не пришло уведомление». DM с reason приходит мгновенно.
Чек-лист интегратора
- API-ключ от @PayProverkaBot + API-ключ от @ProverkaChekakzbot.
- ИИН/БИН вашей организации (берётся из любого чека Kaspi).
-
KASPI_PAY_WEBHOOK_SECRET≥ 16 символов, отдельный от вашего TelegramWEBHOOK_SECRET; env-вары проверяются на старте (boot fail при пустых). -
Boot-проба
GET /v1/auth/meна старте бота — громкий лог, если ключ протух / был revoke'нут. Иначе узнаете об этом только через тихие 401 в production. - Реализован
create_invoice()с таймаутом ≤ 10 секунд и возвратом(None, reason)на любую ошибку. -
HTTP 412 (
kaspi_session_required) сурфейсится отдельно — в DM админу пишите строку дословно с подсказкой «/loginв @PayProverkaBot». Никакие retry не помогут. -
normalize_phone()принимает 10-значный вход (7019009393— без префикса страны). Официальный API его принимает; ваша валидация не должна резать. -
Перед каждым новым push'ем отменяются все pending-инвойсы юзера
(
list_pending_invoices_for_user→cancel_invoice). Иначе клиент после смены тарифа может оплатить старый счёт. -
Реализован
verify_receipt()сiin=,use_balance=true,max_overpay=1000иmax_age=7(анти-replay — старые чеки отказываются). -
verify_receipt()повторяет transient-ошибки (HTTP 0/429/5xx илиretry_afterв теле) до 3 раз с backoff'ом. Без этого один hiccup на стороне Kaspi теряет платёж. -
verify_receipt()различает 4 исхода:paid/partial/duplicate/invalid. Наduplicateпишите клиенту «этот чек уже зачтён ранее», иначе он будет слать его повторно. -
Idempotency-модель выбрана осознанно
(A или B) и используется из ВСЕХ путей —
webhook, PDF, ручной
/grant. -
Переходы в
kaspi_pay_invoices.statusтолько черезset_invoice_status();paid → paidотказывается — защита от «опоздавшего»payment.success. -
Таблица
receiptsсcheck_number PRIMARY KEY; каждый paid-PDF идёт черезclaim_receipt()— с откатом claim'а при исключении в grant'е, иначе transient-фейл активации навсегда «съест» чек. - Подписаны на webhook
payment.success / failed / expired / refundedна pay.proverkacheka.kz (см. /docs/#webhooks). -
Webhook-receiver роутится по
event["event"], НЕ поevent["type"](см. сверку и Markdown-гайд, Step 7). Опционально — fallback наX-Webhook-Eventheader иstatus-field map. -
Webhook читает
clientMobileиз тела (неphoneNumber— это поле запроса, не ответа). -
Receiver делает orphan-reconciliation при неизвестном
paymentIdнаpayment.success: матч поclientMobile+ сумме (точное совпадение с одним тарифом) → авто-grant + админский алерт. Если совпадений 0 или >1 — алерт без действия.503оставляйте только когда сам бот не подцеплен к webhook-роутеру (misconfig); на «не нашли paymentId» отвечайте 200. -
Receiver логирует
X-Webhook-Attempt— без этого отлаживать повторные доставки практически невозможно. -
Receiver DM-ит клиента (или делает refund) на повторный платёж,
когда
is_bought()ужеtrue. - Алерты на mismatch HMAC throttle-ятся (≤1 DM/час) — без этого сканер endpoint'а флудит ваш канал.
-
Все админские DM HTML-escape'ятся и режутся до 400 символов.
Тело Kaspi-ошибки с
</>/&без escape молча уронит сообщение в Telegram-парсере. -
SQL-схема создана:
kaspi_pay_invoices(PKpayment_id, опц.tier_keyдля модели B),users(phone)для orphan-lookup,receipts(PKcheck_number). -
JSON в receiver'е парсится из тех же байтов, что и HMAC — никогда
request.json()послеrequest.read(). -
Кнопка «Поделиться номером» проверяет
contact.user_id == message.from_user.id; номер кэшируется — на последующих платежах контакт не запрашивается. -
У админа есть
/check_invoice <paymentId>, который дёргаетGET /v1/invoice/{id}и приstatus == "Processed"вручную дёргаетmark_bought()/activate_or_extend(). - Админ получает DM на любую ошибку invoice / неуспешную проверку PDF.
FAQ
Клиент пишет «я оплатил, но бот ничего не понял» — как быстро проверить?
GET /v1/invoice/{paymentId} — это источник правды:
curl -H "X-API-Key: $KASPI_PAY_API_KEY" \
https://pay.proverkacheka.kz/api/v1/invoice/abc123
Маппинг статусов: Processed = оплачено,
RemotePaymentCreated = ещё не оплатил,
RemotePaymentRejected = отклонил в Kaspi,
RemotePaymentCanceled = отменили вы,
RemotePaymentExpired = истёк (~24 ч).
Если Processed — webhook не дошёл (или вы его дропнули);
дёрните mark_bought() вручную для этого paymentId.
10 строк админ-команды (/check_invoice <paymentId>) закрывают почти все
тикеты «застрял платёж» в один тап. Полный пример — в Markdown-гайде, Step 8.
А если оба сервиса в дауне?
Тогда падает только автоматизация. Клиент всё равно может оплатить
обычной Kaspi-ссылкой — а вы вручную через
/mark_paid <user_id> выдадите доступ, когда сервис вернётся.
Чек не теряется: Kaspi присылает PDF на почту клиенту навсегда — позже
загрузите его в api.proverkacheka.kz/upload для бухгалтерии.
Можно ли использовать только PDF (без invoice push)?
Да — это самый простой стартовый сценарий. Многие боты так и работают:
клиент оплачивает по обычной Kaspi-ссылке, шлёт PDF — бот верифицирует
через api.proverkacheka.kz/upload. Invoice push добавляется
когда конверсию хочется поднять на 5–15% за счёт «оплата в один тап».
Можно ли использовать только invoice push (без PDF)?
Не рекомендуем. Из ~100 push-оплат типично 3–7 случаев — клиент «не получил уведомление», либо аккаунт Kaspi не на том номере. Если нет PDF-fallback, эти клиенты теряются. Стоимость интеграции второго пути — 30 строк кода (см. выше).
Что с возвратами?
Для invoice push используйте POST /v1/invoice/{id}/refund
на pay.proverkacheka.kz. Для PDF-оплат возврат делается вручную в
Kaspi Pay для бизнеса — стороны проверки чеков это не касается.
Подробнее в /docs/#refund.
Где посмотреть реальную реализацию целиком?
Бот KK17 BEAUTY (@KarinaKunuspekovaBot)
— открытая разработка от ИП КУНУСПЕКОВА. Ключевые файлы:
bot/kaspi_pay.py (клиент pay.proverkacheka.kz),
bot/payments.py (роутинг invoice → fallback),
dashboard/routes_payments.py (webhook-приёмник). Свяжитесь
с поддержкой @PayProverkaBot
за актуальной выгрузкой.