Коротко
Мобильное приложение не обращается в Qut Pay напрямую. Порядок всегда из четырёх звеньев: приложение → ваш сервер → Qut Pay → Kaspi. Причина одна: API-ключ должен жить только на сервере, а не внутри APK или IPA — собранное приложение может распаковать кто угодно и достать ключ. Сервер создаёт счёт и возвращает приложению payUrl или deepLink, приложение его открывает, а после подтверждения оплаты на сервер приходит вебхук.
Как это работает
| Шаг | Кто | Что делает |
|---|---|---|
| 1 | Приложение | Пользователь жмёт «Оплатить», приложение идёт на ваш сервер |
| 2 | Ваш сервер | Проверяет заказ и сам считает сумму |
| 3 | Ваш сервер | POST /api/v1/invoices — X-API-Key используется только здесь |
| 4 | Ваш сервер | Отдаёт приложению только id и payUrl (или deepLink) |
| 5 | Приложение | Открывает ссылку — запускается приложение Kaspi |
| 6 | Покупатель | Подтверждает оплату в Kaspi |
| 7 | Qut Pay | Отправляет на ваш сервер invoice.paid |
| 8 | Ваш сервер | Закрывает заказ и сообщает приложению пушем или статусом |
Не берите сумму из приложения. Запрос легко подменить и прислать 100 тенге. Сумму сервер должен посчитать сам, по содержимому заказа.
Про API-ключ
Это самая важная часть статьи.
- Ключ никогда не лежит внутри приложения. APK и IPA — это обычные архивы, их можно распаковать. Не храните ключ ни в коде, ни в ресурсах, ни в
strings.xml, ни вInfo.plist, ни в обфусцированном виде. - Ключ лежит в переменных окружения вашего сервера. В репозиторий он не коммитится.
- Приложение ходит на ваш сервер со своей авторизацией (сессия пользователя, JWT — что у вас принято). Ключ Qut Pay там не фигурирует вовсе.
- Если ключ утёк, сразу удалите его в кабинете и создайте новый: старый перестаёт работать в тот же момент.
- Выдавайте ключу только нужные права:
invoices:write,invoices:read, иrefunds:write, если делаете возвраты.
Об устройстве сервиса в целом: Безопасно ли подключать и как всё устроено.
Какой метод API используется
На сервере:
POST https://api.qut.kz/api/v1/invoices
X-API-Key: qp_live_…
Idempotency-Key: app-order-8841
{
"amount": 12900,
"kind": "qr",
"description": "Заказ №8841",
"externalOrderId": "8841",
"successUrl": "https://moe-app.kz/pay/ok?order=8841",
"failUrl": "https://moe-app.kz/pay/fail?order=8841",
"metadata": { "platform": "ios", "user_id": "u_512" }
}
Не отдавайте приложению весь ответ — достаточно id и payUrl (или deepLink).
Остальное: GET /api/v1/invoices/{id} — узнать статус, когда пользователь вернулся в приложение, POST /api/v1/invoices/{id}/cancel, POST /api/v1/invoices/{id}/refund.
Если номер покупателя известен (он зарегистрирован в приложении), подойдёт и kind: "phone": в Kaspi прилетит пуш. Тогда customer.phone в формате 7XXXXXXXXXX, сумма — целые тенге, описание — до 60 символов.
Что происходит при открытии payUrl
Когда приложение открывает payUrl, на телефоне запускается приложение Kaspi, и покупатель подтверждает оплату там. Вам нужны две вещи.
1. Проверяйте статус при возвращении. Поймайте момент возврата в приложение (applicationDidBecomeActive, onResume) и спросите у своего сервера статус заказа. Пользователь мог вернуться, ничего не оплатив, — «вернулся» само по себе не значит «оплатил».
2. Источник истины — вебхук. Переводите заказ в «оплачен» только на сервере и только по событию invoice.paid. Словам приложения верить нельзя: его поведение можно подменить.
Лучше держать оба механизма: вебхук — как основной, запрос из приложения — чтобы быстро обновить интерфейс.
successUrl и failUrl принимают только http(s). Удобно вести их на страницы вашего домена, а уже оттуда возвращать пользователя в приложение.
Правила App Store и Play Market
Это не техническое, а магазинное ограничение, но от него зависит, пройдёте ли вы модерацию.
| Что продаётся | Внешняя оплата |
|---|---|
| Цифровой товар: подписка, премиум-доступ, игровая валюта, контент внутри приложения | Нельзя — нужно использовать оплату самого магазина |
| Физический товар: одежда, еда, книги, покупка в магазине | Можно |
| Доставка, курьер, такси | Можно |
| Услуги: ремонт, консультация, обучение, салон | Можно |
| Бронирование: столик, номер, билет, время | Можно |
То есть Qut Pay уместен для реальных товаров и услуг, но не для цифрового контента внутри приложения. Правила магазинов меняются — перед публикацией сверьтесь с их актуальной редакцией.
На что обратить внимание
- Идемпотентность. Мобильная сеть нестабильна, повторная отправка запроса — обычное дело. Передавайте номер заказа в
Idempotency-Key: при повторе с тем же ключом новый счёт не создаётся, возвращается прежний. - Окно сканирования QR — около трёх минут. Показывайте таймер и кнопку «Новый счёт». Не зашивайте время в код — берите
expiresAtиз ответа. - Поздняя оплата. Если деньги придут на истёкший счёт, событие придёт с признаком
late: true. Не закрывайте такой заказ автоматически. - Обработчик вебхука должен быть идемпотентным — пара
(invoice.id, status)обрабатывается один раз. - Сначала песочница. Прогоните всю цепочку ключом
qp_test_…: счёт →simulate→ вебхук → статус в приложении обновился. - Если получаете 401, чаще всего дело в несовпадении режима или неверном заголовке: API отвечает 401.
Вопросы и ответы
У меня нет сервера, только приложение. Как быть? Понадобится небольшой сервер: вся его работа — создавать счета и принимать вебхуки, это буквально пара эндпоинтов. Без сервера ключ неизбежно окажется внутри приложения, а так делать нельзя.
А если обфусцировать ключ? Не помогает. Обфускация не прячет ключ, а лишь усложняет поиск. Достаточно посмотреть трафик собранного приложения.
Нужно ли показывать картинку QR в приложении? Сканировать QR на том же телефоне неудобно. Лучше открывать Kaspi через payUrl или deepLink. QR пригодится для случая, когда платят с другого телефона.
Что если покупатель оплатил, а приложение закрылось? Ничего не теряется. Оплата проходит в Kaspi, вебхук приходит на сервер, заказ закрывается. При следующем запуске приложение увидит готовый статус.
У меня есть ещё веб-версия — можно один ключ на оба? Можно, но удобнее разные: если один утечёт, вы удалите только его, не трогая второй.