# API для интеграции — `/api/v1` Для чужой системы: интернет-магазина, телеграм-бота, службы доставки. Всё, что ей нужно, — завести заказ и узнать, оплачен ли он. Действующая редакция всегда открыта на [kazpayment.kz/docs/api](https://kazpayment.kz/docs/api) — страница собирается из этого файла при каждой выкладке. Порог документа: **по нему должен подключиться человек, не читавший наш код.** Если при чтении пришлось лезть в исходники — это дефект документа, а не читателя. ## Быстрый старт за пять минут Четыре запроса от ключа до подтверждённой оплаты. Подставьте свой ключ в `KEY` и выполняйте по порядку. ```bash KEY="ваш_ключ_интеграции" API="https://kazpayment.kz/api/v1" # 1. Ключ рабочий? curl -s "$API/whoami" -H "Authorization: Bearer $KEY" # → {"tenantId":"…","role":"integration"} # 2. Завести заказ. external_ref — ВАШ номер заказа. curl -s -X POST "$API/orders" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"external_ref":"TEST-1","amount":100,"comment":"Проверка"}' # → {"orderRef":"TEST-1","status":"pending","payUrl":"https://kazpayment.kz/pay/…"} # 3. Открыть payUrl в браузере и оплатить (в тестовом режиме — отметить # оплату в кабинете: «Начисления» → нужное → отметить оплаченным). # 4. Проверить статус. curl -s "$API/orders/TEST-1" -H "Authorization: Bearer $KEY" # → {"orderRef":"TEST-1","status":"paid","paidAmount":100,…} ``` Работает — переходите к вебхуку, чтобы не опрашивать статус вручную. Пошаговое руководство с проверками на каждом шаге: [Подключить интернет-магазин или телеграм-бот](/guides/shop-integration). ## Что тут есть и чего нет Есть: заказ, ссылка на его оплату, отправка этой ссылки покупателю в WhatsApp, статус, вебхук об оплате, подтверждение телефона кодом из SMS, абонементы (плательщик + подписка на тариф). **Свободных сообщений в WhatsApp нет.** Вне суток с последнего сообщения человека WhatsApp принимает только заранее одобренные Meta шаблоны — это её правило, не наше. Поэтому API умеет отправить ссылку на оплату по нашему шаблону, но не «любой текст любому номеру». **Возврат в API есть**, отмены — нет. Вернуть деньги покупателю можно программно: [POST /api/v1/orders/{номер}/refund](#вернуть-деньги-покупателю). А вот отменить ещё не оплаченный счёт из API нельзя — это делает владелец бизнеса из своей панели. Нужна отмена ручкой — скажите, обсудим. ## Ключ Выпускается **в кабинете**: раздел «Интеграция» → «Выпустить ключ». Там же настраивается вебхук и виден список живых ключей — по последним четырём символам, чтобы отозвать нужный, а не все подряд. **Открытое значение показывается один раз** — в базе лежит только его хеш, восстановить нельзя. Потеряли — выпустите новый, а старый отзовите. Выпустить и отозвать может владелец бизнеса, но не сотрудник: ключ открывает заказы и возвраты, то есть деньги. Передаётся как `Authorization: Bearer <ключ>`. Ключ работает **только** в `/api/v1/`. В кабинет он не ходит: ни на чтение, ни на запись. Это сделано ради вас же — утечка ключа из магазина не открывает реестр ваших плательщиков. Отзыв — поштучный, в том же разделе кабинета. Прежний общий отзыв гасил все ключи бизнеса разом, включая работающие интеграции. ## Проверка ключа ``` GET /api/v1/whoami → 200 {"tenantId": "a9a9196a-…", "role": "integration"} ``` Первое, что стоит вызвать после получения ключа. Если тут 401 — дело в ключе, и дальше можно не искать. ## Завести заказ ``` POST /api/v1/orders Content-Type: application/json { "external_ref": "YESH-1201", "amount": 4500, "comment": "Пицца Маргарита", "customer_phone": "+7 777 123 45 67" } ``` | поле | обяз. | что это | |---|---|---| | `external_ref` | да | номер заказа В ВАШЕЙ системе, до 128 символов; в пути статуса передаётся URL-кодированным | | `amount` | да | целое число тенге, больше нуля | | `comment` | нет | что оплачивают; увидит оператор бизнеса, до 200 символов | | `customer_phone` | нет | телефон покупателя в любом виде — `+7…`, `8…`, `777…` | Ответ: ```json { "orderRef": "YESH-1201", "amount": 4500, "paidAmount": 0, "status": "pending", "created": true, "payUrl": "https://kazpayment.kz/pay/7f3a…" } ``` `201` — заказ заведён, `200` — найден по номеру (см. ниже). ### Повтор безопасен Запрос **идемпотентен по `external_ref`**: повторный вызов с тем же номером не заводит второй заказ и не требует денег дважды. Вместо этого возвращается существующий заказ, `created: false` — и **свежая ссылка на оплату**. Поэтому правильная реакция на таймаут — просто повторить запрос **с тем же телом**. **Повтор с другой суммой — ошибка**, а не обновление: придёт `422` с кодом `order_amount_differs`. Изменить сумму существующего заказа нельзя — покупатель изменил корзину, значит заведите заказ с новым номером. Комментарий при повторе игнорируется. Обратная сторона: номер должен быть уникален в вашей системе. Если вы переиспользуете номера, второй заказ с тем же номером не заведётся. ### Зачем `customer_phone` Передали — покупатель заводится как настоящий плательщик бизнеса, и ему можно слать напоминания об оплате. Не передали — заказ анонимный: платить по нему можно, но напоминать некому. Для доставки телефон обычно и так известен, так что передавать его стоит. ## Ссылка на оплату `payUrl` — адрес страницы, на которой покупатель нажимает «Оплатить» и получает QR-код. Код действует **три минуты** (столько даёт Kaspi), поэтому выпускается он не при открытии страницы, а по нажатию; когда истечёт, на странице есть кнопка «Выпустить заново». Платить можно из **любого** банковского приложения, не только Kaspi: это Единый QR. Проверено живой оплатой из Halyk. Страницу можно **встроить в свой сайт через iframe** — она это разрешает. Остальные наши страницы встраивать нельзя. Ссылка живёт, пока заказ не оплачен. Нужна новая — повторите создание заказа тем же номером. ## Вернуть деньги покупателю ``` POST /api/v1/orders/YESH-1201/refund { "amount": 4570, "reason": "Клиент отменил заказ", "refund_ref": "RET-1201-1" } ``` ```json { "refunded": 4570, "remaining": 0, "repeated": false } ``` Деньги уходят обратно через ту же кассу Kaspi, которой платили. Начисление после возврата попадает к вам в «разбор»: полный возврат снимает покрытие, и решить его судьбу — ваше дело, а не наше. ### `refund_ref` обязателен, и вот почему Это номер возврата **в вашем учёте**. Он нужен не нам, а вам: по нему мы отличаем «повтори, я не понял ответа» от «верни ещё раз». Представьте обрыв связи на таймауте. Деньги уже вернулись, но ваш код ответа не увидел и повторяет запрос. Без номера повтор получил бы «возвращать нечего» — то есть отказ там, где всё получилось, и дальше вы возвращали бы второй раз руками или не возвращали вовсе. С номером повтор вернёт **тот же ответ**, а в нём `"repeated": true`. Свой номер мы придумать не можем: по одной оплате бывает несколько частичных возвратов, и «возврат по этому заказу» их не различает. ### Частичный возврат `amount` меньше суммы оплаты — вернётся частично. Но учтите: **после первого возврата, даже частичного, счёт считается закрытым**, и второй возврат по нему уже не пройдёт. Возвращайте нужную сумму одним запросом. ### Ответы | Код | Что значит | |---|---| | `200` | Вернули. `repeated: true` — это ответ по вашему прежнему запросу, денег второй раз не трогали | | `400 nothing_to_refund` | По заказу нет оплаченного счёта — возвращать нечего | | `400 rejected` | Касса отказала. Текст отказа — в `error`, он от Kaspi | | `404 order_not_found` | Такого заказа нет | | `409 in_progress` | Возврат с этим номером ещё выполняется — повторите позже | | `501 not_available` | Возвраты у этой кассы не подключены | ### Чего мы не обещаем Kaspi отказывает в возврате по своим правилам, и они не опубликованы. Мы видели отказ `-990000601` один раз — и, судя по всему, по той оплате возврат уже был сделан вручную в приложении Kaspi. Возраст платежа сам по себе помехой не оказался: проверено возвратом двухсуточной оплаты, прошёл без вопросов. Что из этого следует для вас: **не возвращайте одну оплату дважды** — через API и руками в Kaspi. Если возврат уже сделан в приложении, наш запрос получит от кассы отказ, и правильным ответом на него будет не повтор, а проверка. Текст отказа мы передаём дословно и ничего не додумываем: если касса сказала «нет», денег она не вернула. ## Прислать ссылку в WhatsApp Заказ заведён, ссылка есть — но покупатель её ещё не видел. Этот вызов отправит ему сообщение в WhatsApp со ссылкой на оплату. ``` POST /api/v1/orders/YESH-1201/notify { "channel": "whatsapp" } ``` ```json { "sent": true, "channel": "whatsapp", "messageId": "wamid.HBgLNzc..." } ``` **Ни телефона, ни текста в запросе нет — и не будет.** Номер берётся из `customer_phone` заказа, текст собираем мы. Иначе ваш ключ стал бы способом писать кому угодно что угодно от нашего имени, и отвечать за это пришлось бы нам обоим. Значит, чтобы этот вызов работал, заказ должен быть заведён **с `customer_phone`**. Без него ответ будет `no_phone`. ### Что придёт покупателю ``` Счёт на оплату от KSA Visa. Сумма: 4 570 ₸ Оплатить: https://kazpayment.kz/pay/9f2c1a7b Ссылка ведёт на страницу оплаты KazPayment. Если вы ничего не заказывали, просто не переходите по ней. ``` Название вашего бизнеса подставляется в текст, потому что по умолчанию **отправитель — номер KazPayment**, а не ваш. Можно писать и со своего номера: для этого нужен свой WhatsApp Business Account в Meta и одобренные в нём шаблоны. Тогда в переписке стоит ваше имя, на репутацию вашего номера не влияют чужие рассылки, а счёт за сообщения Meta выставляет вам напрямую — наши 18 ₸ за сообщение при этом не списываются. Напишите нам, и мы подключим. Переключиться обратно на наш номер можно в любой момент, настройки при этом сохраняются: возвращаться обычно приходится не от хорошей жизни — кончились деньги на счету Meta или протух токен, — и проходить настройку заново в такой момент было бы издевательством. ### Если платят не нам Бывает, что вы доводите человека до ЧУЖОЙ кассы: государственного портала, партнёра, маркетплейса. Тогда передайте адрес в `link` — уйдёт другой шаблон, который прямо говорит, что оплата пройдёт на стороннем сайте. ``` POST /api/v1/orders/YESH-1201/notify { "channel": "whatsapp", "link": "https://payment.visitsaudi.com/checkout?id=afbb6785", "amount": "210 SAR" } ``` Адрес проверяется теми же правилами, что приёмник вебхуков: только `https` и только публичное доменное имя — по ссылке пойдёт живой человек. **`amount` обязателен вместе с `link`** — это сумма, которую человек заплатит ТАМ, и знаете её только вы. Взять её из заказа нельзя: в заказе ваша цена, в тенге, и к моменту такого сообщения она обычно уже оплачена — покупатель прочитал бы «к оплате 0 ₸» рядом со ссылкой на чужой платёж. Строкой, а не числом: платят не обязательно в тенге. **Это отдельное сообщение от счёта за вашу работу.** По одному заказу законно уходят оба: сперва «оплатите оформление», потом «вот ссылка на сбор». Ограничение «одно сообщение» действует на каждый вид отдельно. Такое сообщение уходит и по **оплаченному** заказу — в том и смысл: сбор на чужом портале обычно появляется уже после того, как вы получили свои деньги. Правило «по закрытому заказу не пишем» относится только к счёту за вашу работу. Получатель всё так же берётся из заказа: писать можно своему покупателю, а не любому номеру. ### Свой текст сообщения Текст выше подходит магазину, но не всякому делу: где-то платят не «счёт», а работу по заявке, и рядом стоит сбор, который платится мимо нас. Под такое заводится отдельный шаблон — напишите нам, какой текст нужен. Выбрать шаблон запросом нельзя, и это не забывчивость: шаблоны живут в нашем аккаунте Meta и проходят нашу модерацию, а выбор из запроса означал бы, что один магазин может отправить сообщение текстом другого. Шаблон назначается вашему бизнесу один раз, дальше `notify` берёт его сам. Подстановки у всех шаблонов одинаковые: название бизнеса, сумма, ссылка. ### Одно сообщение на заказ Повторный вызов по тому же заказу вернёт `200` и не отправит ничего: ```json { "sent": false, "channel": "whatsapp", "reason": "already_sent" } ``` Прислать человеку один и тот же счёт дважды — верный способ настроить его против вас. Нужна свежая ссылка — заведите новый заказ. ### Когда не отправим | `reason` | Что это значит | | --- | --- | | `already_sent` | По этому заказу уже писали | | `no_phone` | У заказа нет `customer_phone` | | `opted_out` | Покупатель ответил «СТОП» — мы обязаны молчать | | `order_closed` | Заказ оплачен или отменён (только для счёта за вашу работу) | | `no_channel` | Канал WhatsApp не настроен | | `no_balance` | Не хватило денег на балансе — пополните счёт, настройки ни при чём | | `no_pay_link` | Ссылку на оплату не удалось выпустить — повторите вызов | | `send_failed` | WhatsApp отказал; причина у нас в журнале, напишите нам | | `deferred` | WhatsApp был временно недоступен — **сообщение в очереди, мы дошлём его сами**. Повторять вызов не нужно | Все эти случаи приходят с кодом `200`: с вашей стороны ничего не сломалось, и повторять запрос незачем. Настоящие ошибки (нет ключа, чужой заказ) отвечают как обычно — `401`, `404`. Сообщение платное, цена за него назначается отдельно от SMS и списывается с баланса. Ответ покупателя приходит нам, не вам: переписку в WhatsApp сервис не ведёт. ## Написать покупателю в WhatsApp Две ручки, и разница между ними — не в тексте, а в **цене и в правилах**. ``` POST /api/v1/whatsapp/order движение заказа — СЛУЖЕБНОЕ POST /api/v1/whatsapp/cart забытая корзина — РЕКЛАМНОЕ ``` WhatsApp вне суточного окна принимает только заранее одобренные Meta шаблоны, и Meta делит их на служебные и рекламные. Служебное — про то, что человек уже начал: заказ, платёж, доставка. Рекламное зовёт начать. **Рекламное стоит в несколько раз дороже**, и считается оно по своему тарифу. ### Движение заказа ```json { "phone": "+7 708 214 77 03", "order": "YESH-1201", "state": "ждёт оплаты", "lang": "kk" } ``` `state` — готовая строка на языке покупателя: «курьер в пути», «можно забирать». Этапы у магазинов разные, и перевести их на человеческий язык можете только вы. `lang` необязателен. Шаблона на этом языке может не оказаться — тогда напишем языком по умолчанию и скажем каким: в ответе приходит `templateLang`. Промах по языку у Meta означает не «другой язык», а «шаблон не найден», то есть молчание. ### Забытая корзина ```json { "phone": "+7 708 214 77 03", "items": "3 блюда", "total": "4 950 ₸", "lang": "ru" } ``` Номера заказа здесь нет — заказа ещё нет, в этом и разница. **Согласие на рекламу проверяете вы.** Мы ваших покупателей не видим и знать, кто на что соглашался, не можем. Жалобы на рекламу без согласия бьют по рейтингу номера, с которого уходят и ваши служебные сообщения. Назвать рекламное сообщение служебным, чтобы заплатить меньше, нельзя: вид решает адрес ручки, а не поле в запросе. ### Ответ ```json { "messageId": "wamid.HBg...", "priceKzt": 50, "template": "cart_reminder_v1", "templateLang": "ru" } ``` `priceKzt` — сколько списали на самом деле. ## Написать покупателю в SMS ``` POST /api/v1/sms { "phone": "+7 708 214 77 03", "text": "Заказ YESH-1201 готов", "kind": "marketing" } ``` `kind` необязателен и по умолчанию `service`. Рекламные SMS стоят дороже служебных — у оператора это разный трафик, с именем отправителя и с требованием согласия абонента. Отличить одно от другого по тексту мы не можем, поэтому вид называете вы. Занизить вид технически можно, но бессмысленно: рекламная рассылка, названная служебной, упрётся в оператора связи, а не в нас. ## Узнать статус ``` GET /api/v1/orders/YESH-1201 → 200 {"orderRef": "YESH-1201", "amount": 4500, "paidAmount": 4500, "status": "paid", "created": false} ``` `created` в ответе статуса всегда `false` — поле общее с ответом создания, по нему различают «завёл» и «нашёл» именно там. Ссылки на оплату в ответе **нет** намеренно: её выпуск — запись в нашей базе, и отдавать её на каждый опрос значило бы плодить записи оттого, что вы просто спрашиваете «оплатили?». ### Статусы Их три, и других не будет: | статус | что значит | |---|---| | `pending` | ещё не оплачен; смотрите `paidAmount` — часть денег могла прийти | | `paid` | оплачен полностью | | `cancelled` | бизнес отменил заказ, платить не нужно | Наши внутренние состояния наружу не выходят: вам по ним ветвиться нечего, а нам они связали бы руки при первом же переименовании. ### Когда статус станет `paid` Честно: не мгновенно. «Оплата пришла» мы фиксируем сверкой с выпиской кассы — у Kaspi нет вебхука и для нас самих. Обычно это **в пределах часа** после оплаты. Страница оплаты покупателю показывает «Оплачено» сразу (она спрашивает кассу напрямую), а вот статус заказа и вебхук подтверждаются сверкой. Поэтому опрашивать статус чаще раза в минуту смысла нет; для мгновенной реакции на покупателя используйте свою страницу успеха, а закрытие сделки вешайте на вебхук или статус. ## Вебхук: мы сами говорим, что оплата пришла Вместо опроса — событие на ваш адрес. ### Настроить ``` PUT /api/v1/webhook {"url": "https://shop.kz/kazpayment/hook"} → 200 {"url": "…", "secret": "9f2c…64 hex-символа"} ``` Требования к адресу: **https**, публичное доменное имя (IP, `localhost` и внутренние имена отвергаются), без логина и `#фрагмента`. **Секрет показывается один раз** — сохраните его: в базе лежит только шифртекст. Повторный `PUT` (в том числе с тем же адресом) выпускает **новый** секрет — это и есть ротация. `GET /api/v1/webhook` — адрес и судьба последней доставки (`status`, `attempts`, `error`): если доставки умирают, это видно здесь, а не только владельцу в Telegram. `DELETE` — отключить; неотправленная очередь при этом удаляется. ### Проверить приёмник ``` POST /api/v1/webhook/test → 200 {"queued": true} ``` В течение минуты на ваш адрес придёт событие `webhook.test` — тем же путём и с той же подписью, что и настоящие. ### Событие `POST` на ваш адрес, `Content-Type: application/json`: ```json { "event_id": "3f6a…-uuid", "event": "payment.received", "occurred_at": "2026-08-24T11:58:00.000Z", "order_ref": "YESH-1201", "charge_id": "…", "subscription_id": null, "payer_id": null, "amount": 4500, "paid_amount": 4500 } ``` **Как понять, за что заплатили.** У заказа опознавательный знак — ваш же `order_ref`. У абонемента его нет (`null`): счёт выставил планировщик, а не вы, — зато заполнены `subscription_id` и `payer_id`, те же, что вернулись при оформлении подписки. Одно из двух заполнено всегда: | Что оплачено | `order_ref` | `subscription_id` / `payer_id` | | --- | --- | --- | | Заказ, заведённый через API | ваш номер заказа | `null` | | Абонемент | `null` | идентификаторы подписки и плательщика | | Начисление, заведённое в кабинете вручную | `null` | заполнены, если оно по подписке | `occurred_at` — момент денег, а не момент доставки. Персональных данных в событии нет: ни имени, ни телефона — только идентификаторы и суммы. ### Подпись Заголовок `X-Kazpayment-Signature: sha256=` — HMAC-SHA256 от **сырых байт тела** вашим секретом. Проверяйте ДО разбора JSON: ```js const crypto = require('node:crypto'); const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex'); const got = req.headers['x-kazpayment-signature'] ?? ''; const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)); ``` Запрос без верной подписи отбрасывайте: адрес приёмника не секрет, и без проверки любой желающий сможет прислать вам «оплату». ### Ответ, повторы, дедупликация Ответьте **2xx за 10 секунд** — тело мы не читаем. Всё остальное (не-2xx, таймаут, редирект) считается недоставкой. Повторы: через 1 мин → 5 мин → 30 мин → 2 ч → 8 ч → 24 ч. Семь попыток за ~полтора суток; дальше доставка помечается `dead`, владелец бизнеса получает уведомление в Telegram, а в `GET /api/v1/webhook` виден `last_error`. **Повтор несёт тот же `event_id`.** Держите таблицу обработанных `event_id` и закрывайте сделку один раз — доставка «хотя бы однажды», второе прибытие того же события штатно. И наоборот: вебхук может не дойти вовсе (полтора суток ваш приёмник лежал) — не делайте его единственным источником правды, статус заказа всегда можно спросить `GET /api/v1/orders/`. Мелочи для приёмника: тело события меньше килобайта; `dead`-событие не воскресает, но каждая НОВАЯ оплата — это новое событие со своим `event_id`, их доставка идёт независимо. ## Ограничение частоты **120 запросов в минуту на бизнес.** Считается по бизнесу, а не по ключу: второй ключ предел не удваивает. При превышении — `429`: ```json {"error": "Слишком часто. Подождите и повторите.", "code": "rate_limited", "retryAfterSeconds": 12} ``` Повторяйте не раньше, чем через `retryAfterSeconds`. ## Ошибки Тело ошибки всегда одно по форме: ```json {"error": "текст по-русски", "code": "invalid_request"} ``` `error` — человеку в ваши логи. **Ветвиться в коде — только по `code`**: тексты мы меняем свободно, коды — никогда (переименовать код значит сломать ваш `switch`). | `code` | HTTP | когда | что делать | |---|---|---|---| | `invalid_request` | 400 | тело не разобрано или значения вне допустимого | читать `error`, чинить запрос | | `unauthorized` | 401 | ключ не предъявлен или неизвестен | проверить `Authorization` | | `forbidden` | 403 | ключ не той роли либо не даёт доступа сюда | выпустить ключ `integration` | | `not_found` | 404 | такого адреса нет | сверить путь с этим документом | | `order_not_found` | 404 | заказа с таким номером нет | завести его | | `webhook_not_configured` | 404 на GET, 400 на POST /test | приёмник не настроен | сначала `PUT /api/v1/webhook` | | `method_not_allowed` | 405 | адрес есть, метод не тот | сверить метод | | `rate_limited` | 429 | превышен предел частоты | ждать `retryAfterSeconds`, повторить | | `not_available` | 501 | возможность не подключена у нас | написать нам | | `internal` | 500 | сломалось у нас | повторить позже; чинить нам, не вам | Отказ по существу операции приходит как `422` со своим кодом — таких кодов немного и они тоже стабильны: `order_amount_differs` (повтор номера с другой суммой), «бизнес убран из работы» и подобные. ## Пример: заказ в интернет-магазине ```js // 1. Покупатель оформил заказ у вас — заводим его у нас. const r = await fetch('https://kazpayment.kz/api/v1/orders', { method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ external_ref: order.id, amount: order.total, comment: order.title, customer_phone: order.phone, }), }); const { payUrl } = await r.json(); // 2. Показываем страницу оплаты: отдельным окном или в iframe у себя. window.location.href = payUrl; // 3. Пока покупатель платит — спрашиваем статус раз в несколько секунд. const { status } = await ( await fetch(`https://kazpayment.kz/api/v1/orders/${order.id}`, { headers: { Authorization: `Bearer ${KEY}` }, }) ).json(); if (status === 'paid') markPaid(order); ``` ### То же на Python ```python import requests API = "https://kazpayment.kz/api/v1" HEAD = {"Authorization": f"Bearer {KEY}"} # Завести заказ r = requests.post(f"{API}/orders", headers=HEAD, json={ "external_ref": order.id, "amount": order.total, "comment": order.title, "customer_phone": order.phone, }, timeout=15) r.raise_for_status() pay_url = r.json()["payUrl"] # Спросить статус status = requests.get(f"{API}/orders/{order.id}", headers=HEAD, timeout=15).json()["status"] ``` Приёмник вебхука на Flask — проверка подписи по сырому телу: ```python import hmac, hashlib from flask import request, abort @app.post("/kazpayment/hook") def hook(): raw = request.get_data() # именно сырые байты expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest() got = request.headers.get("X-Kazpayment-Signature", "") if not hmac.compare_digest(expected, got): abort(403) event = request.get_json() if already_processed(event["event_id"]): # повтор — штатное дело return "", 200 mark_paid(event["order_ref"], event["amount"]) return "", 200 ``` ### То же на PHP ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $KEY", 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'external_ref' => $order['id'], 'amount' => $order['total'], 'comment' => $order['title'], ]), ]); $order = json_decode(curl_exec($ch), true); header('Location: ' . $order['payUrl']); ``` Приёмник вебхука: ```php \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"status":"cancelled"}' ``` Значения: `active`, `paused`, `cancelled`. Это не удаление — история начислений и оплат остаётся, просто новых требований денег человеку не уходит. ### Ошибки | `code` | Что случилось | | --- | --- | | `invalid_phone` | Номер не похож на казахстанский | | `payer_not_found` | Плательщика с таким телефоном у бизнеса нет | | `plan_not_found` | Тарифа с таким `plan_id` нет | ## Подтверждение телефона кодом (SMS OTP) Отдельная возможность, не связанная с заказами: вы отправляете код на номер своего пользователя и проверяете введённое им значение. Полезно, когда у вас своя регистрация или подтверждение действия, а поднимать договор с оператором рассылки ради этого не хочется. **Каждый код — настоящая SMS, и она списывается с баланса вашего бизнеса.** Цена кода видна в кабинете, раздел «Оплата»; она отдельная и может отличаться от цены обычных SMS сервиса. Проверка кода бесплатна. ### Отправить код ```bash curl -X POST https://kazpayment.kz/api/v1/otp/request \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"+7 701 111 22 33","idempotency_key":"login-attempt-7f31"}' # → {"requestId":"a1b2c3…"} ``` Номер принимается в любом привычном виде — `+7 701…`, `8 701…`, `7701…`; непохожее на казахстанский номер значение отклоняется с `invalid_phone`. `requestId` сохраните: он нужен для проверки и живёт недолго. `idempotency_key` необязателен для обратной совместимости, но его следует передавать всегда. Это уникальная строка длиной 8–128 символов для одной попытки отправки; допустимы латинские буквы, цифры, `.`, `_`, `:`, `-`. Если HTTP-ответ потерялся, повторите запрос с тем же ключом и тем же номером: KazPayment вернёт прежний `requestId` без новой SMS и нового списания. Для осознанной повторной SMS создайте новый ключ. Повторное использование ключа с другим номером отклоняется с HTTP 409 и кодом `idempotency_conflict`. ### Что увидит ваш пользователь Текст SMS — ваш, а не наш: ``` Zimbir: ваш код 0000 ``` Имя берётся из кабинета, раздел «Интеграция» → «Имя в SMS с кодом». Не задали — код придёт безымянным: `Ваш код: 0000`. Имени сервиса в этом сообщении не будет ни в одном из случаев: человек подтверждает телефон у ВАС, и код от незнакомой ему конторы он, скорее всего, вводить не станет. Имя — не длиннее 20 знаков. Причина денежная: кириллическая SMS вмещает 70 знаков, длиннее она уходит двумя частями и стоит вдвое дороже, а с вас списывается цена одной. ### Проверить код ```bash curl -X POST https://kazpayment.kz/api/v1/otp/verify \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"requestId":"a1b2c3…","code":"123456"}' # → {"validated":true,"phone":"77011112233"} ``` `validated: false` — код не подошёл; `phone` в этом случае `null`. Привязывайте номер **из ответа**, а не тот, что прислал вам пользователь: это единственный номер, про который мы утверждаем, что он подтверждён. ### Ошибки | `code` | Что случилось | | --- | --- | | `invalid_phone` | Номер не похож на казахстанский | | `no_balance` | На балансе бизнеса не хватает на одну SMS — код не отправлен | | `daily_cap_reached` | Исчерпан суточный предел кодов (300 на бизнес) | | `rate_limited` | Слишком часто; ждите `retryAfterSeconds` | | `not_available` | Отправка кодов не подключена на стороне сервиса | ### Пределы и почему они такие - **Минута между кодами на один номер и не больше пяти в час.** Чтобы вашей формой нельзя было завалить человека сообщениями. - **300 кодов в сутки на бизнес** — общий предел на все ваши ключи. Это защита от заевшего цикла и перебора номеров: каждый вызов стоит денег, и без потолка одна ошибка в коде выжигает баланс за ночь. Мало для вашей нагрузки — напишите, поднимем. - **Баланс** — последняя черта: нет денег, нет отправки. Имя отправителя в SMS — зарегистрированное имя KazPayment: у Beeline это наше альфа-имя, у остальных операторов имя выбирается автоматически по договору с оператором. Подставить своё название интегратор не может. Учитывайте это в тексте формы: человек увидит отправителем сервис, а не ваш бренд. Нужно собственное имя отправителя — напишите, это отдельно оформляется у оператора. ## Чек-лист перед боевым запуском 1. Ключ и секрет вебхука лежат в переменных окружения, а не в коде. 2. `external_ref` — ваш настоящий номер заказа, уникальный и не переиспользуемый. 3. Повтор запроса после таймаута идёт **с тем же телом**. 4. Подпись вебхука проверяется **по сырым байтам до разбора JSON**. 5. `event_id` дедуплицируется: одно событие закрывает сделку один раз. 6. Приёмник отвечает 2xx за 10 секунд, тяжёлую работу делает после ответа. 7. Есть запасной путь на случай, если вебхук не дошёл: опрос `GET /api/v1/orders/`. 8. Коды ошибок обрабатываются по `code`, а не по тексту. 9. Проведена одна настоящая оплата на маленькую сумму и возврат по ней. ## Безопасность **Ключ интеграции** даёт право заводить требования денег от имени бизнеса — это доступ к деньгам. Храните его как пароль: переменные окружения, не в репозитории, не в браузерном коде. Ключ работает только с `/api/v1` и не даёт входа в панель. **Секрет вебхука** показывается один раз при настройке. Потерян — выпустите новый повторным `PUT /api/v1/webhook` (это и есть ротация) и обновите его у себя: старый перестаёт работать сразу. **Отзыв всех ключей бизнеса** — `DELETE /api/tenants//tokens` из панели владельца. Делайте это, если ключ мог утечь: заведение заказов прекратится немедленно. Мы со своей стороны не передаём в вебхуке персональных данных плательщика: в событии только идентификаторы, суммы и время. ## Чего API не обещает **Деньги идут напрямую бизнесу**, минуя нас: мы выставляем требование через кассу Kaspi самого бизнеса и читаем его выписку. Мы не платёжный агент и денег не держим. **Оплата зависит от кассы бизнеса.** Если его сессия Kaspi отвалилась, выпуск кода вернёт ошибку, пока владелец не войдёт заново. Это его касса, не наша. **Версия в пути меняется, когда меняется контракт.** Новое необязательное поле в ответе версию не двигает — не полагайтесь на то, что список полей неизменен.