Один API — все способы оплаты. Подключение — через оператора: напишите нам,
после проверки проекта вы получите номер магазина (merchant_id) и
секретный ключ (api_secret, по сети не передаётся — им считается подпись).
Первым выдаётся магазин-песочница: полный цикл — платёж, статусы, вебхуки — без
реальных денег. Когда интеграция готова, рядом заводится боевой магазин со своей
парой ключей, а песочница остаётся вам для проверок.
Каждый запрос подписывается HMAC-SHA256 от канонической строки:
поля запроса (кроме signature и пустых) сортируются по имени ключа,
склеиваются как key=value через &.
const crypto = require('crypto')
function sign(params, secret) {
const s = Object.keys(params)
.filter(k => k !== 'signature' && params[k] !== undefined && params[k] !== null && params[k] !== '')
.sort()
.map(k => `${k}=${typeof params[k] === 'object' ? JSON.stringify(params[k]) : params[k]}`)
.join('&')
return crypto.createHmac('sha256', secret).update(s).digest('hex')
}
У формулы выше есть слабое место: в подпись не входят ни метод, ни адрес ручки, ни
время. Поэтому GET /api/v1/payment/7 и POST /api/v1/payment/7/refund
дают одну и ту же подписываемую строку, и подпись, подсмотренная у чтения, годится
для возврата — и годится бессрочно.
Версия 2 добавляет в подписываемую строку метод, путь, время и одноразовое число.
Включается полем sig_v: 2 в самом запросе — переходить можно по одной
интеграции, старая формула продолжает работать.
function signV2(method, path, params, secret) {
// params ОБЯЗАН содержать sig_v: 2, ts (миллисекунды epoch) и nonce
const s = `v2\n${method.toUpperCase()}\n${path}\n${canonical(params)}`
return crypto.createHmac('sha256', secret).update(s).digest('hex')
}
// пример
const body = {
merchant_id: '0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63',
order_id: 'order-123', amount: 299.00, method: 'sbp',
sig_v: 2,
ts: Date.now(), // окно ±5 минут
nonce: crypto.randomBytes(12).toString('hex'), // до 64 символов, не повторять
}
body.signature = signV2('POST', '/api/v1/payment/create', body, apiSecret)
path — адрес без домена и без строки запроса, ровно как в запросе:
/api/v1/payment/create, /api/v1/payment/7/refund.
Хвостовая косая значения не имеет.
401 с кодом
stale_timestamp — это не блокировка, а расхождение часов, лечится NTP.Коды отказа второй версии: bad_signature, bad_timestamp,
stale_timestamp, bad_nonce, nonce_reused,
bad_signature_version, signature_version_too_old.
POST https://ventapay.click/api/v1/payment/create
Content-Type: application/json
{
"merchant_id": "0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63",
"order_id": "order-123", // ОБЯЗАТЕЛЕН, до 64 символов; повтор вернёт тот же платёж
"amount": 299.00, // ₽, здесь — В РУБЛЯХ
"method": "sbp", // sbp | card | any — «any» или пропуск поля
// означает, что способ выберет покупатель
"description": "Подписка на месяц",
"customer": "tg:123456789", // опционально
"signature": "…" // подпись всех полей выше
}
Ответ: { payment_id, status, amount, payment_url, provider, is_test } —
покупателя редиректите на payment_url.
Способ оплаты можно не выбирать за покупателя. Передайте
"method": "any" или не передавайте поле вовсе — страница оплаты
покажет и СБП, и карту, а покупатель решит сам. Чем он в итоге заплатил,
придёт в вебхуке и встанет в поле method платежа: значения
any в отчётах и выгрузках не остаётся.
Почему order_id обязателен: без него один и тот же подписанный запрос можно
переслать повторно и получить сколько угодно платежей. С ним повтор возвращает
duplicate: true и тот же payment_id — это защита и от повторов, и от
двойного списания при ретраях вашей стороны.
Лимиты суммы: от 10 до 300000 ₽. Частота: до 60 запросов в минуту с адреса.
= и & без экранирования, поэтому не кладите их
в значения полей, которые подписываете (особенно в order_id и
external_id). Для описаний и причин это безопасно, но правило проще
соблюдать целиком, чем помнить исключения.GET https://ventapay.click/api/v1/payment/{payment_id}?merchant_id=0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63&signature=…
Подписываются параметры api_key и id (id — из пути).
Ответ — та же карточка платежа, что и в списке ниже.
Статусы: created → pending → paid | failed | expired | canceled,
после возврата — refunded. Терминальны только paid и
refunded: истёкший и отменённый счёт всё ещё может стать оплаченным,
если покупатель нажал «оплатить» в приложении банка по уже выданному QR.
Для ежедневной сверки. Страница идёт курсором, а не смещением: на живом потоке смещение пропускает строки, и в сверке это выглядит как недостача.
GET https://ventapay.click/api/v1/payments?merchant_id=0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63&status=paid&limit=50&signature=…
→ { "payments": [ { payment_id, order_id, status, amount, method, provider,
description, payment_url, refunded, created_at,
expires_at, paid_at, is_test }, … ],
"next_before": 1042 }
Необязательные фильтры: status, method,
order_id, from и to (даты ISO-8601),
limit (до 200, по умолчанию 50). Следующая страница —
before=<next_before>; когда next_before равен
null, лента кончилась. Подписываются все переданные параметры.
POST https://ventapay.click/api/v1/payment/{payment_id}/cancel
{ "merchant_id": "0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63", "signature": "…" } // подписывается api_key + id из пути
Закрывает неоплаченный счёт и освобождает его order_id — тот же
номер заказа можно выставить заново. Оплаченный платёж не отменяется:
на него есть возврат, и подменять одно другим нельзя.
payment.paid. Отказаться от них нельзя: они
списаны у покупателя.POST https://ventapay.click/api/v1/payment/{payment_id}/refund
{
"merchant_id": "0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63",
"amount": 100.00, // ₽; без поля возвращается весь остаток
"reason": "покупатель отказался",
"external_id": "your-refund-1", // опционально, ваш ключ идемпотентности
"signature": "…" // подписываются поля выше + id из пути
}
Ответ: { refund_id, refunded_total, status, refund_status, debt_added }.
refund_status — состояние самого возврата: pending означает,
что сумма уже удержана с вашего баланса, а покупателю уйдёт после обработки
оператором; done — деньги покупателю отправлены. Если возврат не
удастся, придёт вебхук payment.refund_failed, удержание снимется и
платёж снова можно будет вернуть.
Комиссия платформы при возврате не возвращается: покупателю уходит вся сумма
платежа, а получали вы её за вычетом комиссии — разница остаётся на вас. Если
доступного остатка не хватило, разница попадает в debt_added и гасится
из следующих поступлений.
Повтор того же запроса безопасен. Одинаковые байты считаются одним и тем же
запросом: ретрай по таймауту вернёт тот же refund_id, а не спишет с вас
второй раз. Чтобы провести ДВА одинаковых возврата намеренно, передайте разные
external_id — поле входит в подпись, и запросы перестанут быть
одинаковыми.
GET https://ventapay.click/api/v1/refund/{refund_id}?merchant_id=0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63&signature=…
→ { refund_id, payment_id, amount, status, source, reason,
from_balance, debt_added, created_at, executed_at, is_test }
Подписываются api_key и id (id — из пути).
status: pending — сумма удержана с вашего баланса, покупателю
уйдёт после обработки оператором; done — деньги отправлены;
failed — исполнить не удалось, удержание снято, возврат можно повторить.
from_balance и debt_added объясняют, почему доступный
остаток изменился не на всю сумму возврата.
POST на ваш Webhook URL при каждой смене статуса:
{
"event_id": "evt_…", // уникален для доставки; повторы несут ТОТ ЖЕ номер
"created_at": "2026-08-03T12:00:00.000Z",
"event": "payment.paid",
"payment_id": 42,
"order_id": "order-123",
"amount": 29900, // ЗДЕСЬ — В КОПЕЙКАХ
"currency": "RUB",
"status": "paid",
"method": "sbp",
"provider": "…",
"is_test": false,
"refunded": 0, // сколько всего возвращено по платежу
"paid_at": "2026-08-03T12:00:00.000Z",
"signature": "…"
}
api_secret)
и сравнивайте amount с суммой заказа. Отвечайте HTTP 200 — иначе повторим доставку
по расписанию 1 мин → 5 мин → 15 мин → 1 ч → 3 ч → 6 ч.Зачисляйте заказ только по event: "payment.paid". У возврата своё
событие payment.refunded с полями refund_id и
refund_amount; при частичном возврате status остаётся
paid, а amount — исходной суммой платежа, потому что платёж не
изменился. Сколько денег вернулось, показывает refunded.
event_id и отбрасывайте уже виденные.
Повторная доставка уходит теми же байтами — отличить её от новой оплаты можно
только по этому полю. Без такой проверки любой сбой на вашей стороне (таймаут
базы, перезапуск в момент обработки) оборачивается повторной отгрузкой товара
по одному платежу.События: payment.paid, payment.failed,
payment.expired, payment.canceled,
payment.refunded, payment.refund_failed.
Список открытый — обрабатывайте известные вам и отвечайте 200 на остальные,
иначе неизвестное событие будет повторяться сутки.
До включения боевого режима payment_url ведёт на тестовую страницу с кнопками
«Оплатить»/«Отклонить» — прогоните полный цикл, включая вебхук, без реальных денег.
Тестовые платежи помечены is_test: true и в статистику не попадают.
Баланс и заявки на вывод — в кабинете, вход по почте и паролю. Выплата уходит только на реквизиты, сохранённые оператором при проверке — сменить их ни через кабинет, ни через API нельзя: подмена реквизитов получателя и есть главный способ увести чужую выплату. Минимальная сумма — 100 ₽. Перед первой живой выплатой понадобятся ИНН и налоговый статус получателя (п. 14.3 договора) — форма в разделе «Выплаты».
Чтение — обычной подписью api_secret, эти ручки денег не двигают:
GET https://ventapay.click/api/v1/balance?merchant_id=0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63&signature=…
→ { available_rub, debt_rub, held_rub, in_reserve_rub, min_payout_rub,
payout_requisites, payout_api_enabled }
GET https://ventapay.click/api/v1/payouts?merchant_id=0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63&signature=…
Создание заявки на вывод подписывается отдельным ключом
payout_token, а не api_secret:
POST https://ventapay.click/api/v1/payout
{ "merchant_id": "0f8c3a21-6d4b-4f19-9a02-7c5e1b8d4a63", "amount": 500, "signature": "<подпись ключом payout_token>" }
→ { payout_id, amount_rub, status, is_test, available_rub }
Повтор той же заявки безопасен. Как и у возврата, одинаковые байты считаются
одним запросом: ретрай по таймауту вернёт тот же payout_id с пометкой
duplicate: true, а не создаст вторую заявку на те же деньги. Чтобы вывести
ещё раз намеренно, поменяйте сумму или добавьте в тело external_id — подпись
станет другой.
api_secret лежит в коде вашей
интеграции и участвует в каждом запросе о платежах — его утечка не должна открывать
доступ к деньгам. Ключ payout_token выдаёт оператор по запросу, по умолчанию его
нет, и отозвать его можно, не трогая рабочую интеграцию. Деньги в любом случае уходят
только на реквизиты, сохранённые оператором.| Ключ | Для чего | Где живёт |
|---|---|---|
merchant_id + api_secret | создание платежей, возвраты, чтение баланса, проверка подписи вебхука | в коде вашего сервиса |
payout_token | только заявки на вывод по API | в коде бота, если выплаты автоматизированы |
| токен кабинета | весь кабинет: настройки, ключи, выплаты | только у владельца, не в коде |
Разделение сделано намеренно: merchant_id и api_secret
участвуют в каждой операции и рискуют утечь вместе с исходниками или логами. Их утечка
позволит создавать платежи и возвраты, но не даст вывести деньги: для этого нужен
либо payout_token, либо вход в кабинет. И даже тогда деньги уйдут только на
реквизиты, которые сохранил оператор. При любом подозрении на утечку сообщите
оператору: ключи перевыпускаются мгновенно, старые сразу перестают работать.
merchant_id. Если перепутать их
местами, придёт честный 401 unknown merchant_id, а не молчаливый сбой.Это не один магазин с переключателем, а два разных, у каждого свой
merchant_id и свой api_secret. Тестовым номером боевой платёж
создать нельзя физически — вы просто обращаетесь к другому магазину.
Когда интеграция готова, оператор заводит вам боевой магазин и выдаёт вторую пару ключей.
Песочница при этом продолжает работать — есть где проверять правки, не трогая
боевой поток. Контур видно в кабинете в разделе «Интеграция» и в поле is_test
в каждом ответе API.
Ключи вида vnt_live_pub_… выданы до 21.09.2026. Они продолжают работать
без ограничения срока и менять из-за них ничего не нужно: новый формат касается только
вновь выдаваемых ключей. Поле api_key в запросах тоже принимается всегда —
это прежнее имя merchant_id.
Отказ приходит как { "error": "…", "code": "…" }; code есть там,
где на ошибку можно осмысленно отреагировать кодом.
| HTTP | code | Что делать |
|---|---|---|
| 400 | offer_not_accepted | войти в кабинет и принять условия работы |
| 400 | not_cancelable | платёж уже закрыт или оплачен — смотрите status в ответе |
| 400 | insufficient_funds | сумма больше доступного остатка, он же придёт в available_rub |
| 400 | tax_data_required | заполнить ИНН и налоговый статус в кабинете |
| 401 | — | неизвестный api_key или неверная подпись |
| 403 | payout_api_disabled | попросить у оператора payout_token |
| 404 | — | объекта нет или он принадлежит другому мерчанту |
| 429 | — | подождать retry_after секунд (он же в заголовке Retry-After) |
| 502 | all_providers_failed | шлюзы есть, но все отказали сейчас; attempts покажет почему. Повторять имеет смысл, тем же order_id |
| 502 | no_live_provider | боевой шлюз не подключён. Повторять бессмысленно — до подключения не изменится ничего |
Повтор после 429 и 502 безопасен: order_id у платежа и подпись у возврата и
выплаты защищают от двойного проведения.