Venta

Venta · API

Один 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')
}

Версия 2 — рекомендуемая

У формулы выше есть слабое место: в подпись не входят ни метод, ни адрес ручки, ни время. Поэтому 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. Хвостовая косая значения не имеет.

Часы. Окно годности — 5 минут в обе стороны. Если на вашем сервере время уехало, запросы начнут получать 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 — тот же номер заказа можно выставить заново. Оплаченный платёж не отменяется: на него есть возврат, и подменять одно другим нельзя.

Отмена не запрещает оплату задним числом. Если покупатель уже открыл QR в приложении банка, деньги могут прийти после отмены — такой платёж мы зачислим и пришлём 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

Чтение — обычной подписью 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, либо вход в кабинет. И даже тогда деньги уйдут только на реквизиты, которые сохранил оператор. При любом подозрении на утечку сообщите оператору: ключи перевыпускаются мгновенно, старые сразу перестают работать.

Номер и секрет выглядят одинаково — это два UUID, и по самой строке их не различить. Наружу уходит только 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 есть там, где на ошибку можно осмысленно отреагировать кодом.

HTTPcodeЧто делать
400offer_not_acceptedвойти в кабинет и принять условия работы
400not_cancelableплатёж уже закрыт или оплачен — смотрите status в ответе
400insufficient_fundsсумма больше доступного остатка, он же придёт в available_rub
400tax_data_requiredзаполнить ИНН и налоговый статус в кабинете
401—неизвестный api_key или неверная подпись
403payout_api_disabledпопросить у оператора payout_token
404—объекта нет или он принадлежит другому мерчанту
429—подождать retry_after секунд (он же в заголовке Retry-After)
502all_providers_failedшлюзы есть, но все отказали сейчас; attempts покажет почему. Повторять имеет смысл, тем же order_id
502no_live_providerбоевой шлюз не подключён. Повторять бессмысленно — до подключения не изменится ничего

Повтор после 429 и 502 безопасен: order_id у платежа и подпись у возврата и выплаты защищают от двойного проведения.