Действующая редакция контракта. Обновлено: 2026-10-02.
Для ИИ-ассистентов и no-code инструментов
Машиночитаемая версия этой страницы: kazpayment.kz/docs/api.md (она же /llms.txt). Готовый промпт для ChatGPT, Claude или Cursor — ещё несколько на странице промптов:
Прочитай документацию https://kazpayment.kz/docs/api.md и подключи приём оплаты KazPayment в мой проект: создание заказа (POST /api/v1/orders с идемпотентным external_ref), переход покупателя по pay_url, обработчик вебхука об оплате с проверкой подписи X-Kazpayment-Signature ДО разбора JSON. Ключ интеграции и секрет вебхука вынеси в переменные окружения — значения я подставлю сам.
API для интеграции — /api/v1
Для чужой системы: интернет-магазина, телеграм-бота, службы доставки. Всё, что ей нужно, — завести заказ и узнать, оплачен ли он.
Действующая редакция всегда открыта на kazpayment.kz/docs/api — страница собирается из этого файла при каждой выкладке.
Порог документа: по нему должен подключиться человек, не читавший наш код. Если при чтении пришлось лезть в исходники — это дефект документа, а не читателя.
Быстрый старт за пять минут
Четыре запроса от ключа до подтверждённой оплаты. Подставьте свой ключ в
KEY и выполняйте по порядку.
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,…}
Работает — переходите к вебхуку, чтобы не опрашивать статус вручную. Пошаговое руководство с проверками на каждом шаге: Подключить интернет-магазин или телеграм-бот.
Что тут есть и чего нет
Есть: заказ, ссылка на его оплату, отправка этой ссылки покупателю в 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… |
Ответ:
{
"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"
}
{ "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" }
{ "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 и не отправит ничего:
{ "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 делит их на служебные и рекламные. Служебное — про то, что человек уже начал: заказ, платёж, доставка. Рекламное зовёт начать. Рекламное стоит в несколько раз дороже, и считается оно по своему тарифу.
Движение заказа
{ "phone": "+7 708 214 77 03", "order": "YESH-1201", "state": "ждёт оплаты", "lang": "kk" }
state — готовая строка на языке покупателя: «курьер в пути», «можно
забирать». Этапы у магазинов разные, и перевести их на человеческий язык
можете только вы.
lang необязателен. Шаблона на этом языке может не оказаться — тогда
напишем языком по умолчанию и скажем каким: в ответе приходит
templateLang. Промах по языку у Meta означает не «другой язык», а
«шаблон не найден», то есть молчание.
Забытая корзина
{ "phone": "+7 708 214 77 03", "items": "3 блюда", "total": "4 950 ₸", "lang": "ru" }
Номера заказа здесь нет — заказа ещё нет, в этом и разница.
Согласие на рекламу проверяете вы. Мы ваших покупателей не видим и знать, кто на что соглашался, не можем. Жалобы на рекламу без согласия бьют по рейтингу номера, с которого уходят и ваши служебные сообщения.
Назвать рекламное сообщение служебным, чтобы заплатить меньше, нельзя: вид решает адрес ручки, а не поле в запросе.
Ответ
{ "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:
{
"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=<hex> — HMAC-SHA256 от сырых байт
тела вашим секретом. Проверяйте ДО разбора JSON:
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/<ref>.
Мелочи для приёмника: тело события меньше килобайта; dead-событие не
воскресает, но каждая НОВАЯ оплата — это новое событие со своим event_id,
их доставка идёт независимо.
Ограничение частоты
120 запросов в минуту на бизнес. Считается по бизнесу, а не по ключу: второй ключ предел не удваивает.
При превышении — 429:
{"error": "Слишком часто. Подождите и повторите.",
"code": "rate_limited", "retryAfterSeconds": 12}
Повторяйте не раньше, чем через retryAfterSeconds.
Ошибки
Тело ошибки всегда одно по форме:
{"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 (повтор номера с другой
суммой), «бизнес убран из работы» и подобные.
Пример: заказ в интернет-магазине
// 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
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 — проверка подписи по сырому телу:
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
$ch = curl_init('https://kazpayment.kz/api/v1/orders');
curl_setopt_array($ch, [
CURLOPT_POST => 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
$raw = file_get_contents('php://input'); // до json_decode!
$expected = 'sha256=' . hash_hmac('sha256', $raw, $SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_KAZPAYMENT_SIGNATURE'] ?? '')) {
http_response_code(403); exit;
}
$event = json_decode($raw, true);
if (!alreadyProcessed($event['event_id'])) { markPaid($event['order_ref']); }
http_response_code(200);
Проверка без денег
Пока бизнес в тестовом режиме, API работает целиком, но касса поддельная:
заказы заводятся, payUrl открывается, вебхуки уходят по-настоящему — а
денег никто не платит. Оплату отмечает владелец из кабинета («Начисления» →
нужное начисление). Программной ручки «оплатить за клиента» нет намеренно: в
боевом режиме такой ручки не существует, и код, написанный вокруг неё, пришлось
бы переписывать.
Что стоит проверить в тестовом режиме до боевого запуска:
- повтор создания заказа с тем же
external_ref— должен вернуть тот же заказ иcreated: false; - повтор с другой суммой —
422 order_amount_differs; - заказ с неверным ключом —
401; - ваш приёмник вебхука — через
POST /api/v1/webhook/test; - поведение при
429: искусственно упереться в лимит и убедиться, что вы ждётеretryAfterSeconds, а не долбите повторами.
Абонементы: плательщик и подписка
Заказ — это разовая оплата. Если ваш бизнес продаёт абонементы (школа, зал, курсы), нужно другое: завести человека, подписать его на тариф, и дальше начисления с напоминаниями пойдут сами — как из кабинета, только программно.
Типичный путь интеграции с вашей CRM: ученик записался → вы заводите плательщика → оформляете подписку → раз в месяц KazPayment сам выставляет счёт и напоминает, а вы узнаёте об оплате вебхуком.
Тарифы бизнеса
curl https://kazpayment.kz/api/v1/plans -H "Authorization: Bearer $KEY"
# → {"plans":[{"id":"…","name":"Абонемент","amount":15000,"interval":"monthly","status":"active"}]}
Тарифы заводятся в кабинете — программно их не создать намеренно: цена услуги для клиентов бизнеса это решение владельца, а не интеграции.
Завести плательщика
curl -X POST https://kazpayment.kz/api/v1/payers \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"phone":"+7 701 111 22 33","full_name":"Иванов Иван","email":"ivan@example.kz","lang":"ru"}'
# → {"id":"…","phone":"77011112233","full_name":"Иванов Иван","created":true}
Повтор безопасен. Ключ — телефон: тот же номер (в любом написании) вернёт
существующего человека с created: false, а не заведёт второго. Сетевой сбой и
повторная отправка не создадут дубль, у которого потом окажется свой абонемент.
lang — язык напоминаний плательщику (ru или kk), по умолчанию ru.
Оформить подписку
curl -X POST https://kazpayment.kz/api/v1/subscriptions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"payer_phone":"+77011112233","plan_id":"…","starts_on":"2026-09-01"}'
# → {"id":"…","payer_phone":"77011112233","plan_name":"Абонемент","amount":15000,"status":"active"}
starts_onнеобязателен — по умолчанию сегодня.ends_onнеобязателен — без него абонемент бессрочный.amountнеобязателен — своя цена для этого клиента; без него он платит по тарифу и автоматически следует его изменениям.
Повтор с тем же плательщиком и тем же тарифом вернёт действующую подписку
(200), а не заведёт вторую: два абонемента означали бы два счёта в месяц
одному человеку.
Посмотреть подписки
curl "https://kazpayment.kz/api/v1/subscriptions?payer_phone=%2B77011112233" \
-H "Authorization: Bearer $KEY"
Без payer_phone вернутся все подписки бизнеса.
Остановить или возобновить
curl -X PUT https://kazpayment.kz/api/v1/subscriptions/<id> \
-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 сервиса. Проверка кода бесплатна.
Отправить код
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 знаков, длиннее она уходит двумя частями и стоит вдвое дороже, а с вас списывается цена одной.
Проверить код
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 это наше альфа-имя, у остальных операторов имя выбирается автоматически по договору с оператором. Подставить своё название интегратор не может. Учитывайте это в тексте формы: человек увидит отправителем сервис, а не ваш бренд. Нужно собственное имя отправителя — напишите, это отдельно оформляется у оператора.
Чек-лист перед боевым запуском
- Ключ и секрет вебхука лежат в переменных окружения, а не в коде.
external_ref— ваш настоящий номер заказа, уникальный и не переиспользуемый.- Повтор запроса после таймаута идёт с тем же телом.
- Подпись вебхука проверяется по сырым байтам до разбора JSON.
event_idдедуплицируется: одно событие закрывает сделку один раз.- Приёмник отвечает 2xx за 10 секунд, тяжёлую работу делает после ответа.
- Есть запасной путь на случай, если вебхук не дошёл: опрос
GET /api/v1/orders/<ref>. - Коды ошибок обрабатываются по
code, а не по тексту. - Проведена одна настоящая оплата на маленькую сумму и возврат по ней.
Безопасность
Ключ интеграции даёт право заводить требования денег от имени бизнеса —
это доступ к деньгам. Храните его как пароль: переменные окружения, не в
репозитории, не в браузерном коде. Ключ работает только с /api/v1 и не даёт
входа в панель.
Секрет вебхука показывается один раз при настройке. Потерян — выпустите
новый повторным PUT /api/v1/webhook (это и есть ротация) и обновите его у
себя: старый перестаёт работать сразу.
Отзыв всех ключей бизнеса — DELETE /api/tenants/<id>/tokens из панели
владельца. Делайте это, если ключ мог утечь: заведение заказов прекратится
немедленно.
Мы со своей стороны не передаём в вебхуке персональных данных плательщика: в событии только идентификаторы, суммы и время.
Чего API не обещает
Деньги идут напрямую бизнесу, минуя нас: мы выставляем требование через кассу Kaspi самого бизнеса и читаем его выписку. Мы не платёжный агент и денег не держим.
Оплата зависит от кассы бизнеса. Если его сессия Kaspi отвалилась, выпуск кода вернёт ошибку, пока владелец не войдёт заново. Это его касса, не наша.
Версия в пути меняется, когда меняется контракт. Новое необязательное поле в ответе версию не двигает — не полагайтесь на то, что список полей неизменен.