Merchant API

Merchant API

Invoices with a Telegram payment link, signed webhooks, payouts via cheques and transfers, refunds, and subscriptions.

https://oropay.app/api/merchant fee 0.75% invoice lifetime 1 hour bot @OroPayBot
Contents

Overview

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:

WhatFormatPurpose
API keycxc_ + 40 charactersSent to us in every request. We only store its fingerprint (SHA-256), so the key cannot be shown again.
Webhook secret48 characters, no prefixStays 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.

http
Authorization: Bearer cxc_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0

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.

ScopeWhat it enables
invoicesInvoices: create, retrieve, cancel, list. Also plans and subscriptions. Invoice refunds are only available when "API Payouts" are enabled.
payoutsCheques and transfers. Only work if the owner has enabled "API Payouts". The balance is visible to any key.
subscriptionsPlans 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.

HTTPMeaning
400Error in request data, or the current state does not allow the operation
401Key not provided, unknown, reissued, or the application is disabled
403Missing key scope, payouts disabled, limit exceeded, or owner's funds are frozen
404Object not found or belongs to another application; unknown /v1 method
409Conflict with an existing resource: operation key is in use by another operation, invoice is already paid, etc.
429Too many requests—see Rate limits
500An error on our side
503Coin exchange rate is unavailable—try again later

Rate limits

CounterLimitWhat it counts
All requests to /api/merchant/…120 per minutePer 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 refunds60 per minutePer application: balance, cheques, transfers, invoices/{code}/refund(s). A 429 response with code: "RATE_LIMIT".
Entire /api/… site600 per minuteGeneral 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.

FieldWhereRequiredRetry with the same key
orderIdInvoice creationnoSame amount and currency—returns the same invoice. Different amount, or if the invoice has expired/been canceled—409.
spend_idCheques and transfersyesSame data—returns the same payout with "repeated": true. Different data—409 SPEND_ID_CONFLICT.
refund_idInvoice refundyesSame invoice and amount—returns the same refund with "repeated": true. Otherwise—409 REFUND_ID_CONFLICT.
plan_idPlan creationnoSame 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.

Install

npm i oropay        # coming soon to npm

Single-file download

FileWhat it is
oropay.cjsNode.js 18+, CommonJS: const { OroPay } = require("./oropay.cjs")
oropay.mjsNode.js 18+, ES module: import { OroPay } from "./oropay.mjs"
oropay.pyPython 3.8+, standard library only: put it next to your code and import oropay
examples.zipExamples: 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

  1. Keys. In the OroPay bot: "Shop" → create an app → API key (cxc_…) and webhook secret, each shown once. Get a bot token from @BotFather.
  2. 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.
  3. Run. pip install -r requirements.txt and python bot.py — or npm install and npm start.
  4. 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.
  5. 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"))
async def 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 here
    await message.answer(f"To pay: {invoice['charged']} {invoice['currency']}",
                         reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))

async def webhook(request):
    try:
        event = verify_webhook(await request.read(), request.headers, os.environ["OROPAY_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return web.Response(status=401)
    inv = event.get("invoice") or {}
    if event["event"] == "invoice.paid" and (TEST or not inv["test"]):
        chat_id = int(re.match(r"tg(-?\d+)-", inv["orderId"]).group(1))
        await bot.send_message(chat_id, "Thank you! Your item: …")  # deliver once — by inv["code"]
    return web.Response(text="ok")

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

asyncio.run(main())

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);
});

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).

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

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.

  1. Create an invoice — POST /api/merchant/invoice. The response contains a payUrl link.
  2. 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.
  3. Get notified of the payment — via the invoice.paid webhook or a GET /api/merchant/invoice/{code} request.
  4. 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):

feeModeOrder (amount)Buyer pays (charged)Fee (feeAmount)
SHOP1001000.75 — deducted from the payment
BUYER100100.750.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

FieldDescription
codeInvoice code, PR + 10 characters. It is accepted by all other methods.
statusPENDING — awaiting payment; PAID — paid; EXPIRED — expired after one hour; CANCELLED — cancelled. An expired invoice is immediately shown as EXPIRED.
orderId, descriptionYour order number and description, or null.
amountThe order amount — the fee is calculated from it.
chargedHow much the buyer pays, in currency.
feeMode, feePercent, feeAmountFee mode and amount.
paidToken, paidChain, paidAmountThe coin, network, and amount paid. Before payment, these are null.
createdAt, expiresAt, paidAtCreation, expiration, and payment timestamps.
payUrlLink to pay in the bot.
checkoutUrlPayment page for websites: amount, a QR code for the phone, a “Pay in Telegram” button, live status and a return to returnUrl after payment.
returnUrlReturn address set on this invoice, or null (the shop setting applies then).

Create an invoice

POST/api/merchant/invoiceinvoices scope

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

FieldTypeRequiredDescription
amountstringrequiredOrder amount, greater than zero, e.g., "100.00"
currencystringoptionalUSD, EUR, RUB, or UAH. Defaults to USD
orderIdstringoptionalYour order number — an idempotency key, up to 128 characters. Without it, each call creates a new invoice
descriptionstringoptionalShown to the buyer, up to 512 characters (longer text is truncated)
feeModestringoptionalSHOP or BUYER. Defaults to the application setting
callbackUrlstringoptionalWebhook URL for this invoice. Defaults to the URL from the application settings. For requirements, see the Webhooks section
returnUrlstringoptionalWhere 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
paymentMethodsarrayoptionalPayment 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
testbooleanoptionaltrue — 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

Example request

curl -X POST https://oropay.app/api/merchant/invoice \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":"100.00","currency":"USD","orderId":"order-1024","description":"Pro subscription, 1 month","feeMode":"BUYER"}'

Response

200 response
{
  "ok": true,
  "invoice": {
    "code": "PRbtixbbdeoy",
    "status": "PENDING",
    "orderId": "order-1024",
    "description": "Pro subscription, 1 month",
    "amount": "100.00",
    "charged": "100.75",
    "currency": "USD",
    "feeMode": "BUYER",
    "feePercent": "0.75",
    "feeAmount": "0.75",
    "paidToken": null,
    "paidChain": null,
    "paidAmount": null,
    "createdAt": "2026-09-25T12:00:00.000Z",
    "expiresAt": "2026-09-25T13:00:00.000Z",
    "paidAt": null,
    "payUrl": "https://t.me/OroPayBot?start=pay_PRbtixbbdeoy",
    "checkoutUrl": "https://oropay.app/pay/PRbtixbbdeoy",
    "returnUrl": "https://shop.example/order/1001"
  }
}

Errors

HTTPcodeWhen
400—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"}
403SCOPE_REQUIREDThe key does not have the invoices scope

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.

Pay to an address (no bot)

POST/api/merchant/invoice/{code}/addressscope invoices

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

FieldTypeRequiredDescription
networkstringrequiredNetwork and coin from GET /api/merchant/networks, e.g. TRON:USDT, BSC:USDT, TON:TON

Response — the payment field

FieldMeaning
addressWhere to send.
payAmount, coinHow much to send — exactly this amount: it identifies the payment. It includes the invoice amount, the network fee and a unique tag.
amount, surchargeThe invoice amount in the coin and the network fee.
expiresAtUntil 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.
qrQR image (the address; for TON and EVM — the amount too).
statuswaiting, 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).

Get an invoice

GET/api/merchant/invoice/{code}scope invoices

The current state of the invoice. A fallback option if a webhook is not configured: poll the invoice using this method.

Path parameters

FieldTypeRequiredDescription
codestringrequiredThe invoice code from the creation response

Example request

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

Response

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

Errors

HTTPcodeWhen
404—invoice not found — it doesn't exist or belongs to another application
401—The key was not provided, is unknown, has been reissued, or the application is disabled. Body: {"ok": false, "error": "unknown key"}
403SCOPE_REQUIREDThe key does not have the invoices scope

Cancel an invoice

POST/api/merchant/invoice/{code}/cancelinvoices scope

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

FieldTypeRequiredDescription
codestringrequiredInvoice code

Example request

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

Response

200 response
{
  "ok": true,
  "invoice": { "code": "PRbtixbbdeoy", "status": "CANCELLED", "…": "…" }
}

Errors

HTTPcodeWhen
400—Invoice has status PAID, cannot be cancelled (or EXPIRED)
404—invoice not found
401—The key was not provided, is unknown, has been reissued, or the application is disabled. Body: {"ok": false, "error": "unknown key"}
403SCOPE_REQUIREDThe key does not have the invoices scope

List invoices

GET/api/merchant/invoicesinvoices scope

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

FieldTypeRequiredDescription
limitnumberoptionalA number from 1 to 100, defaults to 20
statusstringoptionalPENDING, PAID, EXPIRED, or CANCELLED. Filters by the value in the database: an expired invoice might still have a PENDING status until it is accessed
cursorstringoptionalcode of the last invoice from the previous page

Example request

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

Response

200 response
{
  "ok": true,
  "invoices": [
    { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
  ],
  "nextCursor": "PRhmfmqcwn2b"
}

Errors

HTTPcodeWhen
400—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"}
403SCOPE_REQUIREDThe 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.

EventWhenWhere
invoice.paidInvoice is paidThe invoice's callbackUrl; if not set during creation, the application's URL at that time
invoice.refundedA refund is issued for an invoiceThe invoice's callbackUrl, otherwise the application's URL
subscription.*Subscription events — see listThe URL from the application settings at the time of the event
testThe owner clicked "Send test webhook" in the dashboard — more detailsThe 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

HeaderValue
x-cxc-timestampThe delivery timestamp in Unix milliseconds. A new value for each attempt
x-cxc-signatureHMAC-SHA256, hex — see below
x-cxc-invoiceInvoice code — for invoice.paid and invoice.refunded
x-cxc-refundYour refund_id — for invoice.refunded
x-cxc-event, x-cxc-event-id, x-cxc-subscriptionEvent name, its ID (evt_…), and the subscription ID — for subscription events
x-cxc-event: test, x-cxc-test: 1Only for the test event — see below

invoice.paid body

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

invoice.refunded body

body
{
  "event": "invoice.refunded",
  "refund": {
    "id": "311",
    "refundId": "order-1024-r1",
    "invoiceCode": "PRbtixbbdeoy",
    "asset": "USDT",
    "network": "TRON",
    "amount": "40",
    "reason": "Item out of stock",
    "createdAt": "2026-09-25T14:00:00.000Z"
  },
  "refundedTotal": "40",
  "refundable": "61.2601",
  "invoice": { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
}

Verifying signatures

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.

  1. Read the request body as raw bytes before parsing the JSON. A parsed and re-serialized JSON will produce a different signature.
  2. Compute the HMAC and compare it with the x-cxc-signature header value using a constant-time comparison function.
  3. 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).
  4. 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);
});

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

  1. Set a PIN code in the OroPay settings — payouts cannot be enabled without it (error PIN_NOT_SET).
  2. In the mini app, open "Shop" → application → "Payouts and Refunds".
  3. Enable "API Payouts" and confirm with your PIN code. The payouts scope will be automatically added to the key.
  4. Set the daily limit and single payout limit.
Action in the dashboardPIN code
Enable payouts, add the scope to a key, increase any limitRequired
Reissue a key or secret, change the webhook URLRequired. After reissuing a key, payouts are disabled — enable them again with a PIN
Disable payouts, remove the scope, decrease a limitNot 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

LimitDefaultWhat is counted
Daily$500Everything 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$100The 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 dayPer 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.

Balance

GET/api/merchant/v1/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").

Example request

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

Response

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

enabled — payouts 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

HTTPcodeWhen
401UNAUTHORIZEDKey not provided, unknown, reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute per application

Create a cheque

POST/api/merchant/v1/chequesscope payouts

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.

Body parameters

FieldTypeRequiredDescription
spend_idstringrequiredYour unique operation ID — see Idempotency
assetstringrequiredCoin: USDT, USDC, TRX, GRAM (or TON), BTC, ETH, LTC, BNB, SOL, XRP
amountstringrequiredAmount for a single activation, greater than zero, with precision no finer than the coin's — see Coins
activationsnumberoptionalThe number of people who can claim the cheque, from 1 to 1000. Defaults to 1
networkstringoptionalThe network, if the asset is available on multiple networks in your account. Not needed for USDT
descriptionstringoptionalA description for the recipient, up to 200 characters
expires_in_daysnumberoptional1, 7, or 30. After this period, the remaining balance is automatically returned. If not specified or 0, the cheque is perpetual
passwordstringoptionalA password for activation, up to 64 characters. Only its hash is stored
new_users_onlybooleanoptionaltrue — only users who registered in OroPay after the cheque was created can claim it
channelstringoptionalThe @username of a public channel or group. Only subscribers can claim the cheque. Our bot must be an administrator there
captchabooleanoptionaltrue — require a CAPTCHA for activation. Only applies to multi-use cheques

Example request

curl -X POST https://oropay.app/api/merchant/v1/cheques \
  -H "Authorization: Bearer $OROPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spend_id":"task-8812","asset":"USDT","amount":"0.5","activations":20,"description":"For subscribing to the channel","expires_in_days":7}'

Response

200 response
{
  "ok": true,
  "repeated": false,
  "cheque": {
    "id": "48213",
    "code": "CQk2m9x0a1b3c",
    "spendId": "task-8812",
    "asset": "USDT",
    "network": "TRON",
    "amount": "0.5",
    "activations": 20,
    "claimed": 0,
    "total": "10",
    "returned": "0",
    "status": "ACTIVE",
    "link": "https://t.me/OroPayBot?start=CQk2m9x0a1b3c",
    "description": "For subscribing to the channel",
    "hasPassword": false,
    "newUsersOnly": false,
    "channel": null,
    "expiresAt": "2026-10-02T12:00:00.000Z",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}
statusMeaning
ACTIVEAwaiting activations
CLAIMEDAll activations have been claimed
CANCELLEDCancelled, the remaining balance has been returned (returned)
EXPIREDExpired

Errors

HTTPcodeWhen
400SPEND_ID_REQUIRED, INVALID_SPEND_IDspend_id is missing or is not 1-64 printable ASCII characters without spaces
400INVALID_ASSET, INVALID_AMOUNT, TOO_PRECISEAsset is not supported; amount is not positive; precision is greater than the asset's allowed precision
400INVALID_ACTIVATIONSactivations is not an integer from 1 to 1000
400INVALID_NETWORK, NETWORK_REQUIREDUnknown network; the asset is available on multiple networks — specify the network
400DESCRIPTION_TOO_LONGDescription is longer than 200 characters
400CHEQUE_PASSWORD_LONG, CHEQUE_CHANNEL_BAD, CHEQUE_CHANNEL_NO_ADMIN, CHEQUE_EXPIRY_BAD, CHEQUE_ACTIVATIONSCheque condition error: password is longer than 64 characters; channel not recognized; bot is not an admin in the channel; expiry is not 1, 7, or 30
400INSUFFICIENT_FUNDSInsufficient funds on the balance
403PAYOUTS_DISABLEDAPI payouts are disabled
403AMOUNT_OVER_LIMIT, DAILY_LIMIT_EXCEEDEDSingle payout limit or daily limit exceeded
403OWNER_FROZENOwner's funds are frozen or PIN recovery is in progress
403SCOPE_REQUIREDThe key does not have the payouts scope
409SPEND_ID_CONFLICTThis spend_id has already been used for another payout
503RATE_UNAVAILABLECoin rate is unavailable — try again later with the same spend_id
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute for the application
500INTERNAL_ERRORAn error on our side; the monetary transaction has been rolled back — try again with the same operation key

List cheques

GET/api/merchant/v1/chequesscope payouts

Only cheques created by this application via the API, newest first.

Query parameters

FieldTypeRequiredDescription
statusstringoptionalACTIVE, CLAIMED, CANCELLED, or EXPIRED
countnumberoptionalFrom 1 to 100, default is 20. You can also use limit
offsetnumberoptionalOffset, default is 0

Example request

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

Response

200 response
{
  "ok": true,
  "cheques": [
    { "id": "48213", "code": "CQk2m9x0a1b3c", "status": "ACTIVE", "…": "…" }
  ]
}

Errors

HTTPcodeWhen
400INVALID_STATUSUnknown status
403SCOPE_REQUIREDThe key does not have the payouts scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute for the application

Cancel a cheque

DELETE/api/merchant/v1/cheques/{id}scope payouts

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.

Path parameters

FieldTypeRequiredDescription
idstringrequiredCheque's id or code

Example request

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

Response

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

Errors

HTTPcodeWhen
404CHEQUE_NOT_FOUNDThe cheque does not exist for this application
409CHEQUE_ALREADY_CLAIMEDThe cheque has been fully claimed — nothing to return
403SCOPE_REQUIREDMissing the payouts scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute for the application

Transfer to a user

POST/api/merchant/v1/transferspayouts scope

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

FieldTypeRequiredDescription
spend_idstringrequiredYour unique operation ID
user_idstring | numberrequiredRecipient's Telegram ID, 4–15 digits
assetstringrequiredThe asset, same as for cheques
amountstringrequiredThe amount, greater than zero, with precision not exceeding the asset's precision
commentstringoptionalShown to the recipient, up to 200 characters
networkstringoptionalThe network, if you hold the asset in multiple networks

Example request

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

Response

200 response
{
  "ok": true,
  "repeated": false,
  "transfer": {
    "id": "9021",
    "spendId": "task-8812-551",
    "userId": "123456789",
    "asset": "USDT",
    "network": "TRON",
    "amount": "1.25",
    "comment": "Task #8812",
    "status": "COMPLETED",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}

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

HTTPcodeWhen
400INVALID_USER_IDuser_id is not 4–15 digits
404USER_NOT_FOUNDThe user has not started the bot — send them a cheque
400CANNOT_PAY_SELFThe recipient is the shop owner
400COMMENT_TOO_LONGComment is longer than 200 characters
400SPEND_ID_REQUIRED, INVALID_SPEND_ID, INVALID_ASSET, INVALID_AMOUNT, TOO_PRECISE, INVALID_NETWORK, NETWORK_REQUIRED, INSUFFICIENT_FUNDSSame as for cheques
400TRANSFER_FAILEDTransfer failed for another reason — see the reason field
403PAYOUTS_DISABLED, AMOUNT_OVER_LIMIT, DAILY_LIMIT_EXCEEDED, OWNER_FROZEN, SCOPE_REQUIREDSame as for cheques
409SPEND_ID_CONFLICTspend_id has already been used with different data
503RATE_UNAVAILABLEAsset rate is unavailable
401UNAUTHORIZEDKey not provided, unknown, reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute for the application
500INTERNAL_ERRORAn error on our side; the monetary transaction has been rolled back — retry with the same operation key

Refund an invoice

POST/api/merchant/v1/invoices/{code}/refundinvoices scope

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

FieldTypeRequiredDescription
codestringrequiredInvoice code

Body parameters

FieldTypeRequiredDescription
refund_idstringrequiredYour unique refund ID. A repeated request will not refund the money twice.
amountstringoptionalThe amount to refund in the payment asset. If not specified, the entire remaining amount is refunded.
reasonstringoptionalThe 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"}'

Response

200 response
{
  "ok": true,
  "repeated": false,
  "refund": {
    "id": "311",
    "refundId": "order-1024-r1",
    "invoiceCode": "PRbtixbbdeoy",
    "asset": "USDT",
    "network": "TRON",
    "amount": "40",
    "reason": "Product is out of stock",
    "createdAt": "2026-09-25T14:00:00.000Z"
  },
  "refundedTotal": "40",
  "refundable": "61.2601",
  "invoice": { "code": "PRbtixbbdeoy", "status": "PAID", "…": "…" }
}
  • 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

HTTPcodeWhen
400REFUND_ID_REQUIRED, INVALID_REFUND_IDMissing or invalid refund_id
400INVALID_AMOUNT, TOO_PRECISEAmount is not positive or has more precision than the asset allows
400REFUND_EXCEEDS_PAIDExceeds available amount; see the available field
400INSUFFICIENT_FUNDSInsufficient funds on the shop's balance
404INVOICE_NOT_FOUNDInvoice not found for this application
409INVOICE_NOT_PAIDInvoice is not paid, nothing to refund
409ALREADY_REFUNDEDInvoice has already been fully refunded; see the refunded field
409REFUND_ID_CONFLICTrefund_id has already been used for another refund
403REFUNDS_DISABLED"API Payouts" are disabled. Enable them in your dashboard using your PIN.
403DAILY_LIMIT_EXCEEDED, OWNER_FROZEN, SCOPE_REQUIREDDaily limit exceeded, owner is frozen, or missing invoices scope
503RATE_UNAVAILABLEAsset rate is unavailable. Retry with the same refund_id.
401UNAUTHORIZEDAPI key not provided, unknown, reissued, or the application is disabled
429RATE_LIMITMore than 60 payout requests per minute for the application
500INTERNAL_ERRORInternal error on our side. The transaction was rolled back; retry with the same idempotency key.

Invoice refunds

GET/api/merchant/v1/invoices/{code}/refundsscope invoices

All refunds for an invoice, in order, showing the total amount refunded and the amount still available for refund.

Path parameters

FieldTypeRequiredDescription
codestringrequiredInvoice code

Example request

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

Response

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

Errors

HTTPcodeWhen
404INVOICE_NOT_FOUNDNo such invoice for this application
403SCOPE_REQUIREDMissing invoices scope
401UNAUTHORIZEDKey not provided, unknown, reissued, or application is disabled
429RATE_LIMITMore 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.

statusAccess (active)Meaning
trialingyesTrial period; the first charge is at nextChargeAt
activeyesPaid until currentPeriodEnd
past_duenoCharge failed; next attempt at nextChargeAt, number of attempts in failedAttempts
cancelleduntil currentPeriodEndCancelled (by user or merchant), no further charges will be made
expirednoExpired: after three failed charges or if the shop is disabled

Create a plan

POST/api/merchant/v1/plansscope subscriptions

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

FieldTypeRequiredDescription
namestringrequiredName, up to 64 characters — visible to the subscriber
assetstringrequiredUSDT or USDC
pricestringrequiredPrice per period, from "0.01" to "100000", with up to 4 decimal places
period_daysnumberrequired7, 30, 90, or 365
trial_daysnumberoptional0 (default), 3, or 7
descriptionstringoptionalWhat the subscription provides, up to 256 characters
plan_idstringoptionalYour 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"}'

Example response

200 response
{
  "ok": true,
  "repeated": false,
  "plan": {
    "code": "SBk3m9x2pq7a",
    "planId": "vip-monthly",
    "name": "VIP channel",
    "description": "Access to a private channel",
    "asset": "USDT",
    "price": "9.99",
    "periodDays": 30,
    "trialDays": 3,
    "active": true,
    "link": "https://t.me/OroPayBot?start=SBk3m9x2pq7a",
    "createdAt": "2026-09-25T12:00:00.000Z"
  }
}

Errors

HTTPcodeWhen
400INVALID_NAME, INVALID_ASSET, INVALID_PRICE, TOO_PRECISE, INVALID_PERIOD, INVALID_TRIAL, INVALID_PLAN_IDError in plan parameters
400TOO_MANY_PLANSAlready 50 plans
409PLAN_ID_CONFLICTplan_id is taken by a plan with different terms
403SCOPE_REQUIREDThe key lacks the subscriptions or invoices scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled
500INTERNAL_ERRORAn internal error on our side

List plans

GET/api/merchant/v1/plansscope subscriptions

All application plans, newest first, including disabled ones.

Example request

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

Example response

200 response
{
  "ok": true,
  "plans": [
    { "code": "SBk3m9x2pq7a", "name": "VIP channel", "active": true, "…": "…" }
  ]
}

Errors

HTTPcodeWhen
403SCOPE_REQUIREDThe key lacks the subscriptions or invoices scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled

Disable or enable a plan

POST/api/merchant/v1/plans/{code}/disablescope subscriptions

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

FieldTypeRequiredDescription
codestringrequiredPlan code SB…

Example request

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

Response

200 response
{
  "ok": true,
  "plan": { "code": "SBk3m9x2pq7a", "active": false, "…": "…" }
}

Errors

HTTPcodeWhen
404PLAN_NOT_FOUNDThis app has no such plan
403SCOPE_REQUIREDThe key does not have the subscriptions or invoices scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the app is disabled

Subscriptions and access check

GET/api/merchant/v1/subscriptionsscope subscriptions

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.

Query parameters

FieldTypeRequiredDescription
user_idstringoptionalUser's Telegram ID, 4–15 digits
planstringoptionalPlan code SB…. Can also be plan_code
statusstringoptionaltrialing, active, past_due, cancelled, or expired
limitnumberoptionalFrom 1 to 100, default is 50. Can also be count
offsetnumberoptionalOffset, default is 0

Example request

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

Response

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

Without user_id, the top-level active field is not present. For a cancelled or expired subscription, nextChargeAt is null.

Errors

HTTPcodeWhen
400INVALID_USER_ID, INVALID_STATUSInvalid filter
403SCOPE_REQUIREDThe key lacks the subscriptions or invoices scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled

Retrieve a subscription

GET/api/merchant/v1/subscriptions/{id}scope subscriptions

Retrieves a subscription by its id.

Path parameters

FieldTypeRequiredDescription
idstringrequiredid of the subscription (a number)

Example request

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

Response

200 response
{
  "ok": true,
  "subscription": { "id": "5021", "status": "active", "active": true, "…": "…" }
}

Errors

HTTPcodeWhen
404SUBSCRIPTION_NOT_FOUNDNo such subscription exists for this application
403SCOPE_REQUIREDThe key lacks the subscriptions or invoices scope
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the application is disabled

Cancel a subscription

POST/api/merchant/v1/subscriptions/{id}/cancelscope subscriptions

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

FieldTypeRequiredDescription
idstringrequiredSubscription id

Example request

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

Response

200 response
{
  "ok": true,
  "repeated": false,
  "subscription": { "id": "5021", "status": "cancelled", "active": true, "cancelledBy": "merchant", "…": "…" }
}

Errors

HTTPcodeWhen
404SUBSCRIPTION_NOT_FOUNDSubscription not found for this app
409SUBSCRIPTION_NOT_ACTIVESubscription is already finished (expired)
403SCOPE_REQUIREDThe key lacks the subscriptions or invoices scope
401UNAUTHORIZEDThe 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.

eventWhen
subscription.createdSubscription created. If there is no trial period, it includes the first charge object.
subscription.renewedThe next period has been charged (with a charge object).
subscription.past_dueA charge failed — sent once when the subscription enters the past_due status.
subscription.expiredSubscription has ended: after three failed payments or if the shop is disabled.
subscription.cancelledCancelled by the user or by you.
subscription.resumedThe user re-subscribed to a cancelled subscription before the paid period ended: the cancellation is reversed, no new charge is made.
body
{
  "id": "evt_88213",
  "event": "subscription.renewed",
  "subscription": { "id": "5021", "status": "active", "active": true, "…": "…" },
  "charge": {
    "id": "9120",
    "asset": "USDT",
    "network": "TRON",
    "amount": "9.99",
    "fee": "0.1249",
    "merchantAmount": "9.8651",
    "periodStart": "2026-10-28T12:00:00.000Z",
    "periodEnd": "2026-11-27T12:00:00.000Z"
  },
  "createdAt": "2026-10-28T12:03:10.000Z"
}

Error codes

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).

HTTPcodeWhen
401UNAUTHORIZEDThe key was not provided, is unknown, has been reissued, or the app is disabled.
403SCOPE_REQUIREDThe key lacks the required scope — enable it in your dashboard.
404METHOD_NOT_FOUNDNo such method under /api/merchant/v1/.
429RATE_LIMITMore than 60 payout requests per minute per app.
500INTERNAL_ERRORAn internal failure occurred; funds were not charged. Retry the operation with the same idempotency key.
503RATE_UNAVAILABLEThe coin's exchange rate is unavailable. Retry later with the same idempotency key.
403PAYOUTS_DISABLEDAPI payouts are disabled.
403DAILY_LIMIT_EXCEEDEDDaily limit exceeded; limitUsd, spentUsd, leftUsd.
403AMOUNT_OVER_LIMITSingle payout amount exceeds the limit; limitUsd, amountUsd.
403OWNER_FROZENThe owner's funds are frozen or a PIN reset is in progress; sometimes includes unlockAt.
400INSUFFICIENT_FUNDSInsufficient funds on the shop's balance.
400SPEND_ID_REQUIRED, INVALID_SPEND_IDspend_id is missing or has an invalid format.
400REFUND_ID_REQUIRED, INVALID_REFUND_IDrefund_id is missing or has an invalid format.
400INVALID_ASSETAsset is not supported.
400INVALID_NETWORK, NETWORK_REQUIREDUnknown network / coin exists on multiple networks — specify network
400INVALID_AMOUNT, TOO_PRECISEAmount is not positive / more decimal places than the coin supports
400INVALID_ACTIVATIONS, DESCRIPTION_TOO_LONGCheque parameters
400CHEQUE_PASSWORD_LONG, CHEQUE_CHANNEL_BAD, CHEQUE_CHANNEL_NO_ADMIN, CHEQUE_EXPIRY_BAD, CHEQUE_ACTIVATIONSCheque conditions
400INVALID_USER_IDTelegram ID is not 4–15 digits
400CANNOT_PAY_SELF, COMMENT_TOO_LONG, TRANSFER_FAILEDTransfer: to self / comment too long / other failure (reason)
400REFUND_EXCEEDS_PAIDRefund amount exceeds available; available
400INVALID_STATUSUnknown status in list filter
404USER_NOT_FOUNDUser has not started the bot — send a cheque
404CHEQUE_NOT_FOUND, INVOICE_NOT_FOUNDNo such cheque or invoice for this app
409SPEND_ID_CONFLICT, REFUND_ID_CONFLICTOperation key has already been used for another operation
409CHEQUE_ALREADY_CLAIMEDCheque has been fully claimed
409INVOICE_NOT_PAID, ALREADY_REFUNDEDInvoice is not paid / already fully refunded (refunded)
400INVALID_NAME, INVALID_PRICE, INVALID_PERIOD, INVALID_TRIAL, INVALID_PLAN_IDPlan parameters
400TOO_MANY_PLANSMore than 50 plans
404PLAN_NOT_FOUND, SUBSCRIPTION_NOT_FOUNDNo such plan or subscription for this app
409PLAN_ID_CONFLICTplan_id is taken by a plan with different conditions
409SUBSCRIPTION_NOT_ACTIVESubscription is already finished

Coins and Networks

Coin (asset)Decimal placesNote
USDT4One shared balance across all networks; network is not needed
USDC4
GRAM (TON)4Both names are accepted, responses use GRAM
TRX5
BTC8
ETH7
LTC6
BNB6
SOL6
XRP6

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.