Действующая редакция контракта. Обновлено: 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 открывается, вебхуки уходят по-настоящему — а денег никто не платит. Оплату отмечает владелец из кабинета («Начисления» → нужное начисление). Программной ручки «оплатить за клиента» нет намеренно: в боевом режиме такой ручки не существует, и код, написанный вокруг неё, пришлось бы переписывать.

Что стоит проверить в тестовом режиме до боевого запуска:

Абонементы: плательщик и подписка

Заказ — это разовая оплата. Если ваш бизнес продаёт абонементы (школа, зал, курсы), нужно другое: завести человека, подписать его на тариф, и дальше начисления с напоминаниями пойдут сами — как из кабинета, только программно.

Типичный путь интеграции с вашей 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"}

Повтор с тем же плательщиком и тем же тарифом вернёт действующую подписку (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 Отправка кодов не подключена на стороне сервиса

Пределы и почему они такие

Имя отправителя в SMS — зарегистрированное имя KazPayment: у Beeline это наше альфа-имя, у остальных операторов имя выбирается автоматически по договору с оператором. Подставить своё название интегратор не может. Учитывайте это в тексте формы: человек увидит отправителем сервис, а не ваш бренд. Нужно собственное имя отправителя — напишите, это отдельно оформляется у оператора.

Чек-лист перед боевым запуском

  1. Ключ и секрет вебхука лежат в переменных окружения, а не в коде.
  2. external_ref — ваш настоящий номер заказа, уникальный и не переиспользуемый.
  3. Повтор запроса после таймаута идёт с тем же телом.
  4. Подпись вебхука проверяется по сырым байтам до разбора JSON.
  5. event_id дедуплицируется: одно событие закрывает сделку один раз.
  6. Приёмник отвечает 2xx за 10 секунд, тяжёлую работу делает после ответа.
  7. Есть запасной путь на случай, если вебхук не дошёл: опрос GET /api/v1/orders/<ref>.
  8. Коды ошибок обрабатываются по code, а не по тексту.
  9. Проведена одна настоящая оплата на маленькую сумму и возврат по ней.

Безопасность

Ключ интеграции даёт право заводить требования денег от имени бизнеса — это доступ к деньгам. Храните его как пароль: переменные окружения, не в репозитории, не в браузерном коде. Ключ работает только с /api/v1 и не даёт входа в панель.

Секрет вебхука показывается один раз при настройке. Потерян — выпустите новый повторным PUT /api/v1/webhook (это и есть ротация) и обновите его у себя: старый перестаёт работать сразу.

Отзыв всех ключей бизнеса — DELETE /api/tenants/<id>/tokens из панели владельца. Делайте это, если ключ мог утечь: заведение заказов прекратится немедленно.

Мы со своей стороны не передаём в вебхуке персональных данных плательщика: в событии только идентификаторы, суммы и время.

Чего API не обещает

Деньги идут напрямую бизнесу, минуя нас: мы выставляем требование через кассу Kaspi самого бизнеса и читаем его выписку. Мы не платёжный агент и денег не держим.

Оплата зависит от кассы бизнеса. Если его сессия Kaspi отвалилась, выпуск кода вернёт ошибку, пока владелец не войдёт заново. Это его касса, не наша.

Версия в пути меняется, когда меняется контракт. Новое необязательное поле в ответе версию не двигает — не полагайтесь на то, что список полей неизменен.