BOTONPAY Public API
REST API для приёма платежей. Все запросы должны содержать заголовок Authorization: Bearer <API_KEY>. Ключи начинаются с bp_live_ (production) или bp_test_ (sandbox).
Содержание
Быстрый старт за 5 минут#
Минимальный путь до первой оплаченной сделки. Всё, что нужно — API-ключ и HTTPS-endpoint для вебхуков.
1. Получите test-ключ — bp_test_… в личном кабинете мерчанта → раздел API-ключи. Test-ключ создаёт sandbox-сделки и не трогает реальные балансы.
2. Проверьте связь — GET /api/public/v1/health должен вернуть HTTP 200.
3. Создайте локальный заказ в своей системе до обращения к BotonPay. Сгенерируйте и сохраните merchant_order_id и Idempotency-Key.
4. Создайте сделку — POST /api/public/v1/deals с полями fiat, amount_fiat, merchant_order_id и callback_url.
5. Сохраните ответ HTTP 201 — deal_uuid, merchant_order_id, реквизиты (payment_details), payment_url, status и expires_at.
6. Примите webhook deal.created — сразу после создания сделки BotonPay отправляет на callback_url событие deal.created со статусом waiting_payment. Это первый, а не последний webhook.
7. Принимайте последующие события — при каждом изменении статуса приходит отдельный webhook: deal.processing, deal.completed, deal.cancelled, deal.expired, deal.failed.
8. Проверяйте подпись — HMAC-SHA256 по сырому телу запроса, и обрабатывайте повторные доставки идемпотентно по X-Webhook-Id либо по связке deal_uuid + event + status_version.
9. Окончательный успех — только webhook deal.completed со статусом completed. События deal.cancelled, deal.expired, deal.failed — закрывайте локальный заказ как неуспешный.
10. Проверка статуса в любой момент — GET /api/public/v1/deals/{deal_uuid} или GET /api/public/v1/deals/by-merchant-order/{merchant_order_id}.
Переход на live — тот же код, ключ bp_live_….
Мерчант обязан создать локальный заказ и сохранить merchant_order_id до вызова POST /deals. Нельзя создавать локальную запись только после получения webhook deal.created — тогда callback-обработчик не найдёт платёж и ответит 404.
- Создать локальный payment/order.
- Сохранить
merchant_order_id. - Сгенерировать
Idempotency-Key. - Вызвать
POST /deals. - После
HTTP 201сохранитьdeal_uuid. - Обработать webhook
deal.created.
Первый запрос
# 1) проверка доступности API
curl -s https://botonpay.org/api/public/v1/health
# 2) создание сделки на 5000 RUB
curl -X POST https://botonpay.org/api/public/v1/deals \
-H "Authorization: Bearer bp_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a" \
-d '{
"merchant_order_id": "019fd47b-3e40-746f-b7dd-96aaf5971190",
"fiat": "RUB",
"amount_fiat": 5000,
"callback_url": "https://merchant.example.com/webhooks/botonpay"
}'
# ответ: HTTP 201 Created- Всегда передавайте
Idempotency-Keyпри создании сделок и выплат. - Локальный заказ и
merchant_order_idсоздаются до вызоваPOST /deals. - Источник истины по статусу — вебхук +
GET /deals/:id, а не редирект клиента. - Суммы в фиате — как есть (не в копейках); суммы в USDT приходят отдельными полями.
Жизненный цикл PayIn-сделки#
Полный контракт: локальный заказ → создание сделки → синхронный ответ HTTP 201 → асинхронные webhook-события.
Локальный заказ (merchant_order_id + Idempotency-Key)
→ POST /api/public/v1/deals
→ HTTP 201 Created (deal_uuid, реквизиты, payment_url, expires_at)
→ webhook deal.created / status = waiting_payment
→ webhook deal.processing / status = processing
→ webhook deal.completed / status = completed ← финальный успех
Отмена:
→ HTTP 201 → deal.created / waiting_payment → deal.cancelled / cancelled
Истечение:
→ HTTP 201 → deal.created / waiting_payment → deal.expired / expired- Webhook не блокирует Create Deal — ответ
201отдаётся сразу после COMMIT. - Callback отправляется отдельным worker.
- Ошибка callback не откатывает сделку, не меняет её статус и не освобождает реквизит.
- Каждая версия статуса (
status_version) создаёт отдельное событие.
Authentication#
Каждый запрос требует заголовок Authorization: Bearer <API_KEY>. Live-ключи (bp_live_…) работают с боевыми реквизитами; test-ключи (bp_test_…) — с sandbox-сделками, которые не блокируют баланс трейдеров и возвращают is_test: true. Ошибочный или отсутствующий ключ →401 invalid_api_key.
Scopes (права ключа)
Каждому ключу назначается набор прав. При нехватке права API вернёт 403 forbidden с сообщением Missing scope: ….
deals:read | Чтение PayIn-сделок |
deals:write | Создание PayIn-сделок |
deals:cancel | Отмена PayIn-сделок |
payouts:read | Чтение выплат |
payouts:write | Создание и отмена выплат |
webhooks:read | Чтение настроек вебхуков |
webhooks:write | Изменение настроек вебхуков |
webhooks:test | Отправка тестового вебхука |
rates:read | Курсы и список валют |
Управление самими API-ключами (/api/public/v1/api-keys) доступно только root-ключу мерчанта.
API Versioning#
Текущая версия — v1. Все эндпоинты работают через префикс /api/public/v1.
Base URL: https://botonpay.org/api/public/v1Мажорные версии не ломают обратную совместимость внутри себя. Новая мажорная версия (например /v2) будет опубликована отдельно; /v1 продолжит работать.
Supported currencies#
Поддерживаемые фиатные валюты: RUB, AED, TRY,KZT, UZS, KGS, BHD, VND,EGP, BDT, INR, ARS, SAR. Расчёт в USDT. Курс автоматически подтягивается с P2P-агрегаторов.
Для TRY и EGP обязательно передавать client_name — полное имя ожидаемого отправителя платежа. Если сделка создаётся без суммы (payment link), клиент вводит ФИО самостоятельно на странице оплаты. ФИО отображается трейдеру и в карточке сделки для сверки входящего платежа. В ответах также доступен аддитивный alias expected_sender_name; отдельные поля имени и фамилии пока не поддерживаются.
Сверка имени отправителя — необязательная функция, настраиваемая отдельно для каждой валюты/страны. В этом draft она включена для TRY / Турции; для RUB / России она не активна. Для остальных валют функция по умолчанию выключена.
Sandbox#
Для интеграции без риска используйте ключи bp_test_…. Sandbox-сделки:
- не выделяют реквизит и не блокируют баланс трейдера;
- не смешиваются с live:
GET /dealsпо live-ключу возвращает только live-сделки; - возвращают полный набор полей (
payment_url,deal_url) для проверки клиента; - помечены
is_test: trueв теле и в webhook-payload.
Rate limits#
500 запросов в минуту на API-ключ. При превышении — HTTP 429 с телом{ code: "rate_limited" } и заголовком Retry-After: 60. Каждый успешный ответ содержит:
X-RateLimit-Limit— лимит окна (500);X-RateLimit-Remaining— сколько осталось;X-RateLimit-Reset— ISO-время сброса счётчика.
Idempotency#
Idempotency-Key — обязателен. Один логический заказ = один merchant_order_id = один Idempotency-Key.Повтор запроса с тем же ключом и тем же телом:
- не создаёт новую сделку;
- возвращает тот же
deal_uuid; - возвращает тот же
merchant_order_id; - возвращает те же реквизиты;
- возвращает тот же HTTP-код и то же тело ответа;
- добавляет заголовок
Idempotent-Replay: true(дублируется какIdempotent-Replayed: true).
Повтор с тем же ключом и другим телом — HTTP 409:
{
"success": false,
"error": {
"code": "idempotency_conflict",
"message": "Idempotency-Key was already used with different request parameters"
}
}Если запрос с тем же ключом ещё выполняется, API дожидается результата первого запроса и возвращает его же. Рекомендуется генерировать UUID v4 и хранить ключ минимум 24 часа.
POST https://botonpay.org/api/public/v1/deals
Authorization: Bearer bp_live_...
Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a
Content-Type: application/json
{ "merchant_order_id": "ORDER-124", "fiat": "RUB", "amount_fiat": 5000 }Пример для гео с обязательным ФИО (TRY, EGP):
POST https://botonpay.org/api/public/v1/deals
Authorization: Bearer bp_live_...
Idempotency-Key: 2f3c1a55-6b21-4c6e-9d1f-2b18cf0a71cd
Content-Type: application/json
{ "merchant_order_id": "ORDER-125", "fiat": "TRY", "amount_fiat": 2500, "client_name": "Ahmet Yilmaz" }
// 400 если client_name не передан:
{ "success": false, "error": { "code": "invalid_request",
"message": "client_name is required for TRY deals" } }Неизвестный результат Create Deal (timeout, разрыв связи)#
Если вы не получили ответ на POST /deals из-за таймаута, разрыва соединения, 502/503 или сетевой ошибки клиента — не генерируйте новый Idempotency-Key. Сделка могла быть создана.
Правильное действие — одно из двух:
- Повторить
POST /dealsс тем жеIdempotency-Keyи тем же телом — вернётся исходный результат. - Проверить сделку через
GET /api/public/v1/deals/by-merchant-order/{merchant_order_id}илиGET /api/public/v1/deals/by-idempotency-key/{key}.
GET https://botonpay.org/api/public/v1/deals/by-idempotency-key/8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a
Authorization: Bearer bp_live_...
// 200 — исходный ответ Create Deal (Idempotent-Replayed: true)
// 202 — запрос ещё обрабатывается:
{ "success": false, "processing": true,
"error": { "code": "DEAL_CREATION_PROCESSING",
"message": "Deal creation is still processing. Retry shortly." } }Не создавайте второй заказ с новым ключом, пока не проверен результат первого запроса.
Deal object#
| Поле | Тип | Описание |
|---|---|---|
id | uuid | UUID сделки в BotonPay |
deal_uuid | uuid | Явный синоним id. Используйте его для хранения и сверки |
external_id | string | Внутренний номер сделки BOTONPAY (D-XXXX) |
status | enum | См. таблицу статусов ниже |
merchant_order_id | string | ID заказа в системе мерчанта |
client_name | string? | Полное имя ожидаемого отправителя платежа. Обязательно для TRY, EGP |
expected_sender_name | string? | Аддитивный alias client_name в ответах |
fiat | string | Фиатная валюта (ISO 4217) |
amount_fiat | number | Сумма в фиате |
amount_usdt | number | Сумма в USDT |
rate | number | Курс fiat→USDT на момент сделки |
exchange_rate | string | Зафиксированный курс (строка, 8 знаков) |
gross_amount_usdt | string | Сумма в USDT до удержания комиссии |
merchant_fee_percent | string | Комиссия платформы, % (100 − доля мерчанта по ключу) |
merchant_fee_usdt | string | Сумма комиссии в USDT |
merchant_amount_usdt | string | Итог к зачислению мерчанту в USDT после удержания комиссии |
merchant_net_amount_usdt | string | Синоним merchant_amount_usdt |
calculation_status | enum | locked — расчёт зафиксирован; legacy_unavailable — блок расчёта недоступен (старые сделки) |
created_at | iso8601 | Дата создания |
expires_at | iso8601 | Дата истечения (обычно +10 мин) |
completed_at | iso8601? | Дата завершения |
payment_url | string | Ссылка на страницу оплаты для клиента |
deal_url | string | Синоним payment_url |
requisites_id | uuid? | ID выделенного реквизита |
payment_details | object? | Реквизиты оплаты (см. ниже) |
metadata | object | Свободные поля мерчанта |
is_test | boolean | Sandbox-сделка |
payment_details
Возможные поля (набор зависит от страны и метода оплаты, часть может отсутствовать):
bank— банкholder— владелецcard— номер картыaccount— номер счётаiban— IBANphone— номер телефона
Идентификаторы: deal_uuid и merchant_order_id#
В интеграции участвуют два независимых идентификатора. Их нельзя путать и нельзя подставлять один вместо другого.
| Поле | Кто выдаёт | Назначение |
|---|---|---|
deal_uuid | BotonPay | UUID сделки на стороне BotonPay. Уникален глобально. Присутствует во всех webhook-payload |
merchant_order_id | Мерчант | ID заказа в системе мерчанта. Уникален в рамках API-ключа. Возвращается как есть, без изменений |
external_id | BotonPay | Человекочитаемый номер вида D-1042. Только для отображения и поддержки |
- Ищите локальный платёж по
merchant_order_id, аdeal_uuidхраните как ссылку на сделку BotonPay. - Не используйте
deal_uuidкак первичный ключ вашего заказа. merchant_order_idдолжен быть уникален; повтор с другими параметрами вернёт ошибку.
Deal statuses#
| Статус | Значение |
|---|---|
waiting_payment | Ожидает оплаты клиентом |
processing | Клиент отметил оплату, ждём подтверждения трейдера |
completed | Сделка успешно завершена |
cancelled | Сделка отменена |
expired | Время оплаты истекло |
failed | Сделка завершилась ошибкой |
disputed | Открыта апелляция, идёт разбор |
Финальные статусы: completed, cancelled, expired, failed. После них статус сделки не меняется.
1. Create Deal — POST /api/public/v1/deals#
Создание сделки на приём оплаты. Успешный ответ — HTTP 201 Created.
HTTP 201 содержит полный объект сделки: deal_uuid, merchant_order_id, status, amount_fiat, currency, requisites_id, requisites(snapshot реквизитов), payment_details, payment_url и expires_at. Ждать webhook deal.created, чтобы получить реквизиты, не нужно — snapshot заморожен в момент создания и не меняется при повторных запросах, GET-сделке и идемпотентных повторах. Если реквизит назначить нельзя — возвращается HTTP 422 no_available_requisites и сделка не создаётся. Полный ответ HTTP 201 с реквизитами гарантированно отдаётся не позднее 3 секунд с момента получения запроса. Если уложиться в этот срок невозможно, сделка не создаётся (а уже созданная — отменяется, webhook deal.created не отправляется) и возвращается HTTP 503 allocation_timeout. Повторите запрос с тем же Idempotency-Key: тот же ключ вернёт ровно этот же сохранённый ответ, пока вы не смените ключ. Если реквизит назначен, но snapshot неполный — возвращается HTTP 500 requisites_snapshot_incomplete, сделка автоматически отменяется, HTTP 201 не отправляется.| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
fiat | string | обязательно | Валюта ISO 4217, напр. RUB |
amount_fiat | number | обязательно | Сумма в фиате, не в копейках |
merchant_order_id | string | обязательно (production) | ID заказа мерчанта, уникален в рамках ключа |
callback_url | string (https) | обязательно, если не задан в ключе | URL для webhook. Используется буквально, без нормализации |
client_name | string | условно | ФИО плательщика. Обязательно для TRY, EGP |
payment_method | string | опционально | Тип реквизита: card (карта, C2C), phone или sbp (перевод по номеру телефона), iban, account. Фильтр строгий — см. 1.1 |
bank_code | string | опционально | Банк реквизита, напр. kaspi |
strict_bank_code | boolean | опционально | true — другой банк не подставляется |
success_url | string | опционально | Редирект клиента после оплаты |
cancel_url | string | опционально | Редирект клиента при отмене |
customer_id | string | опционально | Идентификатор клиента мерчанта |
metadata | object | опционально | Свободные поля, возвращаются как есть |
Заголовок Idempotency-Key — обязателен для production.
POST https://botonpay.org/api/public/v1/deals
Authorization: Bearer bp_live_...
Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a
Content-Type: application/json
{
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
"callback_url": "https://merchant.com/webhook",
"success_url": "https://merchant.com/success",
"cancel_url": "https://merchant.com/cancel",
"customer_id": "USER-123"
}POST https://botonpay.org/api/public/v1/deals
Authorization: Bearer bp_live_...
Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a
Content-Type: application/json
{
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
"callback_url": "https://merchant.com/webhook",
"success_url": "https://merchant.com/success",
"cancel_url": "https://merchant.com/cancel",
"customer_id": "USER-123"
}Ответ HTTP 201 Created:
{
"success": true,
"deal": {
"id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"deal_uuid": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"external_id": "D-1042",
"status": "waiting_payment",
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
"amount_usdt": 62.81,
"rate": 79.60,
"exchange_rate": "79.60000000",
"exchange_rate_direction": "fiat_to_usdt",
"gross_amount_usdt": "62.81407035",
"merchant_fee_percent": "5.0000",
"merchant_fee_usdt": "3.14070352",
"merchant_net_amount_usdt": "59.67336683",
"merchant_amount_usdt": "59.67336683",
"calculation_status": "locked",
"created_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-01T00:10:00Z",
"payment_url": "https://botonpay.org/pay/d/abc123xyz",
"deal_url": "https://botonpay.org/pay/d/abc123xyz",
"requisites_id": "9f1e2b45-aaaa-bbbb-cccc-1234567890ab",
"payment_details": {
"bank": "T-Bank",
"holder": "Ivan Ivanov",
"card": "2200123412341234"
}
}
}Ошибочные ответы:
| HTTP | Код | Когда |
|---|---|---|
400 | invalid_request | Некорректное тело запроса или отсутствует обязательное поле |
401 | unauthorized | Неверный или отозванный API-ключ |
403 | forbidden | У ключа нет scope deals:write или запрещён origin/IP |
409 | idempotency_conflict | Тот же Idempotency-Key с другим телом |
422 | NO_AVAILABLE_REQUISITES | Нет свободных реквизитов под сумму и валюту |
422 | CURRENCY_DISABLED | Валюта отключена или недоступна ключу |
429 | rate_limited | Превышен лимит запросов, см. Retry-After |
503 | allocation_timeout | Не удалось выдать полный ответ с реквизитами за 3 секунды. Сделка не создана. Повторите с новым Idempotency-Key |
Формула расчёта: gross_amount_usdt = amount_fiat / exchange_rate, merchant_fee_usdt = gross_amount_usdt × merchant_fee_percent / 100, merchant_amount_usdt = gross_amount_usdt − merchant_fee_usdt — именно эта сумма зачисляется на баланс мерчанта при завершении сделки. Все значения — строки с 8 знаками после точки.
1.1. Карта или номер телефона — payment_method#
Один и тот же эндпоинт обслуживает и приём на карту (C2C), и приём переводом по номеру телефона (СБП / Kaspi). Тип реквизита задаётся полем payment_method в теле запроса — вместе с необязательным bank_code.
| Значение | Что получит плательщик | Поле реквизита в ответе |
|---|---|---|
| card | Карта (H2H C2C) | requisites.card_number |
| phone | Перевод по номеру телефона | requisites.phone |
| sbp | Алиас phone (СБП) | requisites.phone |
| iban | Перевод по IBAN | requisites.iban |
| account | Перевод на счёт | requisites.account_number |
HTTP 422 no_available_requisites — реквизит другого типа не подставляется, даже когда он свободен. Так плательщик никогда не увидит карту там, где ему обещали перевод по номеру. Запрошенный тип возвращается в error.details.requested_payment_method. Без payment_method выдаётся любой подходящий реквизит — как и раньше.Приём на карту в тенге, банк Halyk:
POST https://botonpay.org/api/public/v1/deals
Authorization: Bearer bp_live_...
Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a
Content-Type: application/json
{
"merchant_order_id": "ORDER-125",
"currency": "KZT",
"amount_fiat": 50000,
"payment_method": "card",
"bank_code": "halyk",
"callback_url": "https://merchant.com/webhook"
}Приём переводом по номеру телефона (Kaspi):
{
"merchant_order_id": "ORDER-126",
"currency": "KZT",
"amount_fiat": 50000,
"payment_method": "phone",
"bank_code": "kaspi",
"callback_url": "https://merchant.com/webhook"
}Фактический тип выданного реквизита всегда возвращается явно — угадывать его по заполненным полям не нужно:
{
"success": true,
"deal": {
"payment_method": "phone",
"requested_payment_method": "phone",
"requisites": {
"bank_name": "Kaspi Bank",
"recipient_name": "АЙДОС К.",
"payment_method": "phone",
"card_number": null,
"phone": "+7 700 123 45 67"
}
}
}То же поле работает и в режиме платёжной ссылки (Mode B, без amount_fiat): тип запоминается при создании ссылки и применяется, когда плательщик введёт сумму.
2. Get Deal — GET /api/public/v1/deals/:id#
id — deal_uuid, external_id или merchant_order_id.
GET https://botonpay.org/api/public/v1/deals/b3a1c9e0-1234-4abc-9def-1234567890ab
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/deals/b3a1c9e0-1234-4abc-9def-1234567890ab
Authorization: Bearer bp_live_...Ответ:
{
"success": true,
"deal": {
"id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"deal_uuid": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"external_id": "D-1042",
"status": "completed",
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
"amount_usdt": 62.81,
"gross_amount_usdt": "62.81407035",
"merchant_fee_percent": "5.0000",
"merchant_fee_usdt": "3.14070352",
"merchant_amount_usdt": "59.67336683",
"calculation_status": "locked",
"created_at": "...",
"completed_at": "..."
}
}2.1. Lookup — GET /api/public/v1/deals/by-merchant-order/:merchant_order_id#
Поиск сделки по вашему merchant_order_id. Используйте при сверке, восстановлении после таймаута и при получении webhook по неизвестному заказу.
GET https://botonpay.org/api/public/v1/deals/by-merchant-order/ORDER-124
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/deals/by-merchant-order/ORDER-124
Authorization: Bearer bp_live_...Ответ — тот же объект сделки. 404 с кодом not_found, если заказ не найден по этому API-ключу.
3. List Deals — GET /api/public/v1/deals#
Пагинированный список сделок мерчанта. Фильтры: status,date_from, date_to, merchant_order_id,page, limit (max 100).
GET https://botonpay.org/api/public/v1/deals?status=completed&page=1&limit=20
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/deals?status=completed&page=1&limit=20
Authorization: Bearer bp_live_...Ответ:
{
"success": true,
"data": [ /* Deal[] */ ],
"page": 1,
"limit": 20,
"total": 137,
"pages": 7,
"has_next": true,
"has_prev": false
}4. Cancel Deal — POST /api/public/v1/deals/:id/cancel#
POST https://botonpay.org/api/public/v1/deals/b3a1c9e0-1234-4abc-9def-1234567890ab/cancel
Authorization: Bearer bp_live_...POST https://botonpay.org/api/public/v1/deals/b3a1c9e0-1234-4abc-9def-1234567890ab/cancel
Authorization: Bearer bp_live_...Ответ:
{ "success": true, "status": "cancelled" }После отмены отправляется webhook deal.cancelled.
5. Appeal — POST /api/public/v1/deals/:id/appeal#
Открыть спор по сделке, если платёж был совершён, но сделка не завершилась успехом: деньги ушли на реквизит, а статус cancelled или expired. К апелляции прикладываются доказательства — чек или выписка.
Требуется scope deals:appeal (входит в deals:write). Вместо :id можно передать UUID сделки, external_id или ваш merchant_order_id.
Тело запроса
| Поле | Тип | Описание |
|---|---|---|
reason | string | Обязательное. Суть претензии, 5–2000 символов |
files | array | Необязательное. До 5 вложений |
files[].name | string | Имя файла с расширением, например receipt.pdf |
files[].type | string | MIME: image/jpeg, image/png, image/gif, image/webp, application/pdf |
files[].data | string | Содержимое в base64. Допускается data-URI (data:image/png;base64,...) |
Ограничения на вложения: до 10 МБ на файл и до 25 МБ суммарно. Другие типы файлов отклоняются с invalid_request.
POST https://botonpay.org/api/public/v1/deals/ORDER-124/appeal
Authorization: Bearer bp_live_...
Content-Type: application/json
{
"reason": "Клиент оплатил 1500 TRY, чек приложен, но сделка отменена",
"files": [
{ "name": "receipt.pdf", "type": "application/pdf", "data": "JVBERi0xLjQK..." }
]
}Ответ 201:
{
"success": true,
"deal_id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"appeal": {
"appeal_id": "7aae91ea-f656-4cf9-9e95-8c20ac6bf4f2",
"status": "open",
"reason": "Клиент оплатил 1500 TRY, чек приложен, но сделка отменена",
"rejection_reason": null,
"resolution_note": null,
"evidence_count": 1,
"created_at": "2026-08-11T09:12:21.510Z",
"resolved_at": null,
"deal_status": "disputed"
}
}Повторный вызов
На сделке может быть только одна апелляция. Повторный запрос не создаёт вторую и не возвращает ошибку — приходит уже открытая с флагом "idempotent": true и кодом 200. Ретрай после сетевого таймаута безопасен.
Когда апелляция невозможна
| Код | HTTP | Причина |
|---|---|---|
conflict | 409 | Сделка уже завершена успешно — оспаривать нечего |
invalid_request | 400 | Пустая или слишком короткая причина, неподдерживаемый тип файла, превышен размер |
not_found | 404 | Сделка не найдена или принадлежит другому мерчанту |
forbidden | 403 | У ключа нет scope deals:appeal |
Статус апелляции — GET /api/public/v1/deals/:id/appeal
Требуется scope deals:read. Если спора нет, приходит "appeal": null и флаг appealable — можно ли его открыть сейчас.
GET https://botonpay.org/api/public/v1/deals/ORDER-124/appeal
Authorization: Bearer bp_live_...Какие callback придут
Сразу после создания апелляции сделка переходит в disputed и уходит deal.appeal_opened. Когда спор разбирают — приходит deal.appeal_resolved. Во всех callback по сделке со спором присутствует блок appeal: без него разрешённый спор не отличить от обычной отмены.
| Поле | Описание |
|---|---|
appeal.appeal_id | Идентификатор апелляции |
appeal.status | open — на разборе, resolved_client — решена в вашу пользу, resolved_trader — отклонена |
appeal.outcome | Только в deal.appeal_resolved: merchant либо provider |
appeal.rejection_reason | При отклонении: payment_not_received, wrong_requisites или fake_receipt |
appeal.resolution_note | Комментарий оператора, если оставлен |
appeal.resolved_at | Момент разрешения спора |
Пример deal.appeal_resolved — спор решён в пользу мерчанта, сделка зачтена:
{
"event": "deal.appeal_resolved",
"deal_id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"merchant_order_id": "ORDER-124",
"status": "completed",
"fiat": "TRY",
"amount_fiat": 1500.00,
"amount_usdt": 30.36437247,
"rate": 49.4,
"appeal": {
"appeal_id": "7aae91ea-f656-4cf9-9e95-8c20ac6bf4f2",
"status": "resolved_client",
"outcome": "merchant",
"reason": "Клиент оплатил 1500 TRY, чек приложен, но сделка отменена",
"rejection_reason": null,
"resolution_note": "Платёж найден в выписке",
"created_at": "2026-08-11T09:12:21.510Z",
"resolved_at": "2026-08-11T10:04:03.117Z"
},
"timestamp": "2026-08-11T10:04:03.200Z"
}Пример отклонённой апелляции — сделка остаётся отменённой:
{
"event": "deal.appeal_resolved",
"deal_id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"status": "cancelled",
"cancellation_reason": "dispute_resolved",
"appeal": {
"appeal_id": "7aae91ea-f656-4cf9-9e95-8c20ac6bf4f2",
"status": "resolved_trader",
"outcome": "provider",
"rejection_reason": "payment_not_received",
"resolution_note": "Платёж на реквизит не поступал",
"resolved_at": "2026-08-11T10:04:03.117Z"
}
}6. Health Check — GET /api/public/v1/health#
Публичный endpoint для мониторинга — не требует API-ключа.
GET https://botonpay.org/api/public/v1/healthGET https://botonpay.org/api/public/v1/healthОтвет:
{
"status": "ok",
"version": "1.3.0",
"database": "ok",
"webhooks": "ok",
"exchange_rate": "ok",
"webhook_worker": "ok",
"telegram_webhook": "configured",
"timestamp": "2026-01-01T00:00:00.000Z"
}6. Webhooks#
Webhook lifecycle. POST /v1/deals возвращает HTTP 201 сразу после создания сделки и назначения реквизитов — доставка callback выполняется отдельным worker и никогда не задерживает и не отменяет создание сделки. Ошибка вашего endpoint (4xx, 5xx, timeout, DNS/SSL) не откатывает сделку и не меняет её статус.
deal.created— сделка создана, статусwaiting_payment. Это первый, а не последний webhook.- На каждое изменение статуса приходит отдельное событие.
deal.completed— окончательное подтверждение успешной оплаты.deal.cancelled,deal.expired,deal.failed— сделка закрыта неуспешно.- Отвечайте любым
HTTP 2xx(200 / 201 / 202 / 204) — тело ответа не важно и не парсится. - Обрабатывайте повторные доставки идемпотентно по
X-Webhook-Idлибо по связкеdeal_uuid+event+status_version. - Проверяйте подпись
X-Signatureпо сырому телу запроса доJSON.parse. - Ищите свой платёж по
merchant_order_id, аdeal_uuidхраните как внешний ID провайдера. - Проверить состояние сделки в любой момент:
GET /v1/deals/{deal_uuid}илиGET /v1/deals/by-merchant-order/{merchant_order_id}.
Куда отправляется callback
Приоритет URL: callback_url из тела запроса → webhook URL, привязанный к API-ключу. Если ни то, ни другое не задано — webhook не отправляется. URL используется буквально: мы не добавляем и не убираем слэш, не меняем регистр, не переписываем путь. Редиректы (301/302/307/308) не выполняются — указывайте конечный URL.
Заголовки запроса
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
X-Signature | HMAC-SHA256 (hex) по сырому телу запроса |
X-Webhook-Event | Имя события, напр. deal.created |
X-Webhook-Id | UUID доставки. Ключ идемпотентности на вашей стороне |
X-Webhook-Attempt | Номер попытки доставки, начиная с 1 |
X-Request-Id | ID запроса для обращений в поддержку |
События
| Событие | Значение | Возможные previous_status | status |
|---|---|---|---|
deal.created | Сделка создана | — | waiting_payment |
deal.processing | Клиент отметил оплату | waiting_payment | processing |
deal.completed | Сделка завершена | processing | completed |
deal.cancelled | Сделка отменена (в том числе автоотмена по таймеру: cancellation_reason: "timeout") | waiting_payment, processing, assigned | cancelled |
deal.expired | Сделка истекла | waiting_payment | expired |
deal.failed | Сделка завершилась ошибкой | processing | failed |
deal.appeal_opened | Открыта апелляция | waiting_payment, processing, cancelled, expired | disputed |
deal.appeal_resolved | Апелляция разрешена — исход в блоке appeal.outcome | disputed | completed, cancelled, disputed |
Поле event содержит только тип события — без скобок, стрелок и статусов. Переход статусов передаётся отдельными полями: previous_status (статус до изменения) и status (текущий статус сделки). Например: event = deal.cancelled, previous_status = waiting_payment, status = cancelled.
Структура payload
| Поле | Описание |
|---|---|
event | Тип события. Только имя, без транзишена |
deal_uuid | UUID сделки BotonPay (дублируется как deal_id) |
merchant_order_id | Ваш ID заказа, без изменений |
previous_status | Статус до изменения; null для deal.created |
status | Текущий статус сделки |
status_version | Монотонная версия статуса. Используйте для упорядочивания и дедупликации |
fiat / currency | Валюта сделки |
amount_fiat, amount_usdt, rate | Сумма и курс |
merchant_amount_usdt | Итог к зачислению мерчанту после комиссии |
client_name | Каноническое ФИО ожидаемого плательщика. Всегда заполнено для TRY, EGP |
expected_sender_name | Аддитивный alias client_name; значение одинаково |
cancelled_at, cancellation_reason | Заполнены для deal.cancelled / deal.expired |
environment, is_test | live или test |
metadata | Ваши свободные поля |
event_created_at, timestamp | Момент формирования события (ISO 8601, UTC) |
Пример: deal.created
POST https://merchant.com/webhook
X-Signature: <hmac_sha256_hex>
X-Webhook-Event: deal.created
X-Webhook-Id: 6f0a5e1b-8f2a-4b6d-8f2e-95a1d7c0f3a2
X-Webhook-Attempt: 1
Content-Type: application/json
{
"event": "deal.created",
"deal_uuid": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"deal_id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"merchant_order_id": "ORDER-124",
"previous_status": null,
"status": "waiting_payment",
"status_version": 1,
"fiat": "RUB",
"amount_fiat": 5000,
"amount_usdt": 62.81,
"rate": 79.60,
"client_name": null,
"expected_sender_name": null,
"environment": "live",
"is_test": false,
"metadata": { "product_id": "sku_42" },
"event_created_at": "2026-01-01T00:00:00.000Z",
"timestamp": "2026-01-01T00:00:00.000Z"
}Пример: deal.cancelled
{
"event": "deal.cancelled",
"deal_uuid": "8f1c2f6e-1d5a-4a1b-9f30-3a2f9b7c1e44",
"deal_id": "8f1c2f6e-1d5a-4a1b-9f30-3a2f9b7c1e44",
"merchant_order_id": "ORDER-1042",
"previous_status": "waiting_payment",
"status": "cancelled",
"status_version": 2,
"cancellation_reason": "merchant_cancelled",
"cancelled_at": "2026-08-06T00:00:00.000Z",
"event_created_at": "2026-08-06T00:00:00.500Z",
"timestamp": "2026-08-06T00:00:00.500Z"
}Финальные события (deal.completed, deal.cancelled, deal.expired, deal.failed) отправляются всегда — независимо от источника отмены (API, панель, трейдер, таймер, апелляция). В payload приходят previous_status, status_version, cancelled_at и cancellation_reason(merchant_cancelled, admin_cancelled, operator_cancelled, trader_cancelled, timeout, dispute_resolved и т.д.). Повторная доставка одного и того же status_version идемпотентна — обрабатывайте её безопасно.
Пример: deal.completed
POST https://merchant.com/webhook
X-Signature: <hmac_sha256_hex>
X-Webhook-Event: deal.completed
Content-Type: application/json
{
"event": "deal.completed",
"deal_uuid": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"deal_id": "b3a1c9e0-1234-4abc-9def-1234567890ab",
"merchant_order_id": "ORDER-124",
"previous_status": "processing",
"status": "completed",
"status_version": 3,
"fiat": "RUB",
"amount_fiat": 5000,
"amount_usdt": 62.81,
"rate": 79.60,
"merchant_amount_usdt": "59.67336683",
"client_name": null,
"expected_sender_name": null,
"metadata": { "product_id": "sku_42" },
"is_test": false,
"event_created_at": "2026-01-01T00:00:00.000Z",
"timestamp": "2026-01-01T00:00:00.000Z"
}Pay-in для TRY и EGP: client_name в payload
Для гео TRY и EGP ФИО ожидаемого плательщика обязательно при создании сделки и всегда приходит в каждом webhook-уведомлении в поле client_name. Аддитивное поле expected_sender_name содержит то же значение и подготовлено для будущего sender-aware matching. Для остальных валют поле равно null, если ФИО не передавалось.
POST https://merchant.com/webhook
X-Signature: <hmac_sha256_hex>
Content-Type: application/json
{
"event": "deal.created",
"deal_uuid": "1c9b7d40-52f1-4d0a-9d75-1f3d0c8a44e1",
"deal_id": "1c9b7d40-52f1-4d0a-9d75-1f3d0c8a44e1",
"merchant_order_id": "ORDER-TRY-5501",
"previous_status": null,
"status": "waiting_payment",
"status_version": 1,
"fiat": "TRY",
"amount_fiat": 4500,
"amount_usdt": 129.31,
"rate": 34.80,
"client_name": "Mehmet Yilmaz",
"expected_sender_name": "Mehmet Yilmaz",
"metadata": {},
"is_test": false,
"timestamp": "2026-01-01T00:00:00.000Z"
}{
"event": "deal.completed",
"deal_uuid": "7a2e5f11-6c30-4b8e-a1f9-c0a4b2d8e5f6",
"deal_id": "7a2e5f11-6c30-4b8e-a1f9-c0a4b2d8e5f6",
"merchant_order_id": "ORDER-EGP-7712",
"previous_status": "processing",
"status": "completed",
"status_version": 3,
"fiat": "EGP",
"amount_fiat": 12000,
"amount_usdt": 243.90,
"rate": 49.20,
"client_name": "Ahmed Hassan Ali",
"expected_sender_name": "Ahmed Hassan Ali",
"metadata": {},
"is_test": false,
"timestamp": "2026-01-01T00:05:00.000Z"
}Проверка подписи PayIn (Node.js)
PayIn: X-Signature = HMAC-SHA256(raw_body, webhook_secret) в hex. Считайте HMAC по raw body, а не по распарсенному и заново сериализованному JSON — иначе подпись не совпадёт.
const crypto = require("crypto");
// rawBody — оригинальный Buffer/строка тела запроса.
// В Express: app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }));
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const signature = req.headers["x-signature"];
if (
!signature ||
expected.length !== signature.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
) {
throw new Error("Invalid signature");
}PayOut использует собственную схему подписи с меткой времени — см. раздел PayOut webhooks. Секреты PayIn и PayOut различаются, не используйте один для проверки другого.
6.1. Доставка, SLA и retry#
- Успех доставки = любой
HTTP 2xx. Тело ответа игнорируется. - Любой другой код, timeout, DNS- или SSL-ошибка = неуспех и постановка в retry.
- Таймаут ожидания вашего ответа — 10 секунд.
- Редиректы не выполняются:
301/302/307/308считаются неуспехом. deal.createdотправляется сразу после COMMIT сделки; цель — доставка в пределах 3 секунд с момента создания.
График повторов
| Сценарий | Интервалы |
|---|---|
Быстрый retry (deal.created, а также ответ 404 — трактуется как гонка «мерчант ещё не сохранил заказ») | 2 с → 2 с → 5 с → 10 с → 30 с, затем обычный график |
| Обычный retry (все прочие события и ошибки) | 1 мин → 5 мин → 15 мин → 1 ч → 6 ч → 24 ч |
После исчерпания графика доставка помечается как failed и видна в панели мерчанта. Сделка при этом остаётся в своём фактическом статусе — сверяйте её через GET /v1/deals/by-merchant-order/{merchant_order_id}.
6.2. Webhook troubleshooting#
| Симптом | Причина | Что делать |
|---|---|---|
404 Payment not found на deal.created | Локальный заказ создаётся после ответа API, а webhook пришёл раньше | Создавайте локальный заказ до POST /deals. Если записи всё же нет — верните 202, поставьте событие во внутреннюю очередь и повторите поиск |
Ищем платёж по deal_uuid и не находим | Перепутаны идентификаторы | Ищите по merchant_order_id; deal_uuid — внешний ID провайдера |
| Подпись не совпадает | HMAC считается по пересериализованному JSON или не тем секретом | Считайте HMAC по raw body; используйте секрет PayIn для PayIn-событий |
| Webhook не приходит вовсе | Не задан callback_url ни в запросе, ни в API-ключе | Передайте callback_url (https) или укажите webhook URL в настройках ключа |
| Доставка помечена неуспешной, хотя обработчик отработал | Возвращается редирект или код вне 2xx | Указывайте конечный URL без редиректов и отвечайте 200/204 |
| События обработаны в неверном порядке | Параллельная обработка доставок | Упорядочивайте по status_version, игнорируйте версии меньше уже применённой |
| Одно и то же событие пришло дважды | Retry после таймаута на вашей стороне | Дедуплицируйте по X-Webhook-Id, отвечайте 200 |
7. Error codes#
| Код | HTTP | Значение |
|---|---|---|
invalid_request | 400 | Invalid or missing parameters |
invalid_api_key | 401 | Missing or wrong API key |
origin_not_allowed | 403 | Origin not in allow-list |
forbidden | 403 | Action forbidden |
not_found | 404 | Deal not found |
conflict | 409 | State conflict (e.g. already completed) |
idempotency_conflict | 409 | Тот же Idempotency-Key отправлен с другим телом запроса |
unprocessable | 422 | Business rule rejected the request |
NO_AVAILABLE_REQUISITES | 422 | Нет свободных реквизитов под сумму и валюту |
CURRENCY_DISABLED | 422 | Валюта отключена или недоступна для этого ключа |
rate_limited | 429 | Too many requests |
internal_error | 500 | Server error |
allocation_timeout | 503 | Полный ответ не успевал уложиться в 3 с — сделка не создана (или отменена до отправки webhook). Повторите с новым Idempotency-Key; тот же ключ вернёт этот же ответ |
DEAL_CREATION_PROCESSING | 202 | Создание сделки по этому Idempotency-Key ещё выполняется |
Пример ошибки:
{
"success": false,
"error": {
"code": "invalid_api_key",
"message": "Invalid API key"
}
}8. Examples#
cURL
curl -X POST https://botonpay.org/api/public/v1/deals \
-H "Authorization: Bearer bp_live_..." \
-H "Idempotency-Key: 8b3f4c02-3f6f-4c88-9e02-0b3e9b3e0e7a" \
-H "Content-Type: application/json" \
-d '{
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
"callback_url": "https://merchant.com/webhook"
}'JavaScript (fetch)
const res = await fetch("https://botonpay.org/api/public/v1/deals", {
method: "POST",
headers: {
"Authorization": "Bearer bp_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
merchant_order_id: "ORDER-124",
fiat: "RUB",
amount_fiat: 5000,
callback_url: "https://merchant.com/webhook",
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error.message);
console.log(data.deal.payment_url);Axios
import axios from "axios";
const { data } = await axios.post("https://botonpay.org/api/public/v1/deals", {
merchant_order_id: "ORDER-124",
fiat: "RUB",
amount_fiat: 5000,
}, { headers: { Authorization: "Bearer bp_live_..." } });
console.log(data.deal.payment_url);Node.js (native fetch)
const res = await fetch("https://botonpay.org/api/public/v1/deals", {
method: "POST",
headers: { "Authorization": "Bearer bp_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ merchant_order_id: "ORDER-124", fiat: "RUB", amount_fiat: 5000 }),
});
console.log(await res.json());Python (requests)
import requests
r = requests.post(
"https://botonpay.org/api/public/v1/deals",
json={"merchant_order_id": "ORDER-124", "fiat": "RUB", "amount_fiat": 5000},
headers={"Authorization": "Bearer bp_live_..."},
timeout=30,
)
print(r.json()["deal"]["payment_url"])PHP (cURL)
<?php
$ch = curl_init("https://botonpay.org/api/public/v1/deals");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer bp_live_...",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"merchant_order_id" => "ORDER-124",
"fiat" => "RUB",
"amount_fiat" => 5000,
]),
]);
$data = json_decode(curl_exec($ch), true);
echo $data["deal"]["payment_url"];Go (net/http)
package main
import (
"bytes"; "encoding/json"; "net/http"
)
func main() {
body, _ := json.Marshal(map[string]any{
"merchant_order_id": "ORDER-124",
"fiat": "RUB",
"amount_fiat": 5000,
})
req, _ := http.NewRequest("POST", "https://botonpay.org/api/public/v1/deals", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer bp_live_...")
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
}C# (HttpClient)
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "bp_live_...");
var payload = new StringContent(
System.Text.Json.JsonSerializer.Serialize(new {
merchant_order_id = "ORDER-124", fiat = "RUB", amount_fiat = 5000
}),
System.Text.Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://botonpay.org/api/public/v1/deals", payload);
Console.WriteLine(await res.Content.ReadAsStringAsync());8.1. SDK snippets — проверка webhook и массовые выплаты#
Проверка подписи webhook (HMAC-SHA256 по сырому телу запроса, ключ — ваш webhook secret).
// Node.js / Express
import crypto from "crypto";
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.header("X-Signature") || "";
const expected = crypto
.createHmac("sha256", process.env.BOTONPAY_WEBHOOK_SECRET)
.update(req.body) // именно сырой Buffer, не JSON.parse
.digest("hex");
const a = Buffer.from(signature), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// event.event: deal.paid | deal.completed | deal.cancelled | payout.completed ...
res.send("ok"); // отвечайте 2xx, иначе будет retry
});# Python / FastAPI
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request):
raw = await request.body()
signature = request.headers.get("X-Signature", "")
expected = hmac.new(
os.environ["BOTONPAY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="invalid signature")
event = await request.json()
return {"ok": True}Массовые выплаты: реестр создаётся последовательными вызовами PayOut API с уникальным merchant_order_id на каждую строку — это гарантирует отсутствие дублей при повторной отправке файла.
# Python — массовая выплата из CSV
import csv, os, requests
API = "https://botonpay.org/api/public/v1/payouts"
HEADERS = {"Authorization": f"Bearer {os.environ['BOTONPAY_API_KEY']}"}
with open("payouts.csv", newline="", encoding="utf-8") as f:
for i, row in enumerate(csv.DictReader(f), start=1):
body = {
"currency": row["currency"],
"amount": float(row["amount"]),
"payment_method": row["payment_method"],
"merchant_order_id": row.get("merchant_order_id") or f"BULK-{i}",
"recipient": {
"full_name": row["full_name"],
"card_number": row.get("card_number") or None,
"phone": row.get("phone") or None,
"iban": row.get("iban") or None,
"bank_name": row.get("bank_name") or None,
},
}
r = requests.post(API, json=body, headers={
**HEADERS, "Idempotency-Key": body["merchant_order_id"]
}, timeout=30)
print(body["merchant_order_id"], r.status_code, r.json())// Node.js — массовая выплата из массива
const rows = require("./payouts.json");
for (const [i, row] of rows.entries()) {
const orderId = row.merchant_order_id ?? `BULK-${i + 1}`;
const res = await fetch("https://botonpay.org/api/public/v1/payouts", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BOTONPAY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": orderId,
},
body: JSON.stringify({ ...row, merchant_order_id: orderId }),
});
console.log(orderId, res.status, await res.json());
}В личном кабинете тот же реестр можно загрузить файлом: раздел «Массовые выплаты» — шаблон Excel, предпросмотр с валидацией каждой строки и отчёт по результату.
9. Changelog#
v1.3.0 — текущая
- Create Deal возвращает
HTTP 201 Created; контракт полей —fiatиamount_fiat - В ответах и webhook-payload явно присутствует
deal_uuidотдельно отmerchant_order_id Idempotency-Keyобязателен для production; повтор возвращает исходный ответ сIdempotent-Replay: true, конфликт —409 idempotency_conflict- Восстановление результата:
GET /deals/by-idempotency-key/{key}иGET /deals/by-merchant-order/{id} - Webhook
deal.createdотправляется первым, сразу после создания сделки - Заголовки доставки:
X-Signature,X-Webhook-Event,X-Webhook-Id,X-Webhook-Attempt,X-Request-Id - Payload содержит
previous_status,status_version,event_created_at - Быстрый retry-график для
deal.createdи ответов404: 2с, 2с, 5с, 10с, 30с - Разделены схемы подписи PayIn и PayOut
v1.2.0
- Idempotency-Key на POST-эндпоинтах
- Health Check
/api/public/v1/health - Расширенная документация: Deal object, Statuses, Webhook events, Versioning, Changelog
- List Deals возвращает
pages,has_next,has_prev
v1.1.0
- Sandbox-режим и ключи
bp_test_* - Rate limits (500 req/min) с заголовками
X-RateLimit-* - List Deals с фильтрами и пагинацией
- Единый формат ошибок
{ success:false, error:{ code, message } }
v1.0.0
- Create Deal
- Get Deal
- Cancel Deal
- Webhooks (HMAC-SHA256, retry-стратегия)
10. Pay out API#
PayOut API позволяет мерчанту создавать заявки на выплату денежных средств получателям в фиатных валютах.
Base URL: https://botonpay.org/api/public/v1
Авторизация: Authorization: Bearer <API_KEY>
Для POST /payouts дополнительно обязателен уникальный заголовок Idempotency-Key: <UNIQUE_KEY>.
10.1. Объект выплаты#
Поддерживаемые валюты
RUB, AED, TRY, KZT, UZS, KGS, BHD, VND, EGP, BDT, INR, ARS, SAR.
Для каждой валюты в административной панели BotonPay настраиваются: доступность PayOut, минимальная и максимальная суммы, процентная и фиксированная комиссии, доступные способы выплаты, список поддерживаемых банков и время выполнения заявки.
Способы выплаты
Поле payment_method. Поддерживаемые значения:
| Значение | Описание |
|---|---|
| card | Выплата на банковскую карту |
| bank_account | Выплата на банковский счёт |
| phone | Выплата по номеру телефона |
| sbp | Выплата через СБП |
| upi | Выплата через UPI |
| iban | Выплата по IBAN |
| mobile_wallet | Выплата на мобильный/электронный кошелёк |
Для каждой валюты разрешается отдельный набор способов выплаты.
10.2. Статусы#
- Ожидает обработки:
created— создана;pending— ожидает;searching_trader— поиск трейдера. - Выполняется:
assigned— назначена трейдеру;processing— трейдер выполняет;proof_uploaded— загружено подтверждение. - Успех:
completed— финальный, выплата успешно выполнена. - Неуспешные финальные:
cancelled,rejected,failed,expired,refunded. При переходе в эти статусы зарезервированная сумма возвращается на баланс мерчанта. - Апелляция:
dispute— открыта апелляция, требуется ручное рассмотрение.
10.3. POST /payouts — Создание#
POST https://botonpay.org/api/public/v1/payouts
Authorization: Bearer bp_live_...
Content-Type: application/json
Idempotency-Key: payout-merchant-10001
{
"merchant_order_id": "PAYOUT-10001",
"currency": "KZT",
"amount": 150000,
"payment_method": "card",
"bank_code": "kaspi",
"recipient": {
"full_name": "IVAN IVANOV",
"card_number": "4400123412341234",
"phone": "+77001234567"
},
"callback_url": "https://merchant.example.com/webhooks/botonpay",
"customer_id": "customer-789",
"metadata": { "user_id": "789" },
"is_test": false
}POST https://botonpay.org/api/public/v1/payouts
Authorization: Bearer bp_live_...
Idempotency-Key: payout-merchant-10001
Content-Type: application/json
{
"merchant_order_id": "PAYOUT-10001",
"currency": "KZT",
"amount": 150000,
"payment_method": "card",
"bank_code": "kaspi",
"recipient": {
"full_name": "IVAN IVANOV",
"card_number": "4400123412341234",
"phone": "+77001234567"
},
"callback_url": "https://merchant.example.com/webhooks/botonpay",
"customer_id": "customer-789",
"metadata": {
"user_id": "789"
},
"is_test": false
}Параметры запроса
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
| merchant_order_id | string | Да | Уникальный ID выплаты на стороне мерчанта |
| currency | string | Да | Валюта выплаты |
| amount | number | Да | Сумма, которую получит получатель |
| payment_method | string | Да | Способ выплаты |
| bank_code | string | Зависит от метода | Код банка |
| recipient | object | Да | Реквизиты получателя |
| callback_url | string | Нет | URL для webhook-уведомлений |
| customer_id | string | Нет | ID клиента на стороне мерчанта |
| metadata | object | Нет | Дополнительные данные мерчанта |
| is_test | boolean | Нет | Признак тестовой выплаты |
Объект recipient
Набор обязательных полей зависит от payment_method.
card — обязательные: full_name, card_number.
{
"full_name": "IVAN IVANOV",
"card_number": "4400123412341234",
"phone": "+77001234567"
}phone — обязательные: full_name, phone.
{ "full_name": "IVAN IVANOV", "phone": "+77001234567" }sbp — дополнительно в корне запроса требуется bank_code.
{ "full_name": "IVAN IVANOV", "phone": "+79991234567" }upi
{ "full_name": "RAHUL SHARMA", "upi_id": "rahul@upi" }iban
{
"full_name": "JOHN SMITH",
"iban": "GE29NB0000000101904917",
"bank_name": "Bank of Georgia",
"swift": "BAGAGE22"
}Успешный ответ: HTTP 201 Created
{
"success": true,
"payout": {
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"merchant_order_id": "PAYOUT-10001",
"status": "pending",
"currency": "KZT",
"amount": 150000,
"fee": 3000,
"total_debit": 153000,
"payment_method": "card",
"bank_code": "kaspi",
"recipient": { "full_name": "IVAN IVANOV", "card_number_masked": "440012******1234" },
"expires_at": "2026-07-16T10:30:00.000Z",
"is_test": false
}
}amount — сумма получателю; fee — комиссия BotonPay; total_debit = amount + fee — общая сумма резервирования; expires_at — срок выполнения выплаты.
Идемпотентность
Для каждого запроса создания выплаты необходимо передавать уникальный Idempotency-Key. Если запрос с таким ключом уже был успешно обработан — новая выплата не создаётся, API возвращает первоначальный результат. Если тот же ключ передан с другим телом запроса:
{
"success": false,
"error": {
"code": "idempotency_conflict",
"message": "Idempotency-Key was already used with different request parameters"
}
}merchant_order_id также должен быть уникальным в рамках мерчанта.
10.4. GET /payouts/:id#
GET https://botonpay.org/api/public/v1/payouts/927f5c36-57bd-4f90-a817-a1b5b67af421
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/payouts/927f5c36-57bd-4f90-a817-a1b5b67af421
Authorization: Bearer bp_live_...{
"success": true,
"payout": {
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"merchant_order_id": "PAYOUT-10001",
"status": "processing",
"currency": "KZT",
"amount": 150000,
"fee": 3000,
"total_debit": 153000,
"payment_method": "card",
"bank_code": "kaspi",
"recipient": { "full_name": "IVAN IVANOV", "card_number_masked": "440012******1234" },
"customer_id": "customer-789",
"created_at": "2026-07-16T10:00:00.000Z",
"updated_at": "2026-07-16T10:05:00.000Z",
"expires_at": "2026-07-16T10:30:00.000Z",
"is_test": false
}
}В ответах API полный номер карты не возвращается — только маскированные реквизиты.
10.5. GET /payouts/by-merchant-order/:id#
GET https://botonpay.org/api/public/v1/payouts/by-merchant-order/PAYOUT-10001
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/payouts/by-merchant-order/PAYOUT-10001
Authorization: Bearer bp_live_...Ответ аналогичен запросу по системному ID.
10.6. GET /payouts — список с фильтрами#
Параметры запроса
| Параметр | Описание |
|---|---|
| status | Фильтр по статусу |
| currency | Фильтр по валюте |
| customer_id | Фильтр по ID клиента |
| merchant_order_id | Поиск по ID мерчанта |
| date_from | Начальная дата |
| date_to | Конечная дата |
| page | Номер страницы |
| per_page | Количество записей на странице |
GET https://botonpay.org/api/public/v1/payouts?status=completed¤cy=KZT&page=1&per_page=20
Authorization: Bearer bp_live_...GET https://botonpay.org/api/public/v1/payouts?status=completed¤cy=KZT&page=1&per_page=20
Authorization: Bearer bp_live_...{
"success": true,
"data": [
{
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"merchant_order_id": "PAYOUT-10001",
"status": "completed",
"currency": "KZT",
"amount": 150000,
"fee": 3000,
"total_debit": 153000,
"payment_method": "card",
"created_at": "2026-07-16T10:00:00.000Z",
"completed_at": "2026-07-16T10:10:00.000Z"
}
],
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}10.7. POST /payouts/:id/cancel#
POST /payouts/{id}/cancel
Отмена доступна только в статусах created, pending, searching_trader. После назначения трейдера или начала выполнения отмена через публичный API недоступна.
POST https://botonpay.org/api/public/v1/payouts/927f5c36-57bd-4f90-a817-a1b5b67af421/cancel
Authorization: Bearer bp_live_...
Content-Type: application/json
{ "reason": "customer_cancelled" }POST https://botonpay.org/api/public/v1/payouts/927f5c36-57bd-4f90-a817-a1b5b67af421/cancel
Authorization: Bearer bp_live_...
Content-Type: application/json
{
"reason": "customer_cancelled"
}Допустимые причины:
customer_cancelled · duplicate · incorrect_details · incorrect_amount · merchant_request · other
{
"success": true,
"payout": {
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"merchant_order_id": "PAYOUT-10001",
"status": "cancelled",
"cancel_reason": "customer_cancelled",
"cancelled_at": "2026-07-16T10:03:00.000Z"
}
}Если отмена невозможна:
{
"success": false,
"error": {
"code": "conflict",
"message": "Payout is already being processed",
"details": {
"code": "payout_cannot_be_cancelled",
"status": "processing"
}
}
}10.8. POST /payouts/:id/dispute#
Открыть спор по выплате: получатель не увидел денег, реквизит не тот, выплата зависла у трейдера. Доступно в статусах assigned, processing,proof_uploaded, completed. Требуется scope payouts:write. Спор на выплате один: повторный вызов вернёт уже открытый с "idempotent": true — ретрай после сетевого таймаута безопасен.
Статус выплаты при этом не меняется — она остаётся в своём (например, completed), а спор ведётся отдельно и разбирается поддержкой. О его открытии приходит вебхук payout.dispute_opened.
POST https://botonpay.org/api/public/v1/payouts/{id}/dispute
Authorization: Bearer bp_live_...
Content-Type: application/json
{
"reason": "Получатель не увидел зачисление, прошло 3 часа",
"files": [
{ "name": "screenshot.png", "type": "image/png", "data": "<base64>" }
]
}Вложения необязательны: до 5 файлов, jpg/jpeg/png/gif/webp/pdf, до 10 МБ каждый и 25 МБ суммарно. Поле reason — от 5 до 2000 символов.
{
"success": true,
"payout_id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"dispute": {
"dispute_id": "5f1c...",
"status": "open",
"reason": "Получатель не увидел зачисление, прошло 3 часа",
"resolution_note": null,
"evidence_count": 1,
"created_at": "2026-08-19T09:12:00.000Z",
"resolved_at": null,
"payout_status": "completed"
}
}GET по тому же адресу возвращает текущее состояние спора (или "dispute": null и признак disputable), scope payouts:read.
10.9. Webhooks, ошибки и примеры#
Sandbox
Sandbox используется для тестирования интеграции без проведения реальных выплат. Тестовый режим активируется API-ключом с префиксом bp_test_или передачей "is_test": true. В sandbox: реальный баланс не резервируется, заявка не передаётся реальному трейдеру, банковская операция не выполняется; формат API-ответов и webhook-уведомлений соответствует production.
Симуляция статуса (только для тестовых выплат и ключей bp_test_*):
POST https://botonpay.org/api/public/v1/sandbox/payouts/{id}/status
Authorization: Bearer bp_test_...
Content-Type: application/json
{ "status": "completed", "proof_url": "https://example.com/proof.jpg" }Завершить выплату без чека нельзя — ни в production, ни в sandbox. В sandbox чек прикладывается полем proof_url (https-ссылка): оно же уедет мерчанту в вебхуке, так что скачивание чека проверяется end-to-end. Без него ответ: 422 unprocessable, details.code = "proof_required".
{
"success": true,
"payout": {
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"status": "completed",
"is_test": true
}
}Webhooks
BotonPay отправляет POST на callback_url при каждом изменении статуса.
Content-Type: application/json
X-BotonPay-Timestamp: 1784196600
X-BotonPay-Signature: sha256=...
X-BotonPay-Event: payout.completed
X-BotonPay-Event-Id: evt_18e2f0a0a8Webhook-секрет выплат уникален для каждого мерчанта и отличается от секрета API-ключа, которым подписываются вебхуки приёма. Он показан в кабинете: API → PayOut — секрет вебхуков, там же его можно сменить.
События:
payout.created,payout.pending,payout.assignedpayout.processing,payout.proof_uploadedpayout.completed,payout.cancelled,payout.rejectedpayout.failed,payout.expired,payout.refundedpayout.dispute_opened— мерчант открыл спор (см. 10.8)payout.dispute_opened,payout.amount_changed
{
"event": "payout.completed",
"event_id": "evt_18e2f0a0a8",
"created_at": "2026-07-16T10:10:00.000Z",
"payout": {
"id": "927f5c36-57bd-4f90-a817-a1b5b67af421",
"merchant_order_id": "PAYOUT-10001",
"status": "completed",
"amount": 150000,
"fee": 3000,
"total_debit": 153000,
"currency": "KZT",
"completed_at": "2026-07-16T10:10:00.000Z"
}
}Проверка подписи
Алгоритм: HMAC-SHA256. Строка для подписи — timestamp + "." + raw_body.
signature = HMAC_SHA256(webhook_secret, timestamp + "." + raw_body)
X-BotonPay-Signature: sha256=<SIGNATURE>Важно использовать исходное тело HTTP-запроса без повторной сериализации JSON. Мерчанту рекомендуется проверять: (1) X-BotonPay-Signature, (2) X-BotonPay-Timestamp, (3) уникальность X-BotonPay-Event-Id, (4) соответствие merchant_order_id, (5) актуальный статус через GET /payouts/{id} для критически важных операций.
Повторная отправка
Если сервер мерчанта не вернул HTTP 2xx, BotonPay повторяет отправку по графику: 1 мин → 5 мин → 15 мин → 1 ч → 4 ч → 12 ч → 24 ч. Максимум 8 попыток. Успехом считается любой HTTP 200–299. Обработка webhook на стороне мерчанта должна быть идемпотентной по event_id.
Комиссия и баланс
Комиссия состоит из процентной и фиксированной частей:
fee = amount × percentage + fixed_fee
total_debit = amount + fee
// Пример:
amount = 150000 KZT
percentage = 2%
fixed_fee = 0 KZT
fee = 3000 KZT
total_debit = 153000 KZTПри создании PayOut total_debit резервируется на балансе мерчанта в соответствующей валюте. При completed — окончательно списывается, при cancelled/rejected/failed/expired/refunded — возвращается.
При недостаточном балансе:
{
"success": false,
"error": {
"code": "insufficient_balance",
"message": "Insufficient merchant balance",
"details": { "currency": "KZT", "required": 153000, "available": 120000 }
}
}Формат ошибок
{
"success": false,
"error": {
"code": "validation_error",
"message": "Invalid request parameters",
"details": { "field": "recipient.card_number", "reason": "invalid_card_number" }
}
}Коды ошибок
| code | HTTP | Описание |
|---|---|---|
| unauthorized | 401 | Неверный API-ключ |
| forbidden | 403 | Нет доступа к PayOut |
| validation_error | 422 | Ошибка валидации |
| unsupported_currency | 422 | Валюта не поддерживается |
| unsupported_payment_method | 422 | Метод не поддерживается |
| invalid_recipient_details | 422 | Неверные реквизиты |
| amount_below_minimum | 422 | Сумма ниже минимальной |
| amount_above_maximum | 422 | Сумма выше максимальной |
| insufficient_balance | 409 | Недостаточно средств |
| duplicate_merchant_order_id | 409 | Дублирующий ID |
| idempotency_conflict | 409 | Конфликт ключа идемпотентности |
| payout_not_found | 404 | Выплата не найдена |
| payout_cannot_be_cancelled | 409 | Нельзя отменить |
| rate_limit_exceeded | 429 | Превышен лимит |
| internal_error | 500 | Внутренняя ошибка сервера |
| service_unavailable | 503 | Сервис PayOut временно недоступен |
Пример cURL
curl -X POST "https://botonpay.org/api/public/v1/payouts" \
-H "Authorization: Bearer bp_test_xxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-test-10001" \
-d '{
"merchant_order_id": "PAYOUT-10001",
"currency": "KZT",
"amount": 150000,
"payment_method": "card",
"bank_code": "kaspi",
"recipient": {
"full_name": "IVAN IVANOV",
"card_number": "4400123412341234"
},
"callback_url": "https://merchant.example.com/webhooks/botonpay",
"is_test": true
}'Пример JavaScript
const response = await fetch(
"https://botonpay.org/api/public/v1/payouts",
{
method: "POST",
headers: {
Authorization: "Bearer bp_test_xxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "payout-test-10001",
},
body: JSON.stringify({
merchant_order_id: "PAYOUT-10001",
currency: "KZT",
amount: 150000,
payment_method: "card",
bank_code: "kaspi",
recipient: {
full_name: "IVAN IVANOV",
card_number: "4400123412341234",
},
callback_url: "https://merchant.example.com/webhooks/botonpay",
is_test: true,
}),
}
);
const result = await response.json();
if (!response.ok) {
throw new Error(result?.error?.message || "Failed to create PayOut");
}
console.log(result.payout);Рекомендуемый порядок интеграции
- Получить тестовый API-ключ
bp_test_*. - Настроить
callback_url. - Получить webhook secret.
- Реализовать проверку HMAC-подписи.
- Создать тестовый PayOut через
POST /payouts. - Сохранить системный
payout.id. - Симулировать статус через sandbox endpoint.
- Проверить получение webhook.
- Проверить повторную обработку одинакового
event_id. - После успешного тестирования получить production-ключ
bp_live_*.
Основной сценарий
created
→ pending
→ searching_trader
→ assigned
→ processing
→ proof_uploaded
→ completed
// Отмена до назначения трейдера:
created / pending / searching_trader → cancelled
// С апелляцией:
processing / proof_uploaded → dispute → completed / rejected / refunded