The Merchant API allows your bot or website to accept payments via OroPay and pay people from your shop's balance.
The customer pays from their OroPay wallet in Telegram or by a transfer to an address from any wallet or exchange — without our bot; the money is credited to the shop owner's balance.
Invoices — create an invoice, send the customer to our payment page checkoutUrl (or to the bot via payUrl), and get notified of payment via a webhook or a request. Payment to an address in 13 networks and a test mode without real money.
Payouts — balance, cheques, transfers by Telegram ID, and refunds by invoice. Disabled by default.
Subscriptions — plans with automatic recurring payments.
Base URL
base url
https://oropay.app/api/merchant
Invoices use old, unversioned paths (/api/merchant/invoice, /api/merchant/invoices),
while payouts, refunds, and subscriptions use versioned paths: /api/merchant/v1/…. Both sets of endpoints are functional.
Format
The request body is JSON with the Content-Type: application/json header. The body will not be read without this header.
The response is always JSON. Success: "ok": true and data. Error: "ok": false, text in error,
and for /v1/… methods, a stable code (see Errors).
Amounts are strings ("1.5") to avoid precision loss. Dates are in ISO 8601 format in UTC.
In the body of /v1/… methods, fields can be written in either snake_case (spend_id) or camelCase (spendId).
An unknown path under /api/merchant/v1/ returns a 404 with code: "METHOD_NOT_FOUND".
Authorization and keys
A shop application is created in the OroPay mini-app, under the "Shop" section. You can have up to 10 applications per account.
When you create an application, two values are shown, each only once:
What
Format
Purpose
API key
cxc_ + 40 characters
Sent to us in every request. We only store its fingerprint (SHA-256), so the key cannot be shown again.
Webhook secret
48 characters, no prefix
Stays on your side: you use it to verify the signature of our notifications. It is not sent to us in requests.
How to pass the key
Only in the Authorization header using the Bearer scheme. The key is not accepted in the URL or body.
Using Python with the standard urllib? Set a custom User-Agent header, for example User-Agent: MyShop/1.0. Requests with the default Python-urllib/… header are blocked by our site protection (403 response, "error code: 1010"). With requests, Node.js, curl, and other libraries, you don't need to specify anything.
The key and secret can be reissued separately. A new key becomes active immediately, and the old one stops working instantly—
even for payout requests already in transit (the application is re-read under a lock before debiting).
After reissuing the secret, all notifications will be signed with the new secret.
Reissuing the key and secret, changing the webhook URL, commission mode, and enabling the application must be confirmed with a PIN code
(if set). The owner receives a Telegram message for each reissue and webhook URL change.
After reissuing a key, "API Payouts" are disabled: the permission granted to the old key is not transferred to the new one.
You must re-enable it in your dashboard—with a PIN code.
An application disabled by the owner will respond to any key as if it were unknown—with a 401 error.
The key provides access to your invoices, and with the payouts scope, to your money. Keep it only on your server: do not embed it in your page's code, mobile app, or repository.
Key scopes
Each key has a set of scopes. A new key is granted only the invoices scope. The owner can change scopes in the application dashboard.
Scope
What it enables
invoices
Invoices: create, retrieve, cancel, list. Also plans and subscriptions. Invoice refunds are only available when "API Payouts" are enabled.
payouts
Cheques and transfers. Only work if the owner has enabled "API Payouts". The balance is visible to any key.
subscriptions
Plans and subscriptions—without invoices. Useful for a bot that only checks access.
Missing a scope results in a 403 error with code: "SCOPE_REQUIRED". A key cannot be saved with no scopes at all.
Error format
The /api/merchant/v1/… methods (payouts, refunds, subscriptions) return a stable machine-readable code —
build your logic on the code field; the error text is for human readers and may change. Sometimes the response contains
additional fields (e.g., the remaining limit).
403 response
{
"ok": false,
"code": "DAILY_LIMIT_EXCEEDED",
"error": "Daily payout limit of $500.00 has been reached (calculated across all of the owner's shops): ≈ $498.10 spent, ≈ $5.00 requested",
"limitUsd": 500,
"spentUsd": 498.1,
"leftUsd": 1.9
}
Older invoice methods (/api/merchant/invoice…) return only text: {"ok": false, "error": "…"}.
The exception is 403 SCOPE_REQUIRED, which does have a code. For a full list of codes, see the
Error codes section.
HTTP
Meaning
400
Error in request data, or the current state does not allow the operation
401
Key not provided, unknown, reissued, or the application is disabled
403
Missing key scope, payouts disabled, limit exceeded, or owner's funds are frozen
404
Object not found or belongs to another application; unknown /v1 method
409
Conflict with an existing resource: operation key is in use by another operation, invoice is already paid, etc.
Per IP address. A 429 response without a code: {"error": "Too many requests. A shop is allowed no more than 120 requests per minute."}
Payouts and refunds
60 per minute
Per application: balance, cheques, transfers, invoices/{code}/refund(s). A 429 response with code: "RATE_LIMIT".
Entire /api/… site
600 per minute
General counter per IP address.
The window is one minute. Responses include the standard RateLimit-Limit, RateLimit-Remaining,
and RateLimit-Reset headers. If you receive a 429 error, wait and retry the request with the same operation key.
Idempotency
Network connections can drop responses, and your code will inevitably retry requests. To prevent a retry from creating a second invoice or paying twice,
operations use keys. There is no separate header like Idempotency-Key—the key is passed as a body field.
Field
Where
Required
Retry with the same key
orderId
Invoice creation
no
Same amount and currency—returns the same invoice. Different amount, or if the invoice has expired/been canceled—409.
spend_id
Cheques and transfers
yes
Same data—returns the same payout with "repeated": true. Different data—409 SPEND_ID_CONFLICT.
refund_id
Invoice refund
yes
Same invoice and amount—returns the same refund with "repeated": true. Otherwise—409 REFUND_ID_CONFLICT.
plan_id
Plan creation
no
Same coin, price, period, and trial—returns the same plan. Otherwise—409 PLAN_ID_CONFLICT.
spend_id, refund_id, plan_id: 1 to 64 printable ASCII characters without spaces.
spend_id is shared between cheques and transfers and is unique within an application. orderId: up to 128 characters (longer strings are truncated).
Uniqueness is enforced by the database (unique index), and the payout record is created in the same transaction as the debit.
Two concurrent requests with the same key will not result in a double charge.
Canceling an invoice, cheque, or subscription is idempotent by nature: re-canceling an already canceled item is not an error.
Do not generate a new key on retry. Retrieve it from your database (order number, task + recipient), not from a random number at the time of the request — otherwise, a connection failure will result in a double payout.
Libraries and examples
Ready-made libraries for Node.js and Python plus bot and website examples: accept crypto in a Telegram bot or on a website in 10 minutes. No dependencies, webhook signature verification and every API method included.
Packages are coming soon to npm and PyPI. Until then, download the library as a single file and put it next to your code — there are no dependencies.
Examples: aiogram 3 and grammY bots, Express website, PHP. Libraries included
What's inside: every API method — invoices, pay to an address, payouts, refunds, subscriptions; verifyWebhook / verify_webhook; errors with status and code. Responses are the API fields as is, amounts as strings. The qr field of an address payment is returned as an absolute URL.
Bot in 10 minutes
Keys. In the OroPay bot: "Shop" → create an app → API key (cxc_…) and webhook secret, each shown once. Get a bot token from @BotFather.
Example. Download examples.zip, open aiogram3-shop (Python) or grammy-shop (Node.js), copy .env.example to .env and fill in the token, key and secret.
Run.pip install -r requirements.txt and python bot.py — or npm install and npm start.
Try it. In the bot: /buy → "Pay (test)" → "Pay (test)" on the checkout page → "I've paid" → the bot sends the item. No money moves — this is test mode.
Webhook and going live. Start a tunnel (cloudflared tunnel --url http://localhost:8080) and put its URL into PUBLIC_URL — items are delivered automatically. Then set OROPAY_TEST_MODE=0 to accept real payments.
The core of the bot, condensed (the full version with an "I've paid" button and double-delivery protection is in the archive):
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 — test invoices, no money
@dp.message(Command("buy"))
asyncdef buy(message):
invoice = await asyncio.to_thread( # sync client — run it in a thread
oropay.create_invoice, "5.00", currency="USD",
order_id=f"tg{message.chat.id}-{int(time.time() * 1000)}", # the buyer goes into orderId
description="Guide", test=TEST,
callback_url=os.environ["PUBLIC_URL"] + "/oropay/webhook",
)
rows = [] if TEST else [[Btn(text="Pay in Telegram", url=invoice["payUrl"])]]
rows.append([Btn(text="Any wallet", url=invoice["checkoutUrl"])]) # test invoices — only hereawait message.answer(f"To pay: {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, "Thank you! Your item: …") # deliver once — by 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 — test invoices, no money
bot.command("buy", async (ctx) => {
const invoice = await oropay.createInvoice({
amount: "5.00", currency: "USD",
orderId: `tg${ctx.chat.id}-${Date.now()}`, // the buyer goes into orderId
description: "Guide", test: TEST,
callbackUrl: `${process.env.PUBLIC_URL}/oropay/webhook`,
});
const kb = new InlineKeyboard();
if (!TEST) kb.url("Pay in Telegram", invoice.payUrl).row();
kb.url("Any wallet", invoice.checkoutUrl); // test invoices — only hereawait ctx.reply(`To pay: ${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, "Thank you! Your item: …"); // deliver once — by inv.code
}
res.writeHead(200).end("ok");
}).listen(8080);
bot.start();
The buyer goes into orderId. Invoices have no custom data field, so the chat_id is encoded in orderId (tg123456789-1760000000000); it is also the idempotency key, up to 128 characters. Check test: never deliver goods for a test payment in production. Take the fee from the invoice (feePercent, feeAmount): during the welcome offer it is "0".
Signature verification with the library
verifyWebhook(rawBody, headers, secret) in Node.js and verify_webhook(raw_body, headers, secret) in Python do everything from Signature verification: HMAC-SHA256 of timestamp + "\n" + body with the webhook secret, constant-time compare, rejection of timestamps more than 5 minutes away. They return the parsed event and throw WebhookVerificationError with a reason on any failure. PHP needs no library — three lines are enough.
import express from"express";
import { verifyWebhook } from"oropay";
const app = express();
// Keep the body raw: the signature covers the bytes, not parsed 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) {
// Deliver order event.invoice.orderId unless already delivered.
}
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() # raw bytes; 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# deliver order event["invoice"]["orderId"] unless already deliveredreturn web.Response(text="ok")
<?php
$secret = getenv('OROPAY_WEBHOOK_SECRET'); // webhook secret, not the API key
$raw = file_get_contents('php://input'); // raw body — before json_decode
$ts = $_SERVER['HTTP_X_CXC_TIMESTAMP'] ?? '';
$sig = strtolower($_SERVER['HTTP_X_CXC_SIGNATURE'] ?? '');
// Timestamp is Unix ms, at most 5 minutes off; constant-time signature compare.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']) {
// deliver order $event['invoice']['orderId'] unless already delivered
}
echo'ok';
Signature mismatch? Almost always the framework has already parsed the body: pass the raw bytes (express.raw, await request.read(), php://input) and the webhook secret, not the API key. Use "Send test webhook" in the dashboard to check your endpoint — a test event arrives.
Library methods
Node.js names are camelCase, Python names are snake_case; the library sends body parameters in the form used by this documentation (spendId → spend_id).
A failed call throws OroPayError: status — HTTP status (0 — no response), code — a code from Error codes (empty for the old invoice methods), message, and data — the full response body. There are no automatic retries: retry yourself with the same orderId / spend_id / refund_id.
Invoices
An invoice is an amount in a fiat currency (USD, EUR, RUB, UAH) that the buyer pays with any of their coins.
Create an invoice — POST /api/merchant/invoice. The response contains a payUrl link.
Send the buyer to pay. From a website — to checkoutUrl: our page with the amount, a QR code for the phone and a “Pay in Telegram” button; after payment we send the buyer back to your returnUrl. From a bot or chat — a button to payUrl, a link to the bot like https://t.me/OroPayBot?start=pay_PR….
The bot will show the invoice; the buyer will choose a coin and pay from their OroPay balance. On the payment page they can also transfer to an address from any wallet.
Get notified of the payment — via the invoice.paid webhook or a GET /api/merchant/invoice/{code} request.
Fulfill the order. The money is already in the shop owner's balance.
Fees and crediting
Welcome offer: new shops pay 0% fee for the first 30 days after creating their first shop, or 60 days with a promo code (enter it in the shop dashboard or send /promo to the bot). During this period invoices have feePercent = "0", feeAmount = "0", and charged equals amount for any feeMode.
The fee is 0.75% of the order amount. Who pays it is determined by feeMode (defaults to the application setting):
feeMode
Order (amount)
Buyer pays (charged)
Fee (feeAmount)
SHOP
100
100
0.75 — deducted from the payment
BUYER
100
100.75
0.75 — added to the invoice
The exchange rate is calculated at the moment of payment: the buyer pays the charged amount, converted to the chosen coin. This quantity is paidAmount in paidToken.
The same coin is credited to the shop's balance: paidAmount × (charged − feeAmount) / charged, rounded down to the coin's precision.
From then on, the coin's exchange rate is your responsibility: we do not convert it.
Amounts are returned without rounding to cents: the fee for $5 is "0.0625". There is no minimum fee.
An invoice is valid for 1 hour (expiresAt), after which it cannot be paid. Payment is made from the internal balance,
so underpayments and overpayments are not possible when paying from an OroPay balance; for payments to an address see the 99% rule there.
The invoice object
Field
Description
code
Invoice code, PR + 10 characters. It is accepted by all other methods.
status
PENDING — awaiting payment; PAID — paid; EXPIRED — expired after one hour; CANCELLED — cancelled. An expired invoice is immediately shown as EXPIRED.
orderId, description
Your order number and description, or null.
amount
The order amount — the fee is calculated from it.
charged
How much the buyer pays, in currency.
feeMode, feePercent, feeAmount
Fee mode and amount.
paidToken, paidChain, paidAmount
The coin, network, and amount paid. Before payment, these are null.
createdAt, expiresAt, paidAt
Creation, expiration, and payment timestamps.
payUrl
Link to pay in the bot.
checkoutUrl
Payment page for websites: amount, a QR code for the phone, a “Pay in Telegram” button, live status and a return to returnUrl after payment.
returnUrl
Return address set on this invoice, or null (the shop setting applies then).
Creates an invoice and returns the invoice object with a payUrl link. A repeat request with the same orderId and amount returns the same invoice.
Body parameters
Field
Type
Required
Description
amount
string
required
Order amount, greater than zero, e.g., "100.00"
currency
string
optional
USD, EUR, RUB, or UAH. Defaults to USD
orderId
string
optional
Your order number — an idempotency key, up to 128 characters. Without it, each call creates a new invoice
description
string
optional
Shown to the buyer, up to 512 characters (longer text is truncated)
feeMode
string
optional
SHOP or BUYER. Defaults to the application setting
callbackUrl
string
optional
Webhook URL for this invoice. Defaults to the URL from the application settings. For requirements, see the Webhooks section
returnUrl
string
optional
Where to send the buyer after payment: your website (https://…) or your bot (https://t.me/bot_name?start=…). The buyer sees a “Back to your-site” / “Back to @bot” button in our bot and on the payment page; from the payment page we return them to your website automatically after 5 seconds. Defaults to the shop setting in the cabinet; if neither is set, there is no button. https:// only, up to 512 characters
paymentMethods
array
optional
Payment methods: ["telegram", "address"]. telegram — OroPay wallet in Telegram (our bot), address — a transfer to an address from any wallet or exchange, without our bot. Defaults to both. With only ["address"] the buyer never sees the bot
test
boolean
optional
true — a test invoice for checking your integration: it can't be paid with real money; the payment page (checkoutUrl) shows a “Pay (test)” button. See Test mode
Amount must be greater than zero; Currency RUR is not supported. Available: USD, EUR, RUB, UAH; webhook address errors (Webhook address must start with https://, Internal address is forbidden, etc.)
409
—
orderId has already been used with a different amount or currency
409
—
The invoice with this orderId has already expired or been canceled. Create an invoice with a new orderId...
401
—
Key not provided, unknown, reissued, or the application is disabled. Body: {"ok": false, "error": "unknown key"}
To test your integration without real money, create an invoice with "test": true — the same POST /api/merchant/invoice with the same key. A test invoice:
can't be paid in Telegram or by a transfer to an address — no real money moves;
on the payment page (checkoutUrl) shows a “Pay (test)” button instead of payment methods: the invoice becomes PAID and your webhook receives the usual invoice.paid;
is marked with "test": true everywhere — in API responses and in the webhook body (real invoices have "test": false). Check this field: don't deliver goods for a test payment;
a “paid” test invoice has no coin or paid amount (paidToken, paidAmount are null) and can't be refunded;
is excluded from the dashboard statistics and turnover;
lives for an hour and is cancelled just like a real one. A test and a real invoice must not share an orderId — repeating it in the other mode returns 409.
The buyer pays from any wallet or exchange — no OroPay account needed, and they never see our bot. You get an address and the exact amount in the chosen network and show them on your side (or send the buyer to checkoutUrl — this method is already there). When the transfer arrives, the invoice closes by itself: status PAID, the invoice.paid webhook, and the invoice amount minus the fee goes to the shop balance.
Underpayment and late payment. If at least 99% of the invoice amount arrives in time (while the address is valid and 2 more hours) — the invoice is paid automatically. Less than 99%, later than that window, another network or an amount without our tag — the money isn't lost: the transfer waits for manual review, contact support.
Body parameters
Field
Type
Required
Description
network
string
required
Network and coin from GET /api/merchant/networks, e.g. TRON:USDT, BSC:USDT, TON:TON
Response — the payment field
Field
Meaning
address
Where to send.
payAmount, coin
How much to send — exactly this amount: it identifies the payment. It includes the invoice amount, the network fee and a unique tag.
amount, surcharge
The invoice amount in the coin and the network fee.
expiresAt
Until when we wait for the transfer: 25 minutes, one hour for LTC and BTC (the ttlMin field in GET /api/merchant/networks), but no later than the invoice itself expires. A transfer arriving within 2 more hours still closes the invoice.
qr
QR image (the address; for TON and EVM — the amount too).
status
waiting, expired, paid, cancelled.
Repeating with the same network while the payment is waiting returns the same address and amount. Another network creates another payment: whichever is paid closes the invoice; a second transfer after that goes to manual review and refund. Underpayment within the network tolerance (exchange fees) is accepted; more — manual review. GET /api/merchant/invoice/{code}/address lists the invoice payments. Networks: USDT (TRC-20, BEP-20, ERC-20, TON), USDC (BEP-20, ERC-20), TON, TRX, BNB, ETH, LTC, SOL and BTC (invoices from $70: BTC network fees would eat a small one).
Cancels an unpaid invoice, so you don't have to wait an hour for it to expire. Returns the invoice with the CANCELLED status. Canceling an already canceled invoice is not an error. A paid invoice cannot be canceled; use a refund instead. The status check and update are performed as a single atomic database operation, so a payment and a cancellation cannot occur simultaneously.
Path parameters
Field
Type
Required
Description
code
string
required
Invoice code
Example request
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()
Returns a list of your application's invoices, with the most recent ones appearing first. This endpoint uses cursor-based pagination: pass the nextCursor value in the cursor parameter to fetch the next page. A null value for nextCursor means there are no more pages. Using a cursor instead of an offset ensures that new invoices at the top of the list do not shift the page content.
Query parameters
Field
Type
Required
Description
limit
number
optional
A number from 1 to 100, defaults to 20
status
string
optional
PENDING, PAID, EXPIRED, or CANCELLED. Filters by the value in the database: an expired invoice might still have a PENDING status until it is accessed
Unknown status X. Available: PENDING, PAID, EXPIRED, CANCELLED; Cursor not found
401
—
The key was not provided, is unknown, has been reissued, or the application is disabled. Body: {"ok": false, "error": "unknown key"}
403
SCOPE_REQUIRED
The key does not have the invoices scope
Webhooks
We send a POST request with a JSON body to your URL. Webhooks are optional: if you don't provide a URL, you won't receive notifications, but you can still poll for the status via an API request.
Event
When
Where
invoice.paid
Invoice is paid
The invoice's callbackUrl; if not set during creation, the application's URL at that time
invoice.refunded
A refund is issued for an invoice
The invoice's callbackUrl, otherwise the application's URL
The URL from the application settings at the time of the event
test
The owner clicked "Send test webhook" in the dashboard — more details
The URL from the application settings
URL requirements
Must be an https:// URL, up to 512 characters, a hostname, not an IPv6 address.
localhost, *.local, *.internal, and internal IP ranges (10.x, 127.x, 169.254.x, 172.16–31.x, 192.168.x, 100.64/10, etc.) are forbidden.
Before each delivery attempt, the hostname is resolved via DNS, and if it points to an internal address, the notification is not sent.
Redirects are not followed: a 3xx response is considered a failure.
Headers
Header
Value
x-cxc-timestamp
The delivery timestamp in Unix milliseconds. A new value for each attempt
x-cxc-signature
HMAC-SHA256, hex — see below
x-cxc-invoice
Invoice code — for invoice.paid and invoice.refunded
x-cxc-refund
Your refund_id — for invoice.refunded
x-cxc-event, x-cxc-event-id, x-cxc-subscription
Event name, its ID (evt_…), and the subscription ID — for subscription events
Always verify the signature. Without verification, anyone could send you a "paid" notification and get your product for free.
The signature is the hex-encoded HMAC-SHA256 of the string timestamp + "\n" + body, where timestamp is the value from the x-cxc-timestamp header and body is the raw request payload. The HMAC key is your application's webhook secret (not the API key). This method is the same for all events.
Read the request body as raw bytes before parsing the JSON. A parsed and re-serialized JSON will produce a different signature.
Compute the HMAC and compare it with the x-cxc-signature header value using a constant-time comparison function.
Verify the timestamp. Reject the notification if the x-cxc-timestamp differs from your server's clock by more than 5 minutes (in either direction). The signature proves that the notification is from us, but not that it's recent. Without a timestamp check, an intercepted request could be replayed later. A new timestamp is generated for each delivery attempt, so retries after a failure will pass this check. Keep your server's clock synchronized (e.g., using NTP).
If the signature or timestamp is invalid, respond with an error and take no action. If it's valid, process the event and respond with a 2xx status code.
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; // older than 5 minutes — reject// The body must be raw: the signature is calculated over bytes, not parsed 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") || "";
// Time: timestamp is Unix milliseconds. No timestamp or it's off by more than 5 minutes — reject.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") {
// Fulfill the order event.invoice.orderId if it hasn't been fulfilled yet.
}
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# older than 5 minutes — reject
@app.post("/oropay/webhook")
def oropay_webhook():
body = request.get_data() # raw bytes, before JSON parsing
ts = request.headers.get("x-cxc-timestamp", "")
# Time: timestamp is Unix milliseconds. No timestamp or it's off by more than 5 minutes — reject.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 # fulfill the order event["invoice"]["orderId"] if it hasn't been fulfilled yetreturn"", 200
Delivery and retries
The first attempt is made immediately after the event. Success is a 2xx response within 15 seconds. Your response body is not read.
On failure (another status code, timeout, connection drop, 3xx), we retry with an increasing delay: 1, 2, 4, 8, 16, 32 minutes, then once per hour.
Up to 12 attempts in total — about 6 hours. The queue is checked once a minute.
Each attempt comes with a new x-cxc-timestamp and a new signature.
Webhooks for subscriptions and refunds are delivered under the same rules.
An event might be delivered more than once — for example, if your response was lost. Handle events idempotently: for invoice.paid, use the invoice code; for invoice.refunded, use the refundId; for subscriptions, use the event id (evt_…). If an event has already been processed, simply respond with 200.
Test webhook
In your shop dashboard (Keys tab, next to the webhook URL), there is a "Send test webhook" button. We send a
test event to the URL from your application settings, following the same process as real events: same x-cxc-signature signed with the webhook
secret, same address validation (internal addresses are prohibited), no redirects, 15-second timeout.
The dashboard immediately shows your server's response: status code, time, the first 500 characters of the response, or an error.
body
{
"event": "test",
"test": true,
"id": "test_9a8b7c6d5e4f",
"app": { "id": "17", "name": "Sadovaya Coffee Shop" },
"createdAt": "2026-09-26T09:50:00.000Z",
"message": "Test event from the dashboard. Verify the signature and respond with 2xx; no money is involved."
}
How to distinguish a test event from a real one
In the body: "event": "test" and "test": true; in the headers: x-cxc-event: test and x-cxc-test: 1.
There is no invoice and no x-cxc-invoice header: no money is involved in a test. Do not fulfill anything — just verify the signature and respond with 2xx.
A test event is not retried on failure and does not enter the retry queue.
No more than 10 tests per minute per owner (across all their shops).
The delivery history for real webhooks — time, response code, attempt number — is visible in the invoice details in your dashboard (Invoices tab).
Payouts
You can pay people from your shop balance: using cheques (a link that any Telegram user can use to claim money)
and transfers by Telegram ID. This is useful, for example, for a bot that pays for completed tasks. The shop balance is the owner's balance,
the same one that receives invoice payments.
Payouts are disabled by default — and for a good reason
With an API key, money is sent without a PIN code: the key itself serves as confirmation. If a key with the payouts scope is leaked,
whoever gets it can send your money — for example, by creating cheques that anyone can claim. A claimed
cheque or transfer cannot be canceled.
The only limit to the damage is your limits. In a single day, a leak will cost no more than your daily limit.
Keep your limits minimal — just enough for your actual daily payout volume, and raise them when needed.
Create a separate application for the payout bot with a key that only has the payouts scope; issue invoices using a different key.
Store the key only on the server, not in logs or in the repository.
If you suspect a leak, disable "API Payouts" (instantly, no PIN required) and reissue the key.
How to enable
Set a PIN code in the OroPay settings — payouts cannot be enabled without it (error PIN_NOT_SET).
In the mini app, open "Shop" → application → "Payouts and Refunds".
Enable "API Payouts" and confirm with your PIN code. The payouts scope will be automatically added to the key.
Set the daily limit and single payout limit.
Action in the dashboard
PIN code
Enable payouts, add the scope to a key, increase any limit
Required
Reissue a key or secret, change the webhook URL
Required. After reissuing a key, payouts are disabled — enable them again with a PIN
Disable payouts, remove the scope, decrease a limit
Not required — this is an emergency stop, it works instantly
An API payout is processed only if all of the following are true: the key has the payouts scope, the "API Payouts" switch is enabled,
the owner's funds are not frozen, and a PIN code recovery is not in progress. Otherwise — 403 PAYOUTS_DISABLED or 403 OWNER_FROZEN.
Invoice refunds also require the "API Payouts" switch to be enabled (the payouts scope is not needed for them) — otherwise 403 REFUNDS_DISABLED.
Limits
Limit
Default
What is counted
Daily
$500
Everything sent via the API over a rolling 24-hour period across all of the owner's applications (the money comes from a single balance, so the limit is also single): cheques (the total amount — amount × activations), transfers, and invoice refunds. Exceeding the limit results in 403 DAILY_LIMIT_EXCEEDED with the limitUsd, spentUsd, and leftUsd fields.
Single payout
$100
The amount of a transfer or a single cheque activation. Cannot exceed the daily limit. Does not apply to refunds. Exceeding the limit results in 403 AMOUNT_OVER_LIMIT with limitUsd, amountUsd.
Service ceiling
$25,000 per day
Per owner, across all their applications. The limit cannot be raised above this ceiling. The single payout limit also cannot exceed this ceiling.
Dollars are calculated based on the coin's exchange rate at the time of the payout. If there's no rate, 503 RATE_UNAVAILABLE is returned, and the payout is not made.
Limits are checked under an owner-level lock within the same transaction as the debit: concurrent requests will not bypass the limit together.
Canceling a cheque returns the remainder to the balance but does not restore the daily limit.
Current values and the remaining amount for today are available in the balance.
The shop owner's balances and payout status. Available to any active key. Coins with non-zero balances are shown; available is rounded down to the coin's precision — it can be passed as a payout amount in its entirety. USDT has one shared balance across all networks (network: "TRON").
enabled — payouts are actually working (the switch is on and the payouts scope is present). dailyLimitUsd and maxPayoutUsd are the effective values, taking the service ceiling into account.
Errors
HTTP
code
When
401
UNAUTHORIZED
Key not provided, unknown, reissued, or the application is disabled
429
RATE_LIMIT
More than 60 payout requests per minute per application
A cheque is a link that a person uses to claim money in the bot. It works for anyone, even those who have never opened OroPay. The total amount (amount × activations) is debited from the balance immediately; any unused portion is returned upon cancellation or expiration. A multi-cheque (activations > 1) is a single link for multiple people, where each person receives the amount.
Cancels a cheque and returns unclaimed activations to the balance (the returned field); already claimed activations are not returned. Instead of the id, you can pass the cheque's code (CQ…). Repeatedly canceling a cheque that is already cancelled or expired is not an error: you will get the same cheque back with "repeated": true. The payouts toggle is not checked for cancellation.
An instant internal transfer to an OroPay user's balance via their Telegram ID — does not go to the blockchain. The recipient will see the message "💸 Payout from application name: amount". If a transfer fee is enabled in the service, it is deducted from the shop's balance in addition to the amount.
Body parameters
Field
Type
Required
Description
spend_id
string
required
Your unique operation ID
user_id
string | number
required
Recipient's Telegram ID, 4–15 digits
asset
string
required
The asset, same as for cheques
amount
string
required
The amount, greater than zero, with precision not exceeding the asset's precision
comment
string
optional
Shown to the recipient, up to 200 characters
network
string
optional
The network, if you hold the asset in multiple networks
You can only transfer funds to a user who has already started the OroPay bot. Otherwise, you'll get a 404 USER_NOT_FOUND error. In that case, create a cheque and send the link to the user — even a new user can activate a cheque.
Errors
HTTP
code
When
400
INVALID_USER_ID
user_id is not 4–15 digits
404
USER_NOT_FOUND
The user has not started the bot — send them a cheque
Refunds a paid invoice to the customer, either in full or in part. The funds are debited from the shop's balance and credited to the payer in the same asset and network they used for payment (paidToken, paidChain). This requires the invoices scope and "API Payouts" to be enabled (activated with a PIN code). A refund is a transaction where money leaves your balance via an API key, so a key limited to invoices should not be able to perform it. The payouts scope is not required. Refunds count towards the daily payout limit, but the single payout limit does not apply to them.
Path parameters
Field
Type
Required
Description
code
string
required
Invoice code
Body parameters
Field
Type
Required
Description
refund_id
string
required
Your unique refund ID. A repeated request will not refund the money twice.
amount
string
optional
The amount to refund in the payment asset. If not specified, the entire remaining amount is refunded.
reason
string
optional
The reason for the refund, which will be shown to the customer. Up to 200 characters (longer text will be truncated). You can also use comment.
Example request
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":"Product is out of stock"}'
const res = await fetch("https://oropay.app/api/merchant/v1/invoices/PRbtixbbdeoy/refund", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OROPAY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"refund_id": "order-1024-r1",
"amount": "40",
"reason": "Product is out of stock"
}),
});
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/invoices/PRbtixbbdeoy/refund",
headers={"Authorization": f"Bearer {os.environ['OROPAY_KEY']}"},
json={
"refund_id": "order-1024-r1",
"amount": "40",
"reason": "Product is out of stock",
},
timeout=30,
)
data = r.json()
You can refund no more than the paidAmount minus the amount already refunded. Two concurrent partial refunds will not exceed the paid amount, as the sum is locked during the process.
Our fee is not refunded. A full refund will deduct the entire amount paid by the customer, which is slightly more than what you received.
The invoice remains in the PAID status. The refundedTotal field shows the amount already refunded, and refundable shows how much more can be refunded.
The customer receives a message in Telegram, and an invoice.refunded webhook is sent to your URL.
Errors
HTTP
code
When
400
REFUND_ID_REQUIRED, INVALID_REFUND_ID
Missing or invalid refund_id
400
INVALID_AMOUNT, TOO_PRECISE
Amount is not positive or has more precision than the asset allows
400
REFUND_EXCEEDS_PAID
Exceeds available amount; see the available field
400
INSUFFICIENT_FUNDS
Insufficient funds on the shop's balance
404
INVOICE_NOT_FOUND
Invoice not found for this application
409
INVOICE_NOT_PAID
Invoice is not paid, nothing to refund
409
ALREADY_REFUNDED
Invoice has already been fully refunded; see the refunded field
409
REFUND_ID_CONFLICT
refund_id has already been used for another refund
403
REFUNDS_DISABLED
"API Payouts" are disabled. Enable them in your dashboard using your PIN.
Key not provided, unknown, reissued, or application is disabled
429
RATE_LIMIT
More than 60 requests per minute for the application
Subscriptions
Recurring payments. You create a plan, a user subscribes via the plan's link
https://t.me/OroPayBot?start=SB… or in the Mini App and confirms it with their PIN code, if they have one set up.
This constitutes their consent for automatic charges from their OroPay balance.
The first charge occurs immediately upon subscription; if the plan includes a trial period, the charge occurs on the day it ends. The trial is offered only once:
to users who have not had any previous subscriptions in your shop.
Subsequently, we charge the price on the renewal date (nextChargeAt); the user receives a reminder 24 hours prior.
Renewals are checked every 10 minutes.
If there are insufficient funds or the funds are frozen, the status becomes past_due, access is denied, and we retry after 24 hours. After the third
failed attempt, the status becomes expired. If the shop's application is disabled, the subscription also becomes expired upon renewal.
Cancellation (by the user or by you) — we stop charging, and access remains until the end of the paid period. Funds for the current period
are not automatically refunded.
You receive the price minus the same commission as for invoices (0.75%), credited to the shop owner's balance.
There is exactly one charge per period — this is enforced by a unique index in the database.
Plan conditions: currency must be USDT or USDC; period can be 7, 30, 90, or 365 days; trial can be 0, 3, or 7 days;
price ranges from 0.01 to 100000. The price and period cannot be changed after creation: a new price requires a new plan. Up to 50 plans per application,
including disabled ones. Requires the subscriptions or invoices key scope.
The rate for these methods is limited by a general counter of 120 requests per minute per IP address.
status
Access (active)
Meaning
trialing
yes
Trial period; the first charge is at nextChargeAt
active
yes
Paid until currentPeriodEnd
past_due
no
Charge failed; next attempt at nextChargeAt, number of attempts in failedAttempts
cancelled
until currentPeriodEnd
Cancelled (by user or merchant), no further charges will be made
expired
no
Expired: after three failed charges or if the shop is disabled
Creates a plan and returns a link. Provide this link to the user as a button in your bot or on your website. Plans can also be created without the API in your dashboard: "Shop" → application → "Subscriptions & Plans".
Body parameters
Field
Type
Required
Description
name
string
required
Name, up to 64 characters — visible to the subscriber
asset
string
required
USDT or USDC
price
string
required
Price per period, from "0.01" to "100000", with up to 4 decimal places
period_days
number
required
7, 30, 90, or 365
trial_days
number
optional
0 (default), 3, or 7
description
string
optional
What the subscription provides, up to 256 characters
plan_id
string
optional
Your plan ID — for idempotency
Example request
curl -X POST https://oropay.app/api/merchant/v1/plans \
-H "Authorization: Bearer $OROPAY_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"VIP channel","asset":"USDT","price":"9.99","period_days":30,"trial_days":3,"description":"Access to a private channel","plan_id":"vip-monthly"}'
const res = await fetch("https://oropay.app/api/merchant/v1/plans", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OROPAY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "VIP channel",
"asset": "USDT",
"price": "9.99",
"period_days": 30,
"trial_days": 3,
"description": "Access to a private channel",
"plan_id": "vip-monthly"
}),
});
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",
headers={"Authorization": f"Bearer {os.environ['OROPAY_KEY']}"},
json={
"name": "VIP channel",
"asset": "USDT",
"price": "9.99",
"period_days": 30,
"trial_days": 3,
"description": "Access to a private channel",
"plan_id": "vip-monthly",
},
timeout=30,
)
data = r.json()
A disabled plan does not accept new subscribers, but existing subscriptions continue to renew. To stop a specific subscription, cancel it. To re-enable it, use POST /api/merchant/v1/plans/{code}/enable. Returns the plan.
Path parameters
Field
Type
Required
Description
code
string
required
Plan code SB…
Example request
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()
The main method for a shop bot: before letting a user into a paid section, check if they have paid access. With user_id, the top-level active field indicates whether the user currently has access through at least one of your shop's subscriptions. Without user_id, it returns all app subscriptions, paginated, with the newest ones first.
Cancels a subscription on the shop's side: no further charges will be made, the user retains access until currentPeriodEnd and receives a notification. Funds for the current period are not refunded—if necessary, refund them via a transfer. A repeated cancellation is not an error ("repeated": true). A subscription.cancelled webhook will be sent.
Path parameters
Field
Type
Required
Description
id
string
required
Subscription id
Example request
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()
The key was not provided, is unknown, has been reissued, or the app is disabled
Subscription events
Events are sent to the webhook URL specified in the app settings. No URL, no events. Signature and retries work the same way as for invoices (signature verification). Additional headers: x-cxc-event, x-cxc-event-id, x-cxc-subscription. On redelivery, the event is sent with the same id — do not grant access twice.
event
When
subscription.created
Subscription created. If there is no trial period, it includes the first charge object.
subscription.renewed
The next period has been charged (with a charge object).
subscription.past_due
A charge failed — sent once when the subscription enters the past_due status.
subscription.expired
Subscription has ended: after three failed payments or if the shop is disabled.
subscription.cancelled
Cancelled by the user or by you.
subscription.resumed
The user re-subscribed to a cancelled subscription before the paid period ended: the cancellation is reversed, no new charge is made.
All code values for /api/merchant/v1/… methods. Older invoice methods do not have a code; they only return an error text and an HTTP status (see Create invoice).
HTTP
code
When
401
UNAUTHORIZED
The key was not provided, is unknown, has been reissued, or the app is disabled.
403
SCOPE_REQUIRED
The key lacks the required scope — enable it in your dashboard.
404
METHOD_NOT_FOUND
No such method under /api/merchant/v1/.
429
RATE_LIMIT
More than 60 payout requests per minute per app.
500
INTERNAL_ERROR
An internal failure occurred; funds were not charged. Retry the operation with the same idempotency key.
503
RATE_UNAVAILABLE
The coin's exchange rate is unavailable. Retry later with the same idempotency key.
plan_id is taken by a plan with different conditions
409
SUBSCRIPTION_NOT_ACTIVE
Subscription is already finished
Coins and Networks
Coin (asset)
Decimal places
Note
USDT
4
One shared balance across all networks; network is not needed
USDC
4
GRAM (TON)
4
Both names are accepted, responses use GRAM
TRX
5
BTC
8
ETH
7
LTC
6
BNB
6
SOL
6
XRP
6
Networks (network): TRON, TON, EVM, BSC, LTC, BTC, SOL, XRP.
If you hold a coin in only one network, you can omit the network parameter. For subscriptions, only USDT and USDC are supported.
Invoices are issued in fiat, the coin is chosen by the customer.