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 приложений на аккаунт.
При создании показываются два значения — каждое один раз:
Что
Как выглядит
Зачем
Ключ API
cxc_ + 40 символов
Передаётся нам в каждом запросе. Хранится у нас только отпечаток (SHA-256) — показать ключ повторно невозможно.
Секрет вебхука
48 символов, без префикса
Остаётся у вас: им вы проверяете подпись наших уведомлений. Нам в запросах не передаётся.
Как передавать ключ
Только в заголовке Authorization по схеме Bearer. В адресе или теле ключ не принимается.
Пишете на 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 предназначен человеку и может меняться. Иногда в ответе есть
дополнительные поля (например, остаток лимита).
Старые методы счетов (/api/merchant/invoice…) возвращают только текст: {"ok": false, "error": "…"}.
Исключение — 403 SCOPE_REQUIRED, у него есть code. Полный список кодов — в разделе
Коды ошибок.
HTTP
Значение
400
Ошибка в данных запроса или состояние не позволяет операцию
401
Ключ не передан, неизвестен, перевыпущен, или приложение выключено
403
Нет права ключа, выплаты выключены, превышен лимит, средства владельца заморожены
404
Объект не найден или принадлежит другому приложению; неизвестный метод /v1
409
Конфликт с уже сделанным: ключ операции занят другой операцией, счёт уже оплачен и т. п.
С одного 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. Пока — скачайте библиотеку одним файлом и положите рядом со своим кодом: зависимостей нет.
Примеры: бот на aiogram 3 и на grammY, сайт на Express, PHP. Библиотеки уже внутри
Что внутри: все методы API — счета, оплата на адрес, выплаты, возвраты, подписки; verifyWebhook / verify_webhook; ошибки с полями status и code. Ответы — ровно поля API, суммы строками. Поле qr у оплаты на адрес библиотека отдаёт полным адресом.
Бот за 10 минут
Ключи. В боте OroPay: «Магазин» → создать приложение → ключ API (cxc_…) и секрет вебхука, оба показываются один раз. Токен бота — у @BotFather.
Пример. Скачайте examples.zip, откройте aiogram3-shop (Python) или grammy-shop (Node.js), скопируйте .env.example в .env и впишите токен, ключ и секрет.
Запуск.pip install -r requirements.txt и python bot.py — или npm install и npm start.
Проверка. В боте /buy → «Оплатить (тест)» → на странице оплаты «Оплатить (тест)» → «Я оплатил» → бот присылает товар. Деньги не списываются — это тестовый режим.
Вебхук и бой. Поднимите туннель (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"))
asyncdef 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))
asyncdef 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 ornot 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")
asyncdef 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())
import { createServer } from"node:http";
import { Bot, InlineKeyboard } from"grammy";
import { OroPay, verifyWebhook } from"oropay";
const oropay = new OroPay({ apiKey: process.env.OROPAY_API_KEY });
const bot = new Bot(process.env.BOT_TOKEN);
const TEST = process.env.OROPAY_TEST_MODE !== "0"; // 1 — тестовые счета, без денег
bot.command("buy", async (ctx) => {
const invoice = await oropay.createInvoice({
amount: "5.00", currency: "USD",
orderId: `tg${ctx.chat.id}-${Date.now()}`, // покупатель — в orderId
description: "Гайд", test: TEST,
callbackUrl: `${process.env.PUBLIC_URL}/oropay/webhook`,
});
const kb = new InlineKeyboard();
if (!TEST) kb.url("Оплатить в Telegram", invoice.payUrl).row();
kb.url("Любой кошелёк", invoice.checkoutUrl); // тест — только здесьawait ctx.reply(`К оплате ${invoice.charged} ${invoice.currency}`, { reply_markup: kb });
});
createServer(async (req, res) => {
const chunks = [];
forawait (const chunk of req) chunks.push(chunk);
let event;
try {
event = verifyWebhook(Buffer.concat(chunks), req.headers, process.env.OROPAY_WEBHOOK_SECRET);
} catch {
return res.writeHead(401).end();
}
const inv = event.invoice;
if (event.event === "invoice.paid" && (TEST || !inv.test)) {
const chatId = Number(/^tg(-?\d+)-/.exec(inv.orderId)[1]);
await bot.api.sendMessage(chatId, "Спасибо! Ваш товар: …"); // выдайте один раз — по inv.code
}
res.writeHead(200).end("ok");
}).listen(8080);
bot.start();
Покупатель — в 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);
});
import os
from aiohttp import web
from oropay import verify_webhook, WebhookVerificationError
SECRET = os.environ["OROPAY_WEBHOOK_SECRET"]
asyncdef oropay_webhook(request):
raw = await request.read() # сырые байты; Flask — request.get_data(), Django — request.bodytry:
event = verify_webhook(raw, request.headers, SECRET)
except WebhookVerificationError as e:
return web.Response(status=401) # e.reason: INVALID_SIGNATURE, STALE_TIMESTAMP…if event["event"] == "invoice.paid"andnot event["invoice"]["test"]:
pass# выдать заказ event["invoice"]["orderId"], если он ещё не выданreturn web.Response(text="ok")
<?php
$secret = getenv('OROPAY_WEBHOOK_SECRET'); // секрет вебхука, не ключ API
$raw = file_get_contents('php://input'); // сырое тело — до json_decode
$ts = $_SERVER['HTTP_X_CXC_TIMESTAMP'] ?? '';
$sig = strtolower($_SERVER['HTTP_X_CXC_SIGNATURE'] ?? '');
// Метка — миллисекунды Unix, не старше 5 минут; подпись — сравнение с постоянным временем.if (!preg_match('/^\d{10,16}$/', $ts) || abs(microtime(true) * 1000 - (float) $ts) > 300000
|| !hash_equals(hash_hmac('sha256', $ts . "\n" . $raw, $secret), $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if ($event['event'] === 'invoice.paid' && !$event['invoice']['test']) {
// выдать заказ $event['invoice']['orderId'], если он ещё не выдан
}
echo'ok';
Подпись не сходится? Почти всегда тело уже разобрано фреймворком: передавайте сырые байты (express.raw, await request.read(), php://input) и секрет вебхука, а не ключ API. Своё приложение можно проверить кнопкой «Отправить тестовый вебхук» в кабинете — придёт событие test.
Методы библиотек
Названия в Node.js — camelCase, в Python — snake_case; параметры тела библиотека сама отправляет в виде из этой документации (spendId → spend_id).
Неуспешный вызов бросает OroPayError: status — HTTP-статус (0 — нет ответа), code — код из раздела Коды ошибок (у старых методов счетов — пусто), message и data — всё тело ответа. Автоповторов нет: повторяйте запрос сами, с тем же orderId / spend_id / refund_id.
Счета
Счёт — это сумма в фиатной валюте (USD, EUR, RUB, UAH), которую покупатель оплачивает любой своей монетой.
Выставьте счёт — POST /api/merchant/invoice. В ответе ссылка payUrl.
Отправьте покупателя платить. С сайта — на checkoutUrl: наша страница с суммой, QR-кодом для телефона и кнопкой «Оплатить в Telegram»; после оплаты вернём покупателя на ваш returnUrl. Из бота или чата — кнопкой на payUrl, ссылку на бота вида https://t.me/OroPayBot?start=pay_PR….
Бот покажет счёт; покупатель выберет монету и заплатит со своего баланса OroPay. На странице оплаты можно и перевести на адрес с любого кошелька.
Узнайте об оплате — вебхуком invoice.paid или запросом GET /api/merchant/invoice/{code}.
Выдайте заказ. Деньги уже на балансе владельца магазина.
Комиссия и зачисление
Стартовое предложение: новым магазинам — 0% комиссии первые 30 дней с создания первого магазина, по промокоду (вводится в кабинете или командой /promo в боте) — 60 дней. В этот период в счетах feePercent = "0", feeAmount = "0", а charged равен amount при любом feeMode.
Комиссия — 0.75% от суммы заказа. Кто её платит, задаёт feeMode (по умолчанию — настройка приложения):
feeMode
Заказ (amount)
Платит покупатель (charged)
Комиссия (feeAmount)
SHOP
100
100
0.75 — удерживается из оплаты
BUYER
100
100.75
0.75 — добавлена к счёту
Курс считается в момент оплаты: покупатель платит charged, пересчитанный в выбранную монету. Это количество — paidAmount в paidToken.
На баланс магазина зачисляется та же монета: paidAmount × (charged − feeAmount) / charged, с округлением вниз до точности монеты.
Дальше курс монеты — ваш: мы её не конвертируем.
Суммы отдаются без округления до центов: комиссия с $5 — "0.0625". Минимальной комиссии нет.
Счёт живёт 1 час (expiresAt), после этого оплатить его нельзя. С баланса OroPay недоплаты и переплаты не бывает;
при оплате на адрес — см. правило 99% там.
Объект счёта
Поле
Описание
code
Код счёта, PR + 10 символов. Его принимают все остальные методы.
status
PENDING — ждёт оплаты; 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 (тогда действует адрес из настроек магазина).
Создаёт счёт и возвращает объект счёта со ссылкой payUrl. Повтор с тем же orderId и той же суммой возвращает тот же счёт.
Параметры тела
Поле
Тип
Обязательность
Описание
amount
string
обязательно
Сумма заказа, больше нуля, например "100.00"
currency
string
необязательно
USD, EUR, RUB или UAH. По умолчанию USD
orderId
string
необязательно
Ваш номер заказа — ключ идемпотентности, до 128 символов. Без него каждый вызов создаёт новый счёт
description
string
необязательно
Покажется покупателю, до 512 символов (длиннее — обрезается)
feeMode
string
необязательно
SHOP или BUYER. По умолчанию — настройка приложения
callbackUrl
string
необязательно
Адрес вебхука для этого счёта. По умолчанию — адрес из настроек приложения. Требования — в разделе Вебхуки
returnUrl
string
необязательно
Куда вернуть покупателя после оплаты: ваш сайт (https://…) или ваш бот (https://t.me/имя_бота?start=…). Покупатель увидит кнопку «Вернуться на ваш-сайт» / «Вернуться в @бот» в нашем боте и на странице оплаты; со страницы оплаты на сайт вернём сами через 5 секунд. По умолчанию — адрес из настроек магазина в кабинете; нет и его — кнопки нет. Только https://, до 512 символов
paymentMethods
array
необязательно
Способы оплаты: ["telegram", "address"]. telegram — кошелёк OroPay в Telegram (наш бот), address — перевод на адрес с любого кошелька или биржи, без нашего бота. По умолчанию — оба. Только ["address"] — бот покупатель не увидит
test
boolean
необязательно
true — тестовый счёт для проверки интеграции: настоящими деньгами не оплачивается, на странице оплаты (checkoutUrl) — кнопка «Оплатить (тест)». См. Тестовый режим
Сумма должна быть больше нуля; Валюта RUR не поддерживается. Доступны: USD, EUR, RUB, UAH; ошибки адреса вебхука (Адрес вебхука должен начинаться с https://, Внутренний адрес запрещён и т. п.)
409
—
orderId уже использован с другой суммой или валютой
409
—
Счёт с этим orderId уже истёк или отменён. Выставьте счёт с новым orderId…
401
—
Ключ не передан, неизвестен, перевыпущен или приложение отключено. Тело: {"ok": false, "error": "неизвестный ключ"}
Чтобы проверить интеграцию без настоящих денег, выставьте счёт с "test": true — тем же POST /api/merchant/invoice и тем же ключом. Тестовый счёт:
не оплачивается ни в Telegram, ни переводом на адрес — настоящие деньги по нему не движутся;
на странице оплаты (checkoutUrl) вместо способов оплаты — кнопка «Оплатить (тест)»: счёт становится PAID, а на ваш вебхук приходит обычное invoice.paid;
везде помечен полем "test": true — в ответах API и в теле вебхука (у настоящих счетов "test": false). Проверяйте это поле: не выдавайте товар по тестовой оплате;
у «оплаченного» тестового счёта нет монеты и суммы оплаты (paidToken, paidAmount — null), возврат по нему невозможен;
не входит в статистику и оборот кабинета;
живёт час и отменяется так же, как настоящий. orderId тестового и настоящего счёта не должны совпадать — повтор с другим режимом вернёт 409.
Покупатель платит с любого кошелька или биржи — аккаунт OroPay ему не нужен, наш бот он не видит. Вы берёте адрес и точную сумму в выбранной сети и показываете их у себя (или отправляете покупателя на checkoutUrl — там этот способ уже есть). Когда перевод придёт, счёт закроется сам: статус PAID, вебхук invoice.paid, на баланс магазина — сумма счёта минус комиссия.
Недоплата и опоздание. Пришло не меньше 99% суммы счёта в срок (пока действует адрес и ещё 2 часа после) — счёт оплачен автоматически. Меньше 99%, позже этого окна, другая сеть или сумма без нашей метки — деньги не теряются: поступление ждёт ручной проверки, напишите в поддержку.
Параметры тела
Поле
Тип
Обязательность
Описание
network
string
обязательно
Сеть и монета из 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 — и сумма).
status
waiting, 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 съели бы мелкий).
Отменяет неоплаченный счёт — не нужно ждать час, пока он истечёт. Возвращает счёт со статусом CANCELLED. Повторная отмена отменённого — не ошибка. Оплаченный счёт не отменяется — для него есть возврат. Проверка статуса и запись идут одним действием в базе, поэтому оплата и отмена не могут пройти одновременно.
Параметры пути
Поле
Тип
Обязательность
Описание
code
string
обязательно
Код счёта
Пример запроса
curl -X POST https://oropay.app/api/merchant/invoice/PRbtixbbdeoy/cancel \
-H "Authorization: Bearer $OROPAY_KEY"
const res = await fetch("https://oropay.app/api/merchant/invoice/PRbtixbbdeoy/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OROPAY_KEY}`,
},
});
const data = await res.json();
if (!data.ok) thrownew Error(data.code ?? data.error);
import os, requests
r = requests.post(
"https://oropay.app/api/merchant/invoice/PRbtixbbdeoy/cancel",
headers={"Authorization": f"Bearer {os.environ['OROPAY_KEY']}"},
timeout=30,
)
data = r.json()
Счета приложения, новые сверху. Страницы листаются курсором: передайте nextCursor в cursor; null — страниц больше нет. Курсор, а не смещение: новые счета сверху не сдвигают страницы.
Параметры запроса
Поле
Тип
Обязательность
Описание
limit
number
необязательно
От 1 до 100, по умолчанию 20
status
string
необязательно
PENDING, PAID, EXPIRED или CANCELLED. Фильтр по значению в базе: счёт с истёкшим сроком может числиться PENDING, пока к нему никто не обращался
Владелец нажал «Отправить тестовый вебхук» в кабинете — подробнее
Адрес из настроек приложения
Требования к адресу
Только 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-signature
HMAC-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 подписки — у событий подписок
Проверяйте подпись всегда. Без проверки кто угодно пришлёт вам «оплачено» и получит товар даром.
Подпись — HMAC-SHA256 в hex от строки timestamp + "\n" + тело, где timestamp — значение
x-cxc-timestamp, а тело — сырые байты запроса как пришли. Ключ HMAC — секрет вебхука приложения (не ключ API).
Способ одинаковый для всех событий.
Прочитайте тело сырыми байтами — до разбора JSON. Разобранный и собранный заново JSON даст другую подпись.
Посчитайте HMAC и сравните с x-cxc-signature функцией с постоянным временем.
Проверьте время. Отвергайте уведомление, если x-cxc-timestamp отличается от ваших часов больше чем на 5 минут
(в любую сторону). Подпись доказывает, что уведомление наше, но не что оно свежее: без проверки времени перехваченный
запрос можно прислать вам повторно через день. Метка ставится заново у каждой попытки доставки, поэтому повторы
после сбоя проверку проходят. Держите часы сервера синхронизированными (NTP).
Не сошлось — ответьте ошибкой и ничего не делайте. Сошлось — обработайте и ответьте 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);
});
import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["OROPAY_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000# старше 5 минут — отвергаем
@app.post("/oropay/webhook")
def oropay_webhook():
body = request.get_data() # сырые байты, до разбора JSON
ts = request.headers.get("x-cxc-timestamp", "")
# Время: метка — миллисекунды Unix. Нет метки или она дальше 5 минут — отказ.ifnot ts.isdigit() or abs(time.time() * 1000 - int(ts)) > MAX_AGE_MS:
return"", 401
expected = hmac.new(SECRET, ts.encode() + b"\n" + body, hashlib.sha256).hexdigest()
ifnot hmac.compare_digest(expected, request.headers.get("x-cxc-signature", "")):
return"", 401
event = json.loads(body)
if event["event"] == "invoice.paid":
pass # выдать заказ event["invoice"]["orderId"], если он ещё не выданreturn"", 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-кода, мгновенно) и перевыпустите ключ.
Как включить
Установите PIN-код в настройках OroPay — без него выплаты не включаются (ошибка PIN_NOT_SET).
В мини-приложении откройте «Магазин» → приложение → «Выплаты и возвраты».
Включите «Выплаты по API» и подтвердите PIN-кодом. Ключу автоматически добавится право payouts.
Задайте суточный лимит и лимит одной выплаты.
Действие в кабинете
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, выплата не делается.
Лимиты проверяются под замком владельца внутри той же транзакции, что и списание: одновременные запросы не проскочат лимит вместе.
Отмена чека возвращает остаток на баланс, но суточный лимит не восстанавливает.
Текущие значения и остаток на сегодня — в балансе.
Остатки владельца магазина и состояние выплат. Доступно любому действующему ключу. Показываются монеты с ненулевым остатком; available обрезан вниз до точности монеты — его можно целиком передать суммой выплаты. У USDT один общий остаток на все сети (network: "TRON").
enabled — выплаты действительно работают (выключатель включён и есть право payouts). dailyLimitUsd и maxPayoutUsd — действующие значения с учётом потолка сервиса.
Ошибки
HTTP
code
Когда
401
UNAUTHORIZED
Ключ не передан, неизвестен, перевыпущен или приложение отключено
Чек — ссылка link, по которой человек забирает деньги в боте. Подходит любому, даже тому, кто ни разу не открывал OroPay. Вся сумма (amount × activations) списывается с баланса сразу; неиспользованное возвращается при отмене или по сроку. Мульти-чек (activations > 1) — одна ссылка на несколько человек, каждый получает amount.
Отменяет чек и возвращает на баланс незабранные активации (поле returned); уже забранные не возвращаются. Вместо id можно передать code чека (CQ…). Повторная отмена отменённого или истёкшего чека — не ошибка: придёт тот же чек и "repeated": true. Выключатель выплат для отмены не проверяется.
Мгновенный внутренний перевод на баланс пользователя OroPay по его Telegram ID — в блокчейн не уходит. Получатель увидит сообщение «💸 Выплата от название приложения: сумма». Если в сервисе включена комиссия за переводы, она списывается с баланса магазина сверх amount.
Перевести можно только тому, кто уже запускал бота OroPay. Иначе — 404 USER_NOT_FOUND: создайте чек и отправьте человеку ссылку — чек активирует и новичок.
Возвращает оплаченный счёт покупателю — целиком или частями. Деньги списываются с баланса магазина и зачисляются плательщику в той же монете и сети, которыми он платил (paidToken, paidChain). Нужно право invoicesи включённые «Выплаты по API» (включаются с PIN-кодом): возврат — это деньги, уходящие с вашего баланса по ключу, и ключ «только для счетов» отдавать их не должен. Право payouts не нужно. Возвраты входят в суточный лимит выплат; лимит одной выплаты на них не действует.
Параметры пути
Поле
Тип
Обязательность
Описание
code
string
обязательно
Код счёта
Параметры тела
Поле
Тип
Обязательность
Описание
refund_id
string
обязательно
Ваш уникальный номер возврата — повторный запрос не вернёт деньги дважды
amount
string
необязательно
Сколько вернуть в монете оплаты. Не указано — весь остаток
reason
string
необязательно
Причина — покажется покупателю, до 200 символов (длиннее — обрезается). Можно comment
Ключ не передан, неизвестен, перевыпущен или приложение отключено
429
RATE_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
нет
Завершена: три неудачных списания или магазин выключен
Создаёт тариф и возвращает ссылку link — отдайте её человеку кнопкой в боте или на сайте. Тарифы можно заводить и без API — в кабинете: «Магазин» → приложение → «Подписки и тарифы».
Параметры тела
Поле
Тип
Обязательность
Описание
name
string
обязательно
Название, до 64 символов — его увидит подписчик
asset
string
обязательно
USDT или USDC
price
string
обязательно
Цена за период, от "0.01" до "100000", не больше 4 знаков после точки
period_days
number
обязательно
7, 30, 90 или 365
trial_days
number
необязательно
0 (по умолчанию), 3 или 7
description
string
необязательно
Что даёт подписка, до 256 символов
plan_id
string
необязательно
Ваш номер тарифа — ключ идемпотентности
Пример запроса
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"}'
Выключенный тариф не принимает новых подписчиков, а уже оформленные подписки продолжают продлеваться. Чтобы прекратить конкретную подписку, отмените её. Включить обратно — POST /api/merchant/v1/plans/{code}/enable. Возвращает тариф.
Параметры пути
Поле
Тип
Обязательность
Описание
code
string
обязательно
Код тарифа SB…
Пример запроса
curl -X POST https://oropay.app/api/merchant/v1/plans/SBk3m9x2pq7a/disable \
-H "Authorization: Bearer $OROPAY_KEY"
const res = await fetch("https://oropay.app/api/merchant/v1/plans/SBk3m9x2pq7a/disable", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OROPAY_KEY}`,
},
});
const data = await res.json();
if (!data.ok) thrownew Error(data.code ?? data.error);
import os, requests
r = requests.post(
"https://oropay.app/api/merchant/v1/plans/SBk3m9x2pq7a/disable",
headers={"Authorization": f"Bearer {os.environ['OROPAY_KEY']}"},
timeout=30,
)
data = r.json()
Главный метод для бота магазина: перед тем как пустить человека в платный раздел, спросите, есть ли у него оплаченный доступ. С user_id верхнее поле active говорит, есть ли у человека сейчас доступ хоть по одной подписке вашего магазина. Без user_id — все подписки приложения постранично, новые сверху.
Отмена со стороны магазина: дальше не списываем, человек сохраняет доступ до currentPeriodEnd и получает сообщение. Деньги за текущий период не возвращаются — если нужно, верните их переводом. Повторная отмена — не ошибка ("repeated": true). Придёт вебхук subscription.cancelled.
Параметры пути
Поле
Тип
Обязательность
Описание
id
string
обязательно
id подписки
Пример запроса
curl -X POST https://oropay.app/api/merchant/v1/subscriptions/5021/cancel \
-H "Authorization: Bearer $OROPAY_KEY"
const res = await fetch("https://oropay.app/api/merchant/v1/subscriptions/5021/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OROPAY_KEY}`,
},
});
const data = await res.json();
if (!data.ok) thrownew Error(data.code ?? data.error);
import os, requests
r = requests.post(
"https://oropay.app/api/merchant/v1/subscriptions/5021/cancel",
headers={"Authorization": f"Bearer {os.environ['OROPAY_KEY']}"},
timeout=30,
)
data = r.json()
Ключ не передан, неизвестен, перевыпущен или приложение отключено
События подписок
Уходят на адрес вебхука из настроек приложения — нет адреса, нет и событий. Подпись и повторы — как у счетов
(проверка подписи). Дополнительные заголовки: 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
Человек снова оформил отменённую подписку, пока оплаченный период не закончился: отмена снята, списания нет
Сети (network): TRON, TON, EVM, BSC, LTC, BTC, SOL, XRP.
Если монета лежит у вас только в одной сети, network можно не указывать. Для подписок — только USDT и USDC.
Счета выставляются в фиате, монету выбирает покупатель.