Merchant API

API для магазинов

Счета со ссылкой на оплату в Telegram, вебхуки с подписью, выплаты чеками и переводами, возвраты и подписки.

https://oropay.app/api/merchant комиссия 0.75% счёт живёт 1 час бот @OroPayBot
Содержание

Обзор

Merchant API позволяет вашему боту или сайту принимать оплату через OroPay и платить людям с баланса магазина. Покупатель платит из своего кошелька OroPay в Telegram или переводом на адрес с любого кошелька и биржи — без нашего бота; деньги приходят на баланс владельца магазина.

  • Счета — выставить счёт, отправить покупателя на нашу страницу оплаты checkoutUrl (или в бота по payUrl), узнать об оплате вебхуком или запросом. Оплата на адрес в 13 сетях и тестовый режим без настоящих денег.
  • Выплаты — баланс, чеки, переводы по Telegram ID и возвраты по счетам. Выключены по умолчанию.
  • Подписки — тарифы с автоматическим списанием раз в период.

Базовый адрес

base url
https://oropay.app/api/merchant

У счетов старые пути без версии (/api/merchant/invoice, /api/merchant/invoices), у выплат, возвратов и подписок — с версией: /api/merchant/v1/…. Работают оба набора.

Формат

  • Тело запроса — JSON с заголовком Content-Type: application/json. Без этого заголовка тело не будет прочитано.
  • Ответ — всегда JSON. Успех: "ok": true и данные. Ошибка: "ok": false, текст в error, а у методов /v1/… ещё и стабильный code (см. Ошибки).
  • Суммы — строками ("1.5"), чтобы не терять точность. Даты — ISO 8601 в UTC.
  • В теле методов /v1/… поля можно писать и в snake_case (spend_id), и в camelCase (spendId).
  • Неизвестный путь под /api/merchant/v1/ отвечает 404 с code: "METHOD_NOT_FOUND".

Авторизация и ключи

Приложение магазина создаётся в мини-приложении OroPay, раздел «Магазин». До 10 приложений на аккаунт. При создании показываются два значения — каждое один раз:

ЧтоКак выглядитЗачем
Ключ APIcxc_ + 40 символовПередаётся нам в каждом запросе. Хранится у нас только отпечаток (SHA-256) — показать ключ повторно невозможно.
Секрет вебхука48 символов, без префиксаОстаётся у вас: им вы проверяете подпись наших уведомлений. Нам в запросах не передаётся.

Как передавать ключ

Только в заголовке Authorization по схеме Bearer. В адресе или теле ключ не принимается.

http
Authorization: Bearer cxc_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0

Пишете на Python через стандартный urllib? Укажите свой заголовок User-Agent, например User-Agent: MyShop/1.0: запросы с заголовком по умолчанию Python-urllib/… отклоняет защита сайта (ответ 403, «error code: 1010»). С requests, Node.js, curl и другими библиотеками ничего указывать не нужно.

  • Ключ и секрет перевыпускаются по отдельности. Новый ключ начинает действовать сразу, старый перестаёт работать немедленно — даже для запросов выплат, которые уже в пути (приложение перечитывается под замком перед списанием).
  • После перевыпуска секрета все уведомления подписываются новым секретом.
  • Перевыпуск ключа и секрета, смена адреса вебхука, режима комиссии и включение приложения подтверждаются PIN-кодом (если он установлен). Владелец получает сообщение в Telegram о каждом перевыпуске и смене адреса вебхука.
  • После перевыпуска ключа «Выплаты по API» выключаются: разрешение, выданное старому ключу, на новый не переходит. Включите их снова в кабинете — с PIN-кодом.
  • Выключенное владельцем приложение отвечает на любой ключ как на неизвестный — 401.

Ключ — доступ к вашим счетам, а с правом payouts — к вашим деньгам. Держите его только на сервере: не кладите в код страницы, мобильное приложение или репозиторий.

Права ключа

У ключа есть набор прав. Новый ключ получает только invoices. Права меняет владелец в кабинете приложения.

ПравоЧто открывает
invoicesСчета: создать, получить, отменить, список. Также тарифы и подписки. Возвраты по счетам — только когда включены «Выплаты по API».
payoutsЧеки и переводы. Работают, только если владелец включил «Выплаты по API». Баланс виден любому ключу.
subscriptionsТарифы и подписки — без счетов. Удобно для бота, который только проверяет доступ.

Не хватает права — 403 с code: "SCOPE_REQUIRED". Ключ совсем без прав сохранить нельзя.

Формат ошибок

Методы /api/merchant/v1/… (выплаты, возвраты, подписки) возвращают стабильный машинный код — стройте логику на code, текст error предназначен человеку и может меняться. Иногда в ответе есть дополнительные поля (например, остаток лимита).

ответ 403
{
  "ok": false,
  "code": "DAILY_LIMIT_EXCEEDED",
  "error": "Суточный лимит выплат $500.00 исчерпан (считается по всем магазинам владельца): ушло ≈ $498.10, запрошено ≈ $5.00",
  "limitUsd": 500,
  "spentUsd": 498.1,
  "leftUsd": 1.9
}

Старые методы счетов (/api/merchant/invoice…) возвращают только текст: {"ok": false, "error": "…"}. Исключение — 403 SCOPE_REQUIRED, у него есть code. Полный список кодов — в разделе Коды ошибок.

HTTPЗначение
400Ошибка в данных запроса или состояние не позволяет операцию
401Ключ не передан, неизвестен, перевыпущен, или приложение выключено
403Нет права ключа, выплаты выключены, превышен лимит, средства владельца заморожены
404Объект не найден или принадлежит другому приложению; неизвестный метод /v1
409Конфликт с уже сделанным: ключ операции занят другой операцией, счёт уже оплачен и т. п.
429Слишком часто — см. Частота запросов
500Сбой на нашей стороне
503Нет курса монеты — повторите позже

Частота запросов

СчётчикЛимитЧто считает
Все запросы к /api/merchant/…120 в минутуС одного IP-адреса. Ответ 429 без code: {"error": "Слишком часто. Магазину положено не больше 120 запросов в минуту."}
Выплаты и возвраты60 в минутуНа одно приложение: balance, cheques, transfers, invoices/{code}/refund(s). Ответ 429 с code: "RATE_LIMIT".
Весь сайт /api/…600 в минутуОбщий счётчик с одного IP-адреса.

Окно — одна минута. В ответах есть стандартные заголовки RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Получив 429, подождите и повторите запрос с тем же ключом операции.

Идемпотентность

Сеть теряет ответы, и ваш код обязательно повторит запрос. Чтобы повтор не создал второй счёт и не заплатил дважды, у операций есть ключи. Отдельного заголовка вроде Idempotency-Key нет — ключ передаётся полем тела.

ПолеГдеОбязательноПовтор с тем же ключом
orderIdСоздание счётанетТа же сумма и валюта — тот же счёт. Другая сумма, или счёт уже истёк/отменён — 409.
spend_idЧеки и переводыдаТе же данные — та же выплата и "repeated": true. Другие данные — 409 SPEND_ID_CONFLICT.
refund_idВозврат по счётудаТот же счёт и сумма — тот же возврат и "repeated": true. Иначе — 409 REFUND_ID_CONFLICT.
plan_idСоздание тарифанетТе же монета, цена, период и пробный — тот же тариф. Иначе — 409 PLAN_ID_CONFLICT.
  • spend_id, refund_id, plan_id — от 1 до 64 печатных ASCII-символов без пробелов. spend_id общий для чеков и переводов и уникален в пределах приложения. orderId — до 128 символов (длиннее — обрезается).
  • За уникальностью следит база (уникальный индекс), а запись о выплате создаётся в той же транзакции, что и списание. Два одновременных запроса с одним ключом не спишут дважды.
  • Отмена счёта, чека и подписки идемпотентна сама по себе: повторная отмена уже отменённого — не ошибка.

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

Библиотеки и примеры

Готовые библиотеки для Node.js и Python и примеры ботов и сайта: принять крипту в Telegram-боте или на сайте — за 10 минут. Без зависимостей, с проверкой подписи вебхука и всеми методами API.

Пакеты скоро в npm и PyPI. Пока — скачайте библиотеку одним файлом и положите рядом со своим кодом: зависимостей нет.

Установка

npm i oropay        # скоро в npm

Скачать одним файлом

ФайлЧто это
oropay.cjsNode.js 18+, CommonJS: const { OroPay } = require("./oropay.cjs")
oropay.mjsNode.js 18+, ES-модуль: import { OroPay } from "./oropay.mjs"
oropay.pyPython 3.8+, только стандартная библиотека: положите рядом с кодом и import oropay
examples.zipПримеры: бот на aiogram 3 и на grammY, сайт на Express, PHP. Библиотеки уже внутри

Что внутри: все методы API — счета, оплата на адрес, выплаты, возвраты, подписки; verifyWebhook / verify_webhook; ошибки с полями status и code. Ответы — ровно поля API, суммы строками. Поле qr у оплаты на адрес библиотека отдаёт полным адресом.

Бот за 10 минут

  1. Ключи. В боте OroPay: «Магазин» → создать приложение → ключ API (cxc_…) и секрет вебхука, оба показываются один раз. Токен бота — у @BotFather.
  2. Пример. Скачайте examples.zip, откройте aiogram3-shop (Python) или grammy-shop (Node.js), скопируйте .env.example в .env и впишите токен, ключ и секрет.
  3. Запуск. pip install -r requirements.txt и python bot.py — или npm install и npm start.
  4. Проверка. В боте /buy → «Оплатить (тест)» → на странице оплаты «Оплатить (тест)» → «Я оплатил» → бот присылает товар. Деньги не списываются — это тестовый режим.
  5. Вебхук и бой. Поднимите туннель (cloudflared tunnel --url http://localhost:8080) и впишите его адрес в PUBLIC_URL — товар будет приходить сам. Затем OROPAY_TEST_MODE=0 — и бот принимает настоящие платежи.

Суть бота — сокращённо (полная версия с кнопкой «Я оплатил» и защитой от двойной выдачи — в архиве):

import asyncio, os, re, time
from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton as Btn, InlineKeyboardMarkup
from aiohttp import web
from oropay import OroPay, verify_webhook, WebhookVerificationError

oropay = OroPay(api_key=os.environ["OROPAY_API_KEY"])
bot, dp = Bot(os.environ["BOT_TOKEN"]), Dispatcher()
TEST = os.getenv("OROPAY_TEST_MODE", "1") != "0"  # 1 — тестовые счета, без денег

@dp.message(Command("buy"))
async def buy(message):
    invoice = await asyncio.to_thread(  # клиент синхронный — в отдельном потоке
        oropay.create_invoice, "5.00", currency="USD",
        order_id=f"tg{message.chat.id}-{int(time.time() * 1000)}",  # покупатель — в orderId
        description="Гайд", test=TEST,
        callback_url=os.environ["PUBLIC_URL"] + "/oropay/webhook",
    )
    rows = [] if TEST else [[Btn(text="Оплатить в Telegram", url=invoice["payUrl"])]]
    rows.append([Btn(text="Любой кошелёк", url=invoice["checkoutUrl"])])  # тест — только здесь
    await message.answer(f"К оплате {invoice['charged']} {invoice['currency']}",
                         reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))

async def webhook(request):
    try:
        event = verify_webhook(await request.read(), request.headers, os.environ["OROPAY_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return web.Response(status=401)
    inv = event.get("invoice") or {}
    if event["event"] == "invoice.paid" and (TEST or not inv["test"]):
        chat_id = int(re.match(r"tg(-?\d+)-", inv["orderId"]).group(1))
        await bot.send_message(chat_id, "Спасибо! Ваш товар: …")  # выдайте один раз — по inv["code"]
    return web.Response(text="ok")

async def main():
    app = web.Application()
    app.router.add_post("/oropay/webhook", webhook)
    runner = web.AppRunner(app)
    await runner.setup()
    await web.TCPSite(runner, "0.0.0.0", 8080).start()
    await dp.start_polling(bot)

asyncio.run(main())

Покупатель — в orderId. Поля для своих данных у счёта нет, поэтому chat_id кодируется в orderId (tg123456789-1760000000000); он же ключ идемпотентности, до 128 символов. Проверяйте test: в бою не выдавайте товар по тестовой оплате. Комиссию берите из счёта (feePercent, feeAmount): в стартовый период она "0".

Проверка подписи с библиотекой

verifyWebhook(rawBody, headers, secret) в Node.js и verify_webhook(raw_body, headers, secret) в Python делают всё из раздела Проверка подписи: HMAC-SHA256 от timestamp + "\n" + тело секретом вебхука, сравнение с постоянным временем, отказ при метке времени дальше 5 минут. Возвращают разобранное событие, при любой ошибке бросают WebhookVerificationError с полем reason. В PHP библиотека не нужна — хватает трёх строк.

import express from "express";
import { verifyWebhook } from "oropay";

const app = express();
// Тело — сырое: подпись считается по байтам, а не по разобранному JSON.
app.post("/oropay/webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.OROPAY_WEBHOOK_SECRET);
  } catch (e) {
    return res.sendStatus(401); // e.reason: INVALID_SIGNATURE, STALE_TIMESTAMP…
  }
  if (event.event === "invoice.paid" && !event.invoice.test) {
    // Выдать заказ event.invoice.orderId, если он ещё не выдан.
  }
  res.sendStatus(200);
});

Подпись не сходится? Почти всегда тело уже разобрано фреймворком: передавайте сырые байты (express.raw, await request.read(), php://input) и секрет вебхука, а не ключ API. Своё приложение можно проверить кнопкой «Отправить тестовый вебхук» в кабинете — придёт событие test.

Методы библиотек

Названия в Node.js — camelCase, в Python — snake_case; параметры тела библиотека сама отправляет в виде из этой документации (spendId → spend_id).

Метод APINode.jsPython
POST /invoicecreateInvoice(params)create_invoice(amount, …)
GET /invoice/{code}getInvoice(code)get_invoice(code)
POST /invoice/{code}/cancelcancelInvoice(code)cancel_invoice(code)
GET /invoiceslistInvoices({ limit, status, cursor })list_invoices(limit, status, cursor)
GET /networksgetNetworks()get_networks()
POST /invoice/{code}/addresscreateAddressPayment(code, network)create_address_payment(code, network)
GET /invoice/{code}/addresslistAddressPayments(code)list_address_payments(code)
GET /v1/balancegetBalance()get_balance()
POST /v1/chequescreateCheque({ spendId, asset, amount, … })create_cheque(spend_id, asset, amount, …)
GET /v1/chequeslistCheques({ status, count, offset })list_cheques(status, count, offset)
DELETE /v1/cheques/{id}cancelCheque(idOrCode)cancel_cheque(id_or_code)
POST /v1/transferstransfer({ spendId, userId, asset, amount })transfer(spend_id, user_id, asset, amount)
POST /v1/invoices/{code}/refundrefund(code, { refundId, amount, reason })refund(code, refund_id, amount, reason)
GET /v1/invoices/{code}/refundslistRefunds(code)list_refunds(code)
GET /v1/planslistPlans()list_plans()
POST /v1/planscreatePlan({ name, asset, price, periodDays, … })create_plan(name, asset, price, period_days, …)
POST /v1/plans/{code}/disabledisablePlan(code) · enablePlan(code)disable_plan(code) · enable_plan(code)
GET /v1/subscriptionslistSubscriptions({ userId, plan, status })list_subscriptions(user_id, plan, status)
GET /v1/subscriptions?user_id=hasAccess(userId, { plan })has_access(user_id, plan)
GET /v1/subscriptions/{id}getSubscription(id)get_subscription(id)
POST /v1/subscriptions/{id}/cancelcancelSubscription(id)cancel_subscription(id)
—verifyWebhook(rawBody, headers, secret, { toleranceSec })verify_webhook(raw_body, headers, secret, tolerance)

Неуспешный вызов бросает OroPayError: status — HTTP-статус (0 — нет ответа), code — код из раздела Коды ошибок (у старых методов счетов — пусто), message и data — всё тело ответа. Автоповторов нет: повторяйте запрос сами, с тем же orderId / spend_id / refund_id.

Счета

Счёт — это сумма в фиатной валюте (USD, EUR, RUB, UAH), которую покупатель оплачивает любой своей монетой.

  1. Выставьте счёт — POST /api/merchant/invoice. В ответе ссылка payUrl.
  2. Отправьте покупателя платить. С сайта — на checkoutUrl: наша страница с суммой, QR-кодом для телефона и кнопкой «Оплатить в Telegram»; после оплаты вернём покупателя на ваш returnUrl. Из бота или чата — кнопкой на payUrl, ссылку на бота вида https://t.me/OroPayBot?start=pay_PR…. Бот покажет счёт; покупатель выберет монету и заплатит со своего баланса OroPay. На странице оплаты можно и перевести на адрес с любого кошелька.
  3. Узнайте об оплате — вебхуком invoice.paid или запросом GET /api/merchant/invoice/{code}.
  4. Выдайте заказ. Деньги уже на балансе владельца магазина.

Комиссия и зачисление

Стартовое предложение: новым магазинам — 0% комиссии первые 30 дней с создания первого магазина, по промокоду (вводится в кабинете или командой /promo в боте) — 60 дней. В этот период в счетах feePercent = "0", feeAmount = "0", а charged равен amount при любом feeMode.

Комиссия — 0.75% от суммы заказа. Кто её платит, задаёт feeMode (по умолчанию — настройка приложения):

feeModeЗаказ (amount)Платит покупатель (charged)Комиссия (feeAmount)
SHOP1001000.75 — удерживается из оплаты
BUYER100100.750.75 — добавлена к счёту
  • Курс считается в момент оплаты: покупатель платит charged, пересчитанный в выбранную монету. Это количество — paidAmount в paidToken.
  • На баланс магазина зачисляется та же монета: paidAmount × (charged − feeAmount) / charged, с округлением вниз до точности монеты. Дальше курс монеты — ваш: мы её не конвертируем.
  • Суммы отдаются без округления до центов: комиссия с $5 — "0.0625". Минимальной комиссии нет.
  • Счёт живёт 1 час (expiresAt), после этого оплатить его нельзя. С баланса OroPay недоплаты и переплаты не бывает; при оплате на адрес — см. правило 99% там.

Объект счёта

ПолеОписание
codeКод счёта, PR + 10 символов. Его принимают все остальные методы.
statusPENDING — ждёт оплаты; PAID — оплачен; EXPIRED — час истёк; CANCELLED — отменён. Счёт, у которого истёк срок, показывается как EXPIRED сразу.
orderId, descriptionВаши номер заказа и описание, или null.
amountСумма заказа — от неё считается комиссия.
chargedСколько платит покупатель, в currency.
feeMode, feePercent, feeAmountРежим и размер комиссии.
paidToken, paidChain, paidAmountМонета, сеть и количество, которыми заплатили. До оплаты — null.
createdAt, expiresAt, paidAtВремя создания, истечения и оплаты.
payUrlСсылка на оплату в боте.
checkoutUrlСтраница оплаты для сайта: сумма, QR-код для телефона, кнопка «Оплатить в Telegram», статус и возврат на returnUrl после оплаты.
returnUrlАдрес возврата, заданный в этом счёте, или null (тогда действует адрес из настроек магазина).

Создать счёт

POST/api/merchant/invoiceправо invoices

Создаёт счёт и возвращает объект счёта со ссылкой payUrl. Повтор с тем же orderId и той же суммой возвращает тот же счёт.

Параметры тела

ПолеТипОбязательностьОписание
amountstringобязательноСумма заказа, больше нуля, например "100.00"
currencystringнеобязательноUSD, EUR, RUB или UAH. По умолчанию USD
orderIdstringнеобязательноВаш номер заказа — ключ идемпотентности, до 128 символов. Без него каждый вызов создаёт новый счёт
descriptionstringнеобязательноПокажется покупателю, до 512 символов (длиннее — обрезается)
feeModestringнеобязательноSHOP или BUYER. По умолчанию — настройка приложения
callbackUrlstringнеобязательноАдрес вебхука для этого счёта. По умолчанию — адрес из настроек приложения. Требования — в разделе Вебхуки
returnUrlstringнеобязательноКуда вернуть покупателя после оплаты: ваш сайт (https://…) или ваш бот (https://t.me/имя_бота?start=…). Покупатель увидит кнопку «Вернуться на ваш-сайт» / «Вернуться в @бот» в нашем боте и на странице оплаты; со страницы оплаты на сайт вернём сами через 5 секунд. По умолчанию — адрес из настроек магазина в кабинете; нет и его — кнопки нет. Только https://, до 512 символов
paymentMethodsarrayнеобязательноСпособы оплаты: ["telegram", "address"]. telegram — кошелёк OroPay в Telegram (наш бот), address — перевод на адрес с любого кошелька или биржи, без нашего бота. По умолчанию — оба. Только ["address"] — бот покупатель не увидит
testbooleanнеобязательноtrue — тестовый счёт для проверки интеграции: настоящими деньгами не оплачивается, на странице оплаты (checkoutUrl) — кнопка «Оплатить (тест)». См. Тестовый режим

Пример запроса

curl -X POST https://oropay.app/api/merchant/invoice \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":"100.00","currency":"USD","orderId":"order-1024","description":"Подписка Pro, 1 месяц","feeMode":"BUYER"}'

Ответ

ответ 200
{
  "ok": true,
  "invoice": {
    "code": "PRbtixbbdeoy",
    "status": "PENDING",
    "orderId": "order-1024",
    "description": "Подписка Pro, 1 месяц",
    "amount": "100.00",
    "charged": "100.75",
    "currency": "USD",
    "feeMode": "BUYER",
    "feePercent": "0.75",
    "feeAmount": "0.75",
    "paidToken": null,
    "paidChain": null,
    "paidAmount": null,
    "createdAt": "2026-09-25T12:00:00.000Z",
    "expiresAt": "2026-09-25T13:00:00.000Z",
    "paidAt": null,
    "payUrl": "https://t.me/OroPayBot?start=pay_PRbtixbbdeoy",
    "checkoutUrl": "https://oropay.app/pay/PRbtixbbdeoy",
    "returnUrl": "https://shop.example/order/1001"
  }
}

Ошибки

HTTPcodeКогда
400—Сумма должна быть больше нуля; Валюта RUR не поддерживается. Доступны: USD, EUR, RUB, UAH; ошибки адреса вебхука (Адрес вебхука должен начинаться с https://, Внутренний адрес запрещён и т. п.)
409—orderId уже использован с другой суммой или валютой
409—Счёт с этим orderId уже истёк или отменён. Выставьте счёт с новым orderId…
401—Ключ не передан, неизвестен, перевыпущен или приложение отключено. Тело: {"ok": false, "error": "неизвестный ключ"}
403SCOPE_REQUIREDУ ключа нет права invoices

Чтобы проверить интеграцию без настоящих денег, выставьте счёт с "test": true — тем же POST /api/merchant/invoice и тем же ключом. Тестовый счёт:

  • не оплачивается ни в Telegram, ни переводом на адрес — настоящие деньги по нему не движутся;
  • на странице оплаты (checkoutUrl) вместо способов оплаты — кнопка «Оплатить (тест)»: счёт становится PAID, а на ваш вебхук приходит обычное invoice.paid;
  • везде помечен полем "test": true — в ответах API и в теле вебхука (у настоящих счетов "test": false). Проверяйте это поле: не выдавайте товар по тестовой оплате;
  • у «оплаченного» тестового счёта нет монеты и суммы оплаты (paidToken, paidAmount — null), возврат по нему невозможен;
  • не входит в статистику и оборот кабинета;
  • живёт час и отменяется так же, как настоящий. orderId тестового и настоящего счёта не должны совпадать — повтор с другим режимом вернёт 409.

Оплата на адрес (без бота)

POST/api/merchant/invoice/{code}/addressправо invoices

Покупатель платит с любого кошелька или биржи — аккаунт OroPay ему не нужен, наш бот он не видит. Вы берёте адрес и точную сумму в выбранной сети и показываете их у себя (или отправляете покупателя на checkoutUrl — там этот способ уже есть). Когда перевод придёт, счёт закроется сам: статус PAID, вебхук invoice.paid, на баланс магазина — сумма счёта минус комиссия.

Недоплата и опоздание. Пришло не меньше 99% суммы счёта в срок (пока действует адрес и ещё 2 часа после) — счёт оплачен автоматически. Меньше 99%, позже этого окна, другая сеть или сумма без нашей метки — деньги не теряются: поступление ждёт ручной проверки, напишите в поддержку.

Параметры тела

ПолеТипОбязательностьОписание
networkstringобязательноСеть и монета из GET /api/merchant/networks, например TRON:USDT, BSC:USDT, TON:TON

Ответ — поле payment

ПолеЧто это
addressКуда отправить.
payAmount, coinСколько отправить — ровно эту сумму: по ней мы узнаём платёж. В неё входят сумма счёта, комиссия сети и уникальная метка.
amount, surchargeСумма счёта в монете и комиссия сети.
expiresAtДо какого времени ждём перевод: 25 минут, в LTC и BTC — час (поле ttlMin в GET /api/merchant/networks), но не позже срока самого счёта. Перевод, пришедший в течение ещё 2 часов, тоже закроет счёт.
qrКартинка QR (адрес, для TON и EVM — и сумма).
statuswaiting, expired, paid, cancelled.

Повтор с той же сетью, пока оплата ждёт, вернёт тот же адрес и сумму. Другая сеть — ещё одна оплата: какую оплатят, той счёт и закроется; второй перевод после оплаты уйдёт на ручную проверку и возврат. Недоплата в пределах допуска сети (комиссия биржи) засчитывается; больше — ручная проверка. GET /api/merchant/invoice/{code}/address — список оплат счёта. Сети: USDT (TRC-20, BEP-20, ERC-20, TON), USDC (BEP-20, ERC-20), TON, TRX, BNB, ETH, LTC, SOL и BTC (счёт от $70: комиссии сети BTC съели бы мелкий).

Получить счёт

GET/api/merchant/invoice/{code}право invoices

Текущее состояние счёта. Запасной вариант, если вебхук не настроен: опрашивайте счёт этим методом.

Параметры пути

ПолеТипОбязательностьОписание
codestringобязательноКод счёта из ответа на создание

Пример запроса

curl https://oropay.app/api/merchant/invoice/PRbtixbbdeoy \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200 (сокращён)
{
  "ok": true,
  "invoice": {
    "code": "PRbtixbbdeoy",
    "status": "PAID",
    "orderId": "order-1024",
    "amount": "100.00",
    "charged": "100.75",
    "currency": "USD",
    "paidToken": "USDT",
    "paidChain": "TRON",
    "paidAmount": "101.2601",
    "paidAt": "2026-09-25T12:07:41.512Z",
    "payUrl": "https://t.me/OroPayBot?start=pay_PRbtixbbdeoy",
    "checkoutUrl": "https://oropay.app/pay/PRbtixbbdeoy",
    "returnUrl": "https://shop.example/order/1001"
  }
}

Ошибки

HTTPcodeКогда
404—счёт не найден — такого нет или он принадлежит другому приложению
401—Ключ не передан, неизвестен, перевыпущен или приложение отключено. Тело: {"ok": false, "error": "неизвестный ключ"}
403SCOPE_REQUIREDУ ключа нет права invoices

Отменить счёт

POST/api/merchant/invoice/{code}/cancelправо invoices

Отменяет неоплаченный счёт — не нужно ждать час, пока он истечёт. Возвращает счёт со статусом CANCELLED. Повторная отмена отменённого — не ошибка. Оплаченный счёт не отменяется — для него есть возврат. Проверка статуса и запись идут одним действием в базе, поэтому оплата и отмена не могут пройти одновременно.

Параметры пути

ПолеТипОбязательностьОписание
codestringобязательноКод счёта

Пример запроса

curl -X POST https://oropay.app/api/merchant/invoice/PRbtixbbdeoy/cancel \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "invoice": { "code": "PRbtixbbdeoy", "status": "CANCELLED", "…": "…" }
}

Ошибки

HTTPcodeКогда
400—Счёт в статусе PAID, отменить нельзя (или EXPIRED)
404—счёт не найден
401—Ключ не передан, неизвестен, перевыпущен или приложение отключено. Тело: {"ok": false, "error": "неизвестный ключ"}
403SCOPE_REQUIREDУ ключа нет права invoices

Список счетов

GET/api/merchant/invoicesправо invoices

Счета приложения, новые сверху. Страницы листаются курсором: передайте nextCursor в cursor; null — страниц больше нет. Курсор, а не смещение: новые счета сверху не сдвигают страницы.

Параметры запроса

ПолеТипОбязательностьОписание
limitnumberнеобязательноОт 1 до 100, по умолчанию 20
statusstringнеобязательноPENDING, PAID, EXPIRED или CANCELLED. Фильтр по значению в базе: счёт с истёкшим сроком может числиться PENDING, пока к нему никто не обращался
cursorstringнеобязательноcode последнего счёта предыдущей страницы

Пример запроса

curl "https://oropay.app/api/merchant/invoices?status=PAID&limit=50" \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "invoices": [
    { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
  ],
  "nextCursor": "PRhmfmqcwn2b"
}

Ошибки

HTTPcodeКогда
400—Неизвестный статус X. Доступны: PENDING, PAID, EXPIRED, CANCELLED; Курсор не найден
401—Ключ не передан, неизвестен, перевыпущен или приложение отключено. Тело: {"ok": false, "error": "неизвестный ключ"}
403SCOPE_REQUIREDУ ключа нет права invoices

Вебхуки

Мы отправляем POST с JSON на ваш адрес. Вебхук не обязателен: без адреса уведомлений просто не будет, и статус можно спрашивать запросом.

СобытиеКогдаКуда
invoice.paidСчёт оплаченcallbackUrl счёта; если не задан при создании — адрес приложения на тот момент
invoice.refundedСделан возврат по счётуcallbackUrl счёта, иначе адрес приложения
subscription.*События подписок — списокАдрес из настроек приложения на момент события
testВладелец нажал «Отправить тестовый вебхук» в кабинете — подробнееАдрес из настроек приложения

Требования к адресу

  • Только https://, до 512 символов, имя хоста, а не IPv6-адрес.
  • Запрещены localhost, *.local, *.internal и внутренние диапазоны (10.x, 127.x, 169.254.x, 172.16–31.x, 192.168.x, 100.64/10 и т. п.). Перед каждой отправкой имя разрешается в DNS, и если оно указывает на внутренний адрес, уведомление не отправляется.
  • Перенаправления не выполняются: ответ 3xx считается неудачей.

Заголовки

ЗаголовокЗначение
x-cxc-timestampВремя отправки в миллисекундах Unix. Новое у каждой попытки
x-cxc-signatureHMAC-SHA256, hex — см. ниже
x-cxc-invoiceКод счёта — у invoice.paid и invoice.refunded
x-cxc-refundВаш refund_id — у invoice.refunded
x-cxc-event, x-cxc-event-id, x-cxc-subscriptionИмя события, его id (evt_…) и id подписки — у событий подписок
x-cxc-event: test, x-cxc-test: 1Только у тестового события — см. ниже

Тело invoice.paid

тело
{
  "event": "invoice.paid",
  "invoice": {
    "code": "PRbtixbbdeoy",
    "status": "PAID",
    "orderId": "order-1024",
    "description": "Подписка Pro, 1 месяц",
    "amount": "100.00",
    "charged": "100.75",
    "currency": "USD",
    "feeMode": "BUYER",
    "feePercent": "0.75",
    "feeAmount": "0.75",
    "paidToken": "USDT",
    "paidChain": "TRON",
    "paidAmount": "101.2601",
    "createdAt": "2026-09-25T12:00:00.000Z",
    "expiresAt": "2026-09-25T13:00:00.000Z",
    "paidAt": "2026-09-25T12:07:41.512Z",
    "payUrl": "https://t.me/OroPayBot?start=pay_PRbtixbbdeoy",
    "checkoutUrl": "https://oropay.app/pay/PRbtixbbdeoy",
    "returnUrl": "https://shop.example/order/1001"
  }
}

Тело invoice.refunded

тело
{
  "event": "invoice.refunded",
  "refund": {
    "id": "311",
    "refundId": "order-1024-r1",
    "invoiceCode": "PRbtixbbdeoy",
    "asset": "USDT",
    "network": "TRON",
    "amount": "40",
    "reason": "Товар закончился",
    "createdAt": "2026-09-25T14:00:00.000Z"
  },
  "refundedTotal": "40",
  "refundable": "61.2601",
  "invoice": { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
}

Проверка подписи

Проверяйте подпись всегда. Без проверки кто угодно пришлёт вам «оплачено» и получит товар даром.

Подпись — HMAC-SHA256 в hex от строки timestamp + "\n" + тело, где timestamp — значение x-cxc-timestamp, а тело — сырые байты запроса как пришли. Ключ HMAC — секрет вебхука приложения (не ключ API). Способ одинаковый для всех событий.

  1. Прочитайте тело сырыми байтами — до разбора JSON. Разобранный и собранный заново JSON даст другую подпись.
  2. Посчитайте HMAC и сравните с x-cxc-signature функцией с постоянным временем.
  3. Проверьте время. Отвергайте уведомление, если x-cxc-timestamp отличается от ваших часов больше чем на 5 минут (в любую сторону). Подпись доказывает, что уведомление наше, но не что оно свежее: без проверки времени перехваченный запрос можно прислать вам повторно через день. Метка ставится заново у каждой попытки доставки, поэтому повторы после сбоя проверку проходят. Держите часы сервера синхронизированными (NTP).
  4. Не сошлось — ответьте ошибкой и ничего не делайте. Сошлось — обработайте и ответьте 2xx.
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.OROPAY_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000; // старше 5 минут — отвергаем

// Тело нужно сырым: подпись считается по байтам, а не по разобранному JSON.
app.post("/oropay/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  const ts = req.get("x-cxc-timestamp") || "";
  // Время: метка — миллисекунды Unix. Нет метки или она дальше 5 минут — отказ.
  if (!/^\d{10,16}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > MAX_AGE_MS) {
    return res.sendStatus(401);
  }
  const expected = crypto.createHmac("sha256", SECRET).update(`${ts}\n${body}`).digest();
  const got = Buffer.from(req.get("x-cxc-signature") || "", "hex");
  if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(body);
  if (event.event === "invoice.paid") {
    // Выдать заказ event.invoice.orderId, если он ещё не выдан.
  }
  res.sendStatus(200);
});

Доставка и повторы

  • Первая попытка — сразу после события. Успех — только ответ 2xx за 15 секунд. Тело вашего ответа не читается.
  • Неудача (другой код, таймаут, обрыв, 3xx) — повтор с нарастающей паузой: 1, 2, 4, 8, 16, 32 минуты, дальше раз в час. Всего до 12 попыток — примерно 6 часов. Проверка очереди идёт раз в минуту.
  • Каждая попытка приходит с новым x-cxc-timestamp и новой подписью.
  • Вебхуки о подписках и возвратах доставляются по тем же правилам.

Одно событие может прийти дважды — например, если ваш ответ потерялся. Обрабатывайте идемпотентно: для invoice.paid — по code счёта, для invoice.refunded — по refundId, для подписок — по id события (evt_…). Уже обработано — просто ответьте 200.

Тестовый вебхук

В кабинете магазина (вкладка «Ключи», рядом с адресом вебхука) есть кнопка «Отправить тестовый вебхук». Мы отправляем на адрес из настроек приложения событие test — тем же путём, что и настоящие: та же подпись x-cxc-signature секретом вебхука, та же проверка адреса (внутренние адреса запрещены), без перенаправлений, таймаут 15 секунд. В кабинете сразу видно, что ответил ваш сервер: код, время, первые 500 символов ответа или ошибку.

тело
{
  "event": "test",
  "test": true,
  "id": "test_9a8b7c6d5e4f",
  "app": { "id": "17", "name": "Кофейня на Садовой" },
  "createdAt": "2026-09-26T09:50:00.000Z",
  "message": "Тестовое событие из кабинета. Проверьте подпись и ответьте 2xx; денег за ним нет."
}

Как отличить тест от настоящего события

  • В теле "event": "test" и "test": true; в заголовках — x-cxc-event: test и x-cxc-test: 1.
  • Нет ни счёта, ни заголовка x-cxc-invoice: за тестом не стоит никаких денег. Ничего не выдавайте — просто проверьте подпись и ответьте 2xx.
  • Тест не повторяется при неудаче и не попадает в очередь повторов.
  • Не чаще 10 тестов в минуту на владельца (по всем его магазинам).

История доставки настоящих вебхуков — время, код ответа, номер попытки — видна в кабинете в карточке счёта (вкладка «Счета»).

Выплаты

С баланса магазина можно платить людям: чеками (ссылка, по которой деньги забирает любой пользователь Telegram) и переводами по Telegram ID. Это пригодится, например, боту, который платит за выполненные задания. Баланс магазина — это баланс владельца, тот же, куда приходят оплаты счетов.

Выплаты выключены по умолчанию — и это не случайно

По ключу деньги уходят без PIN-кода: ключ сам служит подтверждением. Если ключ с правом payouts утечёт, тот, кто его получил, сможет отправлять ваши деньги — например, создавать чеки, которые активирует кто угодно. Отменить забранный чек или перевод нельзя.

Единственная граница ущерба — ваши лимиты. За сутки утечка обойдётся не дороже суточного лимита.

  • Держите лимиты минимальными — под реальный дневной оборот выплат, и поднимайте, когда понадобится.
  • Заведите для бота выплат отдельное приложение с ключом только payouts; счета выставляйте другим ключом.
  • Храните ключ только на сервере, не в логах и не в репозитории.
  • Подозреваете утечку — выключите «Выплаты по API» (без PIN-кода, мгновенно) и перевыпустите ключ.

Как включить

  1. Установите PIN-код в настройках OroPay — без него выплаты не включаются (ошибка PIN_NOT_SET).
  2. В мини-приложении откройте «Магазин» → приложение → «Выплаты и возвраты».
  3. Включите «Выплаты по API» и подтвердите PIN-кодом. Ключу автоматически добавится право payouts.
  4. Задайте суточный лимит и лимит одной выплаты.
Действие в кабинетеPIN-код
Включить выплаты, добавить ключу право, поднять любой лимитНужен
Перевыпустить ключ или секрет, сменить адрес вебхукаНужен. После перевыпуска ключа выплаты выключаются — включите снова с PIN
Выключить выплаты, убрать право, снизить лимитНе нужен — это тормоз, он срабатывает сразу

Выплата по API проходит, только если одновременно: у ключа есть право payouts, выключатель «Выплаты по API» включён, средства владельца не заморожены и не идёт восстановление PIN-кода. Иначе — 403 PAYOUTS_DISABLED или 403 OWNER_FROZEN. Возвраты по счетам тоже требуют включённого выключателя «Выплаты по API» (право payouts для них не нужно) — иначе 403 REFUNDS_DISABLED.

Лимиты

ЛимитПо умолчаниюЧто считается
Суточный$500Всё, что ушло по API за скользящие 24 часа со всех приложений владельца (деньги — с одного баланса, значит и предел один): чеки (вся сумма — amount × activations), переводы и возвраты по счетам. Превышение — 403 DAILY_LIMIT_EXCEEDED с полями limitUsd, spentUsd, leftUsd.
Одна выплата$100Сумма перевода или одной активации чека. Не больше суточного. На возвраты не действует. Превышение — 403 AMOUNT_OVER_LIMIT с limitUsd, amountUsd.
Потолок сервиса$25 000 в суткиНа владельца, по всем его приложениям. Выше него лимит не поднимется. Лимит одной выплаты — тоже не выше потолка.
  • Доллары считаются по курсу монеты в момент выплаты. Нет курса — 503 RATE_UNAVAILABLE, выплата не делается.
  • Лимиты проверяются под замком владельца внутри той же транзакции, что и списание: одновременные запросы не проскочат лимит вместе.
  • Отмена чека возвращает остаток на баланс, но суточный лимит не восстанавливает.
  • Текущие значения и остаток на сегодня — в балансе.

Баланс

GET/api/merchant/v1/balance

Остатки владельца магазина и состояние выплат. Доступно любому действующему ключу. Показываются монеты с ненулевым остатком; available обрезан вниз до точности монеты — его можно целиком передать суммой выплаты. У USDT один общий остаток на все сети (network: "TRON").

Пример запроса

curl https://oropay.app/api/merchant/v1/balance \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "balances": [
    { "asset": "USDT", "network": "TRON", "available": "152.4" },
    { "asset": "GRAM", "network": "TON", "available": "20" }
  ],
  "payouts": {
    "enabled": true,
    "scopes": ["invoices", "payouts"],
    "dailyLimitUsd": 500,
    "dailySpentUsd": 37.5,
    "dailyLeftUsd": 462.5,
    "maxPayoutUsd": 100
  }
}

enabled — выплаты действительно работают (выключатель включён и есть право payouts). dailyLimitUsd и maxPayoutUsd — действующие значения с учётом потолка сервиса.

Ошибки

HTTPcodeКогда
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение

Создать чек

POST/api/merchant/v1/chequesправо payouts

Чек — ссылка link, по которой человек забирает деньги в боте. Подходит любому, даже тому, кто ни разу не открывал OroPay. Вся сумма (amount × activations) списывается с баланса сразу; неиспользованное возвращается при отмене или по сроку. Мульти-чек (activations > 1) — одна ссылка на несколько человек, каждый получает amount.

Параметры тела

ПолеТипОбязательностьОписание
spend_idstringобязательноВаш уникальный номер операции — см. Идемпотентность
assetstringобязательноМонета: USDT, USDC, TRX, GRAM (или TON), BTC, ETH, LTC, BNB, SOL, XRP
amountstringобязательноСумма одной активации, больше нуля, не точнее монеты — см. Монеты
activationsnumberнеобязательноСколько человек заберут, от 1 до 1000. По умолчанию 1
networkstringнеобязательноСеть, если монета лежит у вас в нескольких сетях. Для USDT не нужна
descriptionstringнеобязательноПодпись для получателя, до 200 символов
expires_in_daysnumberнеобязательно1, 7 или 30. После срока остаток возвращается сам. Не указан или 0 — бессрочный
passwordstringнеобязательноПароль для активации, до 64 символов. Хранится только его хеш
new_users_onlybooleanнеобязательноtrue — забрать могут только те, кто зарегистрировался в OroPay после создания чека
channelstringнеобязательно@username публичного канала или группы — забрать может только подписчик. Наш бот должен быть там администратором
captchabooleanнеобязательноtrue — капча при активации. Действует только у мульти-чека

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/cheques \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spend_id":"task-8812","asset":"USDT","amount":"0.5","activations":20,"description":"За подписку на канал","expires_in_days":7}'

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "cheque": {
    "id": "48213",
    "code": "CQk2m9x0a1b3c",
    "spendId": "task-8812",
    "asset": "USDT",
    "network": "TRON",
    "amount": "0.5",
    "activations": 20,
    "claimed": 0,
    "total": "10",
    "returned": "0",
    "status": "ACTIVE",
    "link": "https://t.me/OroPayBot?start=CQk2m9x0a1b3c",
    "description": "За подписку на канал",
    "hasPassword": false,
    "newUsersOnly": false,
    "channel": null,
    "expiresAt": "2026-10-02T12:00:00.000Z",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}
statusЗначение
ACTIVEЖдёт активаций
CLAIMEDВсе активации забраны
CANCELLEDОтменён, остаток вернулся (returned)
EXPIREDИстёк срок

Ошибки

HTTPcodeКогда
400SPEND_ID_REQUIRED, INVALID_SPEND_IDНет spend_id или он не 1–64 печатных ASCII без пробелов
400INVALID_ASSET, INVALID_AMOUNT, TOO_PRECISEМонета не поддерживается; сумма не положительная; знаков больше, чем у монеты
400INVALID_ACTIVATIONSactivations — не целое от 1 до 1000
400INVALID_NETWORK, NETWORK_REQUIREDСеть неизвестна; монета лежит в нескольких сетях — укажите network
400DESCRIPTION_TOO_LONGОписание длиннее 200 символов
400CHEQUE_PASSWORD_LONG, CHEQUE_CHANNEL_BAD, CHEQUE_CHANNEL_NO_ADMIN, CHEQUE_EXPIRY_BAD, CHEQUE_ACTIVATIONSОшибка условий чека: пароль длиннее 64; канал не распознан; бот не администратор канала; срок не 1/7/30
400INSUFFICIENT_FUNDSНе хватает денег на балансе
403PAYOUTS_DISABLEDВыплаты по API выключены
403AMOUNT_OVER_LIMIT, DAILY_LIMIT_EXCEEDEDПревышен лимит одной выплаты или суточный
403OWNER_FROZENСредства владельца заморожены или идёт восстановление PIN-кода
403SCOPE_REQUIREDУ ключа нет права payouts
409SPEND_ID_CONFLICTЭтот spend_id уже использован для другой выплаты
503RATE_UNAVAILABLEНет курса монеты — повторите позже с тем же spend_id
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение
500INTERNAL_ERRORСбой на нашей стороне; денежная транзакция откатилась — повторите с тем же ключом операции

Список чеков

GET/api/merchant/v1/chequesправо payouts

Только чеки, созданные этим приложением по API, новые сверху.

Параметры запроса

ПолеТипОбязательностьОписание
statusstringнеобязательноACTIVE, CLAIMED, CANCELLED или EXPIRED
countnumberнеобязательноОт 1 до 100, по умолчанию 20. Можно limit
offsetnumberнеобязательноСдвиг, по умолчанию 0

Пример запроса

curl "https://oropay.app/api/merchant/v1/cheques?status=ACTIVE&count=20" \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "cheques": [
    { "id": "48213", "code": "CQk2m9x0a1b3c", "status": "ACTIVE", "…": "…" }
  ]
}

Ошибки

HTTPcodeКогда
400INVALID_STATUSНеизвестный статус
403SCOPE_REQUIREDНет права payouts
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение

Отменить чек

DELETE/api/merchant/v1/cheques/{id}право payouts

Отменяет чек и возвращает на баланс незабранные активации (поле returned); уже забранные не возвращаются. Вместо id можно передать code чека (CQ…). Повторная отмена отменённого или истёкшего чека — не ошибка: придёт тот же чек и "repeated": true. Выключатель выплат для отмены не проверяется.

Параметры пути

ПолеТипОбязательностьОписание
idstringобязательноid или code чека

Пример запроса

curl -X DELETE https://oropay.app/api/merchant/v1/cheques/48213 \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "cheque": { "id": "48213", "status": "CANCELLED", "claimed": 3, "returned": "8.5", "…": "…" }
}

Ошибки

HTTPcodeКогда
404CHEQUE_NOT_FOUNDНет такого чека у этого приложения
409CHEQUE_ALREADY_CLAIMEDЧек забран полностью — возвращать нечего
403SCOPE_REQUIREDНет права payouts
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение

Перевод пользователю

POST/api/merchant/v1/transfersправо payouts

Мгновенный внутренний перевод на баланс пользователя OroPay по его Telegram ID — в блокчейн не уходит. Получатель увидит сообщение «💸 Выплата от название приложения: сумма». Если в сервисе включена комиссия за переводы, она списывается с баланса магазина сверх amount.

Параметры тела

ПолеТипОбязательностьОписание
spend_idstringобязательноВаш уникальный номер операции
user_idstring | numberобязательноTelegram ID получателя, 4–15 цифр
assetstringобязательноМонета, как у чеков
amountstringобязательноСумма, больше нуля, не точнее монеты
commentstringнеобязательноПокажется получателю, до 200 символов
networkstringнеобязательноСеть, если монета лежит у вас в нескольких сетях

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/transfers \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spend_id":"task-8812-551","user_id":123456789,"asset":"USDT","amount":"1.25","comment":"Задание №8812"}'

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "transfer": {
    "id": "9021",
    "spendId": "task-8812-551",
    "userId": "123456789",
    "asset": "USDT",
    "network": "TRON",
    "amount": "1.25",
    "comment": "Задание №8812",
    "status": "COMPLETED",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}

Перевести можно только тому, кто уже запускал бота OroPay. Иначе — 404 USER_NOT_FOUND: создайте чек и отправьте человеку ссылку — чек активирует и новичок.

Ошибки

HTTPcodeКогда
400INVALID_USER_IDuser_id — не 4–15 цифр
404USER_NOT_FOUNDЧеловек не запускал бота — отправьте ему чек
400CANNOT_PAY_SELFПолучатель — сам владелец магазина
400COMMENT_TOO_LONGКомментарий длиннее 200 символов
400SPEND_ID_REQUIRED, INVALID_SPEND_ID, INVALID_ASSET, INVALID_AMOUNT, TOO_PRECISE, INVALID_NETWORK, NETWORK_REQUIRED, INSUFFICIENT_FUNDSКак у чеков
400TRANSFER_FAILEDПеревод не выполнен по другой причине — поле reason
403PAYOUTS_DISABLED, AMOUNT_OVER_LIMIT, DAILY_LIMIT_EXCEEDED, OWNER_FROZEN, SCOPE_REQUIREDКак у чеков
409SPEND_ID_CONFLICTspend_id уже использован с другими данными
503RATE_UNAVAILABLEНет курса монеты
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение
500INTERNAL_ERRORСбой на нашей стороне; денежная транзакция откатилась — повторите с тем же ключом операции

Возврат по счёту

POST/api/merchant/v1/invoices/{code}/refundправо invoices

Возвращает оплаченный счёт покупателю — целиком или частями. Деньги списываются с баланса магазина и зачисляются плательщику в той же монете и сети, которыми он платил (paidToken, paidChain). Нужно право invoices и включённые «Выплаты по API» (включаются с PIN-кодом): возврат — это деньги, уходящие с вашего баланса по ключу, и ключ «только для счетов» отдавать их не должен. Право payouts не нужно. Возвраты входят в суточный лимит выплат; лимит одной выплаты на них не действует.

Параметры пути

ПолеТипОбязательностьОписание
codestringобязательноКод счёта

Параметры тела

ПолеТипОбязательностьОписание
refund_idstringобязательноВаш уникальный номер возврата — повторный запрос не вернёт деньги дважды
amountstringнеобязательноСколько вернуть в монете оплаты. Не указано — весь остаток
reasonstringнеобязательноПричина — покажется покупателю, до 200 символов (длиннее — обрезается). Можно comment

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/invoices/PRbtixbbdeoy/refund \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"refund_id":"order-1024-r1","amount":"40","reason":"Товар закончился"}'

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "refund": {
    "id": "311",
    "refundId": "order-1024-r1",
    "invoiceCode": "PRbtixbbdeoy",
    "asset": "USDT",
    "network": "TRON",
    "amount": "40",
    "reason": "Товар закончился",
    "createdAt": "2026-09-25T14:00:00.000Z"
  },
  "refundedTotal": "40",
  "refundable": "61.2601",
  "invoice": { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
}
  • Вернуть можно не больше paidAmount минус уже возвращённое. Два одновременных частичных возврата не превысят оплату — сумма считается под замком.
  • Наша комиссия с оплаты не возвращается: полный возврат спишет всю сумму, заплаченную покупателем, — чуть больше, чем вы получили.
  • Счёт остаётся в статусе PAID; сколько возвращено — refundedTotal, сколько ещё можно — refundable.
  • Покупатель получает сообщение в Telegram, на ваш адрес уходит вебхук invoice.refunded.

Ошибки

HTTPcodeКогда
400REFUND_ID_REQUIRED, INVALID_REFUND_IDНет refund_id или он неверного вида
400INVALID_AMOUNT, TOO_PRECISEСумма не положительная или точнее монеты
400REFUND_EXCEEDS_PAIDБольше доступного; поле available
400INSUFFICIENT_FUNDSНе хватает денег на балансе магазина
404INVOICE_NOT_FOUNDНет такого счёта у этого приложения
409INVOICE_NOT_PAIDСчёт не оплачен — возвращать нечего
409ALREADY_REFUNDEDСчёт уже возвращён полностью; поле refunded
409REFUND_ID_CONFLICTrefund_id уже использован для другого возврата
403REFUNDS_DISABLED«Выплаты по API» выключены — включите в кабинете с PIN-кодом
403DAILY_LIMIT_EXCEEDED, OWNER_FROZEN, SCOPE_REQUIREDСуточный лимит, заморозка владельца, нет права invoices
503RATE_UNAVAILABLEНет курса монеты — повторите с тем же refund_id
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение
500INTERNAL_ERRORСбой на нашей стороне; денежная транзакция откатилась — повторите с тем же ключом операции

Возвраты по счёту

GET/api/merchant/v1/invoices/{code}/refundsправо invoices

Все возвраты по счёту по порядку, сколько возвращено и сколько ещё можно вернуть.

Параметры пути

ПолеТипОбязательностьОписание
codestringобязательноКод счёта

Пример запроса

curl https://oropay.app/api/merchant/v1/invoices/PRbtixbbdeoy/refunds \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "refunds": [
    { "id": "311", "refundId": "order-1024-r1", "amount": "40", "…": "…" }
  ],
  "refundedTotal": "40",
  "refundable": "61.2601",
  "invoice": { "code": "PRbtixbbdeoy", "…": "…" }
}

Ошибки

HTTPcodeКогда
404INVOICE_NOT_FOUNDНет такого счёта у этого приложения
403SCOPE_REQUIREDНет права invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение

Подписки

Регулярные платежи. Вы заводите тариф, человек оформляет подписку по ссылке тарифа https://t.me/OroPayBot?start=SB… или в мини-приложении и подтверждает её PIN-кодом, если он у него заведён. Это его согласие на автосписания с баланса OroPay.

  • Первое списание — сразу при оформлении; если в тарифе есть пробный период — в день его окончания. Пробный даётся один раз: только тому, у кого ещё не было подписок в вашем магазине.
  • Дальше мы списываем цену в дату продления (nextChargeAt); за сутки до этого человек получает напоминание. Продления проверяются раз в 10 минут.
  • Не хватило денег или средства заморожены — статус past_due, доступа нет, повтор через 24 часа. После третьей неудачной попытки — expired. Если приложение магазина выключено, подписка при продлении тоже становится expired.
  • Отмена (человеком или вами) — дальше не списываем, доступ остаётся до конца оплаченного периода. Деньги за текущий период автоматически не возвращаются.
  • Вы получаете цену за вычетом той же комиссии, что у счетов (0.75%), на баланс владельца магазина.
  • За один период списание бывает ровно одно — за этим следит уникальный индекс в базе.

Условия тарифа: монета — только USDT или USDC; период — 7, 30, 90 или 365 дней; пробный — 0, 3 или 7 дней; цена — от 0.01 до 100000. Цена и период после создания не меняются: новая цена — новый тариф. До 50 тарифов на приложение, выключенные тоже считаются. Нужно право ключа subscriptions или invoices. Частота этих методов ограничена только общим счётчиком — 120 запросов в минуту с адреса.

statusДоступ (active)Значение
trialingдаПробный период; первое списание — в nextChargeAt
activeдаОплачено до currentPeriodEnd
past_dueнетСписание не прошло; следующая попытка — nextChargeAt, сколько было — failedAttempts
cancelledдо currentPeriodEndОтменена (cancelledBy: user или merchant), больше не списывается
expiredнетЗавершена: три неудачных списания или магазин выключен

Создать тариф

POST/api/merchant/v1/plansправо subscriptions

Создаёт тариф и возвращает ссылку link — отдайте её человеку кнопкой в боте или на сайте. Тарифы можно заводить и без API — в кабинете: «Магазин» → приложение → «Подписки и тарифы».

Параметры тела

ПолеТипОбязательностьОписание
namestringобязательноНазвание, до 64 символов — его увидит подписчик
assetstringобязательноUSDT или USDC
pricestringобязательноЦена за период, от "0.01" до "100000", не больше 4 знаков после точки
period_daysnumberобязательно7, 30, 90 или 365
trial_daysnumberнеобязательно0 (по умолчанию), 3 или 7
descriptionstringнеобязательноЧто даёт подписка, до 256 символов
plan_idstringнеобязательноВаш номер тарифа — ключ идемпотентности

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/plans \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"VIP-канал","asset":"USDT","price":"9.99","period_days":30,"trial_days":3,"description":"Доступ в закрытый канал","plan_id":"vip-monthly"}'

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "plan": {
    "code": "SBk3m9x2pq7a",
    "planId": "vip-monthly",
    "name": "VIP-канал",
    "description": "Доступ в закрытый канал",
    "asset": "USDT",
    "price": "9.99",
    "periodDays": 30,
    "trialDays": 3,
    "active": true,
    "link": "https://t.me/OroPayBot?start=SBk3m9x2pq7a",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}

Ошибки

HTTPcodeКогда
400INVALID_NAME, INVALID_ASSET, INVALID_PRICE, TOO_PRECISE, INVALID_PERIOD, INVALID_TRIAL, INVALID_PLAN_IDОшибка в параметрах тарифа
400TOO_MANY_PLANSУже 50 тарифов
409PLAN_ID_CONFLICTplan_id занят тарифом с другими условиями
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено
500INTERNAL_ERRORСбой на нашей стороне

Список тарифов

GET/api/merchant/v1/plansправо subscriptions

Все тарифы приложения, новые сверху, включая выключенные.

Пример запроса

curl https://oropay.app/api/merchant/v1/plans \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "plans": [
    { "code": "SBk3m9x2pq7a", "name": "VIP-канал", "active": true, "…": "…" }
  ]
}

Ошибки

HTTPcodeКогда
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено

Выключить или включить тариф

POST/api/merchant/v1/plans/{code}/disableправо subscriptions

Выключенный тариф не принимает новых подписчиков, а уже оформленные подписки продолжают продлеваться. Чтобы прекратить конкретную подписку, отмените её. Включить обратно — POST /api/merchant/v1/plans/{code}/enable. Возвращает тариф.

Параметры пути

ПолеТипОбязательностьОписание
codestringобязательноКод тарифа SB…

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/plans/SBk3m9x2pq7a/disable \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "plan": { "code": "SBk3m9x2pq7a", "active": false, "…": "…" }
}

Ошибки

HTTPcodeКогда
404PLAN_NOT_FOUNDНет такого тарифа у этого приложения
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено

Подписки и проверка доступа

GET/api/merchant/v1/subscriptionsправо subscriptions

Главный метод для бота магазина: перед тем как пустить человека в платный раздел, спросите, есть ли у него оплаченный доступ. С user_id верхнее поле active говорит, есть ли у человека сейчас доступ хоть по одной подписке вашего магазина. Без user_id — все подписки приложения постранично, новые сверху.

Параметры запроса

ПолеТипОбязательностьОписание
user_idstringнеобязательноTelegram ID человека, 4–15 цифр
planstringнеобязательноКод тарифа SB…. Можно plan_code
statusstringнеобязательноtrialing, active, past_due, cancelled или expired
limitnumberнеобязательноОт 1 до 100, по умолчанию 50. Можно count
offsetnumberнеобязательноСдвиг, по умолчанию 0

Пример запроса

curl "https://oropay.app/api/merchant/v1/subscriptions?user_id=123456789" \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "active": true,
  "subscriptions": [
    {
      "id": "5021",
      "status": "active",
      "active": true,
      "userId": "123456789",
      "plan": { "code": "SBk3m9x2pq7a", "name": "VIP-канал" },
      "asset": "USDT",
      "price": "9.99",
      "periodDays": 30,
      "trialEndsAt": "2026-09-28T12:00:00.000Z",
      "currentPeriodStart": "2026-09-28T12:00:00.000Z",
      "currentPeriodEnd": "2026-10-28T12:00:00.000Z",
      "nextChargeAt": "2026-10-28T12:00:00.000Z",
      "failedAttempts": 0,
      "cancelledAt": null,
      "cancelledBy": null,
      "expiredAt": null,
      "createdAt": "2026-09-25T12:00:00.000Z"
    }
  ]
}

Без user_id поля active на верхнем уровне нет. У отменённой и истёкшей подписки nextChargeAt — null.

Ошибки

HTTPcodeКогда
400INVALID_USER_ID, INVALID_STATUSНеверный фильтр
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено

Одна подписка

GET/api/merchant/v1/subscriptions/{id}право subscriptions

Подписка по её id.

Параметры пути

ПолеТипОбязательностьОписание
idstringобязательноid подписки (число)

Пример запроса

curl https://oropay.app/api/merchant/v1/subscriptions/5021 \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "subscription": { "id": "5021", "status": "active", "active": true, "…": "…" }
}

Ошибки

HTTPcodeКогда
404SUBSCRIPTION_NOT_FOUNDНет такой подписки у этого приложения
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено

Отменить подписку

POST/api/merchant/v1/subscriptions/{id}/cancelправо subscriptions

Отмена со стороны магазина: дальше не списываем, человек сохраняет доступ до currentPeriodEnd и получает сообщение. Деньги за текущий период не возвращаются — если нужно, верните их переводом. Повторная отмена — не ошибка ("repeated": true). Придёт вебхук subscription.cancelled.

Параметры пути

ПолеТипОбязательностьОписание
idstringобязательноid подписки

Пример запроса

curl -X POST https://oropay.app/api/merchant/v1/subscriptions/5021/cancel \
  -H "Authorization: Bearer $OROPAY_KEY"

Ответ

ответ 200
{
  "ok": true,
  "repeated": false,
  "subscription": { "id": "5021", "status": "cancelled", "active": true, "cancelledBy": "merchant", "…": "…" }
}

Ошибки

HTTPcodeКогда
404SUBSCRIPTION_NOT_FOUNDНет такой подписки у этого приложения
409SUBSCRIPTION_NOT_ACTIVEПодписка уже завершена (expired)
403SCOPE_REQUIREDУ ключа нет права subscriptions или invoices
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение отключено

События подписок

Уходят на адрес вебхука из настроек приложения — нет адреса, нет и событий. Подпись и повторы — как у счетов (проверка подписи). Дополнительные заголовки: x-cxc-event, x-cxc-event-id, x-cxc-subscription. При повторе доставки событие приходит с тем же id — не продлевайте доступ дважды.

eventКогда
subscription.createdПодписка оформлена. Если без пробного периода — с объектом charge первого списания
subscription.renewedСписан очередной период (с charge)
subscription.past_dueСписание не прошло — один раз, при переходе в past_due
subscription.expiredПодписка завершена: три неудачи или магазин выключен
subscription.cancelledОтменена человеком или вами
subscription.resumedЧеловек снова оформил отменённую подписку, пока оплаченный период не закончился: отмена снята, списания нет
тело
{
  "id": "evt_88213",
  "event": "subscription.renewed",
  "subscription": { "id": "5021", "status": "active", "active": true, "…": "…" },
  "charge": {
    "id": "9120",
    "asset": "USDT",
    "network": "TRON",
    "amount": "9.99",
    "fee": "0.1249",
    "merchantAmount": "9.8651",
    "periodStart": "2026-10-28T12:00:00.000Z",
    "periodEnd": "2026-11-27T12:00:00.000Z"
  },
  "createdAt": "2026-10-28T12:03:10.000Z"
}

Коды ошибок

Все значения code методов /api/merchant/v1/…. У старых методов счетов кода нет — только текст error и HTTP-статус (см. Создать счёт).

HTTPcodeКогда
401UNAUTHORIZEDКлюч не передан, неизвестен, перевыпущен или приложение выключено
403SCOPE_REQUIREDУ ключа нет нужного права — включается в кабинете
404METHOD_NOT_FOUNDТакого метода под /api/merchant/v1/ нет
429RATE_LIMITБольше 60 запросов выплат в минуту на приложение
500INTERNAL_ERRORСбой; деньги не списаны — повторите с тем же ключом операции
503RATE_UNAVAILABLEНет курса монеты — повторите позже с тем же ключом
403PAYOUTS_DISABLEDВыплаты по API выключены
403DAILY_LIMIT_EXCEEDEDСуточный лимит исчерпан; limitUsd, spentUsd, leftUsd
403AMOUNT_OVER_LIMITОдна выплата больше лимита; limitUsd, amountUsd
403OWNER_FROZENСредства владельца заморожены или идёт восстановление PIN-кода; иногда unlockAt
400INSUFFICIENT_FUNDSНе хватает денег на балансе магазина
400SPEND_ID_REQUIRED, INVALID_SPEND_IDНет spend_id или он неверного вида
400REFUND_ID_REQUIRED, INVALID_REFUND_IDНет refund_id или он неверного вида
400INVALID_ASSETМонета не поддерживается
400INVALID_NETWORK, NETWORK_REQUIREDСеть неизвестна / монета в нескольких сетях — укажите network
400INVALID_AMOUNT, TOO_PRECISEСумма не положительная / знаков больше, чем у монеты
400INVALID_ACTIVATIONS, DESCRIPTION_TOO_LONGПараметры чека
400CHEQUE_PASSWORD_LONG, CHEQUE_CHANNEL_BAD, CHEQUE_CHANNEL_NO_ADMIN, CHEQUE_EXPIRY_BAD, CHEQUE_ACTIVATIONSУсловия чека
400INVALID_USER_IDTelegram ID — не 4–15 цифр
400CANNOT_PAY_SELF, COMMENT_TOO_LONG, TRANSFER_FAILEDПеревод: самому себе / длинный комментарий / другой отказ (reason)
400REFUND_EXCEEDS_PAIDВозврат больше доступного; available
400INVALID_STATUSНеизвестный статус в фильтре списка
404USER_NOT_FOUNDЧеловек не запускал бота — отправьте чек
404CHEQUE_NOT_FOUND, INVOICE_NOT_FOUNDНет такого чека или счёта у этого приложения
409SPEND_ID_CONFLICT, REFUND_ID_CONFLICTКлюч операции уже использован для другой операции
409CHEQUE_ALREADY_CLAIMEDЧек забран полностью
409INVOICE_NOT_PAID, ALREADY_REFUNDEDСчёт не оплачен / уже возвращён полностью (refunded)
400INVALID_NAME, INVALID_PRICE, INVALID_PERIOD, INVALID_TRIAL, INVALID_PLAN_IDПараметры тарифа
400TOO_MANY_PLANSБольше 50 тарифов
404PLAN_NOT_FOUND, SUBSCRIPTION_NOT_FOUNDНет такого тарифа или подписки у этого приложения
409PLAN_ID_CONFLICTplan_id занят тарифом с другими условиями
409SUBSCRIPTION_NOT_ACTIVEПодписка уже завершена

Монеты и сети

Монета (asset)Знаков после точкиПримечание
USDT4Один общий остаток на все сети; network не нужен
USDC4
GRAM (TON)4Принимаются оба имени, в ответах — GRAM
TRX5
BTC8
ETH7
LTC6
BNB6
SOL6
XRP6

Сети (network): TRON, TON, EVM, BSC, LTC, BTC, SOL, XRP. Если монета лежит у вас только в одной сети, network можно не указывать. Для подписок — только USDT и USDC. Счета выставляются в фиате, монету выбирает покупатель.