PlatinaPay Merchant API
Accept USDT, USDC and eight more coins across ten networks: ETH, BNB, POL, SOL, BTC, LTC, TRX and GRAM. Fast integration, real-time blockchain confirmation, automatic webhook notifications.
https://platinapay.orgAll API requests use HTTPS. HTTP requests are redirected.
How it works
Authentication
Every API request must include your api_key in the Authorization header:
Authorization: Bearer YOUR_API_KEY
You receive your api_key and api_secret after your merchant account is approved by the platform administrator. Find them in your Merchant Dashboard → API Keys.
Supported Currencies
PlatinaPay accepts payments in fiat currencies and converts them into the coin the payer chooses, at the current market rate.
| Code | Currency | Min Amount | Rate Source | Notes |
|---|---|---|---|---|
RUB |
Russian Ruble | 1 ₽ | CoinMarketCap (live, updated every 60s) | Default. Rate markup may apply per merchant. |
USD |
US Dollar | 1 $ | CoinMarketCap (live, ≈1.00) | No rate markup. USDT ≈ USD. |
"currency": "RUB" or "currency": "USD" in the request body.
If omitted, defaults to RUB. The amount field is always in the specified fiat currency — the system calculates the USDT equivalent automatically.
Rate calculation
// Example: 1000 RUB order at rate 83.21 RUB/USDT actual_usdt = 1000 / 83.21 = 12.0178 USDT // Example: 50 USD order at rate ~1.00 USD/USDT actual_usdt = 50 / 1.00 = 50.0000 USDT // The rate_used field in the response shows the exact rate applied. // Max order: 10,000 USDT equivalent (configurable by platform admin).
Networks & Coins
Ten coins across ten networks, twenty-eight combinations. Coin and network are fixed when the order is created: either you name them, or the payer picks them on the hosted checkout page.
| Network | network value | Coins | Confirmations | Wait |
|---|---|---|---|---|
| TRON | tron / trc20 | USDT, TRX | 19 | ~57 s |
| TON | ton | USDT, GRAM | 1 | seconds |
| BNB Smart Chain | bsc / bep20 | USDT, USDC, BNB, BTC, LTC, SOL | 15 | ~7 s |
| Polygon PoS | polygon | USDT, USDC, POL | 30 | ~45 s |
| Arbitrum One | arbitrum | USDT, USDC, ETH | 20 | ~5 s |
| Base | base | USDT, USDC, ETH | 20 | ~40 s |
| Ethereum | ethereum / erc20 | USDT, USDC, ETH, TRX | 12 | ~2 min 24 s |
| Solana | solana / sol | USDT, USDC, SOL | 32 | ~10 s |
| Bitcoin | bitcoin / btc | BTC | 2 | ~20 min |
| Litecoin | litecoin / ltc | LTC | 6 | ~15 min |
The network field accepts either a chain id (bsc, tron) or the name used on exchange withdrawal screens (bep20, trc20, erc20), in any case, and so does the coin ticker for networks named after their coin (sol, btc, ltc). Omit the network and the order is created on TRON; omit the coin and it is USDT. USDT on TRON is also the one combination whose webhook keeps the original nine fields.
Bitcoin and Litecoin are the exception to waits measured in seconds: a payment there needs tens of minutes to reach its confirmations. Size any timeout on your side for the network the order is on, not for TRON.
GET /api/v1/networks rather than hardcoding it — the endpoint is public and needs no signature.Current list
{
"status_code": 200,
"data": [
{
"network": "bsc",
"network_name": "BNB Smart Chain",
"standard": "BEP-20",
"coin": "USDT",
"contract": "0x55d398326f99059fF775485246999027B3197955",
"decimals": 18,
"confirmations": 15,
"confirmation_seconds": 6,
"payment_window_seconds": 3600
},
{
"network": "bsc",
"network_name": "BNB Smart Chain",
"standard": "",
"coin": "BNB",
"contract": "",
"decimals": 18,
"confirmations": 15,
"confirmation_seconds": 6,
"payment_window_seconds": 3600
}
]
}
What comes back is the intersection of three things: what the gateway supports, what it has an enabled wallet for, and what you accept — see Payment Settings.
Request Signing (HMAC-SHA256)
Every POST request body must include a sign field. This prevents request tampering and proves your identity.
Collect fields
Take all request body fields except sign, and leave out any whose value is empty. Numbers are signed in their shortest form: 1000.00 is 1000.
Sort alphabetically
Sort field names by ASCII value (A-Z, a-z).
Concatenate
Join as key1=value1&key2=value2&...
HMAC-SHA256
Compute HMAC-SHA256 of the string using your api_secret as the key. The result (lowercase hex) is your sign value.
Signing example
Given these fields and api_secret = "abc123secret":
// Input fields (excluding "sign"):
order_id = "ORDER-001"
amount = 1000
currency = "RUB"
notify_url = "https://example.com/callback"
// Step 2: Sort by key name
amount, currency, notify_url, order_id
// Step 3: Concatenate
"amount=1000¤cy=RUB¬ify_url=https://example.com/callback&order_id=ORDER-001"
// Step 4: HMAC-SHA256 with api_secret
sign = hmac_sha256("amount=1000¤cy=RUB¬ify_url=https://example.com/callback&order_id=ORDER-001", "abc123secret")
// → "e5f3a1b2c4d6..."
Integration Flow
Create an order
Call POST /api/v1/orders/create from your backend with the payment amount and your webhook URL.
Redirect the user
Redirect the user to the payment_url from the response. They'll see a checkout page with a QR code, wallet address, and countdown timer.
Receive the webhook
When payment is confirmed on the blockchain, we POST to your notify_url. Verify the sign field and respond with HTTP 200.
Fulfill the order
Mark the order as paid in your system. The user is automatically redirected to your redirect_url.
Create Order
Create a new payment order. Returns a payment URL to redirect the user to.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| order_id | string | required | Your unique order identifier (max 100 chars) |
| amount | number | required | Payment amount in fiat currency (min 1 RUB / 1 USD) |
| currency | string | optional | "RUB" (default) or "USD". See Currencies. |
| network | string | optional | Network, as a chain id or its exchange name: tron/trc20, bsc/bep20, solana/sol and so on. Defaults to TRON. See Networks & Coins |
| coin | string | optional | "USDT" (default) |
| notify_url | string | optional | Webhook URL for payment notifications (HTTPS required) |
| redirect_url | string | optional | URL to redirect user after payment |
| sign | string | required | HMAC-SHA256 signature |
Response
| Field | Type | Description |
|---|---|---|
| trade_id | string | PlatinaPay unique payment ID |
| order_id | string | Your order ID (echoed back) |
| amount | number | Original fiat amount |
| actual_amount | number | Amount to pay in the chosen coin. The number of decimals depends on the coin: eight for BTC; six for ETH, BNB, POL, SOL and LTC; four for USDT, USDC, GRAM and TRX. Parse it as a number, not a string |
| currency | string | Currency code |
| rate_used | number | Exchange rate applied (fiat/USDT) |
| token | string | Wallet address to pay to, on the chosen network |
| network | string | The order's network: tron, ton, bsc, polygon, arbitrum, base, ethereum, solana, bitcoin, litecoin |
| coin | string | The order's coin |
| expiration_time | integer | Unix timestamp when order expires |
| payment_url | string | Redirect user here to pay |
Code examples
curl -X POST https://platinapay.org/api/v1/orders/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "ORDER-001",
"amount": 1000.00,
"currency": "RUB",
"notify_url": "https://yoursite.com/webhook",
"redirect_url": "https://yoursite.com/success",
"sign": "COMPUTED_HMAC_SHA256_HEX"
}'
Response example
{
"status_code": 200,
"message": "success",
"data": {
"trade_id": "20260322a1b2c3d4e5f67890",
"order_id": "ORDER-001",
"amount": 1000.00,
"actual_amount": 12.075800,
"currency": "RUB",
"rate_used": 82.81,
"token": "TN4JsVEyUBMcBjJbRGTriAPBDMjZaxnMet",
"expiration_time": 1774131385,
"payment_url": "https://platinapay.org/pay/checkout-counter/20260322a1b2c3d4e5f67890"
},
"request_id": "req_abc123"
}
Order with payer-chosen network
Creates an order whose coin and network the payer picks on our hosted checkout page. Use it when you would rather not maintain a network list of your own.
Takes the same fields as Create Order except network and coin — the point is that they are not yet known.
{
"status_code": 200,
"data": {
"trade_id": "20260322a1b2c3d4e5f67890",
"order_id": "ORDER-001",
"amount": 1000.00,
"currency": "RUB",
"expiration_time": 1774131385,
"payment_url": "https://platinapay.org/pay/checkout-counter/20260322a1b2c3d4e5f67890"
}
}
No token, actual_amount or rate_used: nothing about the payment can be quoted until the payer picks an asset. The order sits at status 4.
expiration_time here is the deadline for choosing, not for paying. The payment window starts when the payer chooses, and its length depends on the asset.From there it is the usual flow: send the payer to payment_url and learn the outcome from the webhook or by polling Query Order. If no asset is currently receivable the endpoint returns 10010 rather than sending the payer to a page with nothing to pick.
Cancel Order
Cancel a pending (unpaid) order. Only orders with status 0 (Awaiting) can be cancelled.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| trade_id | string | required | PlatinaPay trade ID from create response |
| sign | string | required | HMAC-SHA256 signature |
curl -X POST https://platinapay.org/api/v1/orders/cancel \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"trade_id": "20260322a1b2c3d4e5f67890", "sign": "HMAC_HEX"}'
Query Order
Retrieve the current status and details of an order.
curl https://platinapay.org/api/v1/orders/query/20260322a1b2c3d4e5f67890 \ -H "Authorization: Bearer YOUR_API_KEY"
Response example
{
"status_code": 200,
"data": {
"trade_id": "20260322a1b2c3d4e5f67890",
"order_id": "ORDER-001",
"amount": 1000.00,
"actual_amount": 12.0758,
"currency": "RUB",
"rate_used": 82.81,
"token": "TN4JsVEyUBMcBjJbRGTriAPBDMjZaxnMet",
"status": 1,
"block_transaction_id": "7f3a2b1c...",
"callback_status": 1,
"commission": 0.2415,
"net_amount": 11.8343,
"created_at": "2026-03-22T12:00:00Z",
"paid_at": "2026-03-22T12:03:45Z",
"expiration_time": null,
"callback_payload": { "..." }
}
}
Payment Status (Polling)
Lightweight endpoint to check if a payment has been received. Useful for frontend polling while the user is on the checkout page. No authentication required.
// Response
{"status_code": 200, "data": {"status": 1}}
Webhook Callback
When a payment is confirmed on the blockchain, PlatinaPay sends a POST request to your notify_url with the payment details.
network and coin.The body is signed, so its field set is part of the contract. A handler that hardcodes the nine field names will sign nine while the gateway signed eleven: every such callback then fails verification, the payment reads as unpaid, and the money is already on the wallet. A handler that signs whatever arrived needs no change — the examples below are written that way.
Want one shape on every network? Pin version 2 in payment settings and network and coin arrive on USDT-on-TRON orders too.
Callback payload
Sent as application/x-www-form-urlencoded, so every value arrives as a string. One line on the wire, split here for reading:
trade_id=20260322a1b2c3d4e5f67890 &order_id=ORDER-001 &amount=1000.00 &actual_amount=12.0758 ¤cy=RUB &rate_used=82.81 &token=TN4JsVEyUBMcBjJbRGTriAPBDMjZaxnMet &block_transaction_id=7f3a2b1c4d5e6f7890abcdef... &status=1 &sign=a3f2b1c4d5e6f7890123456789abcdef...
| Field | Type | Description |
|---|---|---|
| trade_id | string | PlatinaPay payment ID |
| order_id | string | Your order ID |
| amount | number | Original fiat amount |
| actual_amount | number | Amount received, in the order’s coin. Decimals depend on the coin: eight for BTC; six for ETH, BNB, POL, SOL and LTC; four for USDT, USDC, GRAM and TRX |
| currency | string | Currency code |
| rate_used | number | Exchange rate used |
| token | string | Wallet address the payment arrived at |
| block_transaction_id | string | Blockchain transaction hash |
| status | integer | Always 1 (Paid) |
| network | string | Chain id, e.g. ton or bsc. Not sent for USDT on TRON |
| coin | string | Coin: USDT, USDC, ETH, BNB, POL, GRAM, SOL, BTC, LTC, TRX. Not sent for USDT on TRON |
| sign | string | HMAC-SHA256 signature — verify this! |
network and coin, and the test webhook from the cabinet adds is_test, which is never signed. Verifying against a fixed list produces a different signature and rejects a valid webhook. The same applies to any field added later.Verifying Webhook Signatures
Always verify the sign field in webhook callbacks to ensure the request is authentic.
# Flask example
from flask import Flask, request
import hmac, hashlib
app = Flask(__name__)
API_SECRET = "your_api_secret"
def verify_sign(fields: dict, secret: str) -> bool:
received = fields.get("sign", "")
# Every field that arrived except sign and is_test, skipping empty ones
signed = {k: v for k, v in fields.items() if k not in ("sign", "is_test") and v != ""}
param_str = "&".join(f"{k}={v}" for k, v in sorted(signed.items()))
expected = hmac.new(secret.encode(), param_str.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), received.encode())
@app.route("/webhook", methods=["POST"])
def webhook():
data = request.form.to_dict() # the webhook is form-urlencoded, not JSON
if not verify_sign(data, API_SECRET):
return "Invalid signature", 403
# ✅ Mark the order paid in your database, once per trade_id
print(f"Payment confirmed: order {data['order_id']}, trade {data['trade_id']}")
return "ok", 200 # Must return 200!
Retry Policy
If your server doesn't respond with HTTP 200, we retry with exponential backoff:
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1st | Immediate | 0 min |
| 2nd | 1 minute | 1 min |
| 3rd | 2 minutes | 3 min |
| 4th | 5 minutes | 8 min |
| 5th | 10 minutes | 18 min |
| 6th | 15 minutes | 33 min |
| 7th (final) | 30 minutes | ~63 min |
After 7 failed attempts, the callback is marked as failed. You can still query the order status via the API. The platform administrator can also trigger a new retry cycle from the admin panel.
Payment Settings
Which coins and networks you accept, and which callback shape you receive. Set in the merchant cabinet under Payment settings, or through the API.
| Field | Type | Description |
|---|---|---|
| available | array | Everything the gateway can currently receive. The menu, not your choice |
| allowed_assets | array | What you accept, as "USDT.bsc" strings. An empty list means everything, not nothing |
| callback_version | integer | 0 — shape follows the order's network (default), 1 — always the nine v1 fields, 2 — always with network and coin |
PUT /merchant/api/payment-settings
{
"allowed_assets": ["USDT.tron", "USDT.ton", "USDT.bsc", "USDC.bsc"],
"callback_version": 2
}
allowed_assets means "accept everything", including networks added later. Want a fixed set? List it explicitly.Restricting the list narrows both what GET /api/v1/networks returns for your orders and what the hosted checkout offers a payer. An entry naming an asset the gateway cannot currently receive is kept rather than rejected — it simply matches nothing until a wallet for it exists.
Pinned callback_version: 1 and still need the network? It is in the Query Order response — network and coin are reported there on every network, because that response is not signed.
Order & Callback Statuses
Order status
| Value | Name | Description |
|---|---|---|
| 0 | Awaiting | Network chosen, address assigned, waiting for payment |
| 1 | Paid | Payment confirmed on the blockchain |
| 2 | Expired | Order expired (payment window is 1 hour) |
| 3 | Cancelled | Cancelled by merchant via API |
| 4 | Awaiting choice | The payer has not picked a coin and network yet, so there is no address or amount. /orders/checkout orders only |
Callback status
| Value | Name | Description |
|---|---|---|
| 0 | Pending | Not yet attempted |
| 1 | Delivered | Merchant returned HTTP 200 |
| 2 | Retrying | Delivery in progress (retry cycle) |
| 3 | Failed | All 7 attempts exhausted |
Error Codes
| Code | Name | Description |
|---|---|---|
10001 | INVALID_REQUEST | Missing or invalid request parameters |
10002 | UNAUTHORIZED | Invalid API key or signature |
10003 | MERCHANT_SUSPENDED | Your account has been suspended |
10004 | ORDER_ALREADY_EXISTS | Duplicate order_id for this merchant |
10005 | PAY_AMOUNT_TOO_SMALL | Amount below minimum (1 RUB / 1 USD) |
10006 | PAY_AMOUNT_TOO_LARGE | Amount exceeds maximum (10,000 USDT equivalent) |
10007 | RATE_NOT_AVAILABLE | Exchange rate temporarily unavailable |
10008 | ORDER_NOT_CANCELLABLE | Order is not in Awaiting status |
10009 | NOT_AVAILABLE_AMOUNT | No payment slot available (all amounts taken) |
10010 | NOT_AVAILABLE_WALLET | No wallet address available |
10011 | INSUFFICIENT_BALANCE | Not enough balance for withdrawal |
10012 | ORDER_NOT_FOUND | Order doesn't exist or belongs to another merchant |
10013 | ACCOUNT_PENDING | Account pending admin approval |
10014 | ACCOUNT_REJECTED | Account registration was rejected |
10015 | TWO_FACTOR_REQUIRED | 2FA verification needed |
10016 | UNSUPPORTED_NETWORK | Unknown value in network — take values from /api/v1/networks |
10017 | NETWORK_UNAVAILABLE | The network is temporarily unavailable — offer another or retry later |
Error response format
{
"status_code": 10004,
"message": "order_id already exists for this merchant",
"data": null,
"request_id": "req_abc123"
}
Rate Limits
| Endpoint | Limit | Scope |
|---|---|---|
POST /api/v1/orders/create | 100 req/min | Per API key |
POST /api/v1/orders/create | 1,000 req/min | Global (all merchants) |
All /api/v1/* endpoints | 300 req/min | Per API key |
GET /pay/status/* | 60 req/min | Per IP address |
When rate limited, the API returns HTTP 429 (Too Many Requests).
Best Practices
Always verify webhook signatures
Use constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js) to prevent timing attacks.
Make your webhook handler idempotent
We may send the same callback multiple times. Check if the order is already fulfilled before processing. Use trade_id as the idempotency key.
Respond to webhooks quickly
Return HTTP 200 within 30 seconds. Do heavy processing asynchronously after acknowledging.
Use unique order_ids
Each order_id must be unique per merchant. Reusing an order_id returns error 10004.
Handle order expiration
The payer has 5 minutes to choose a coin and network, then 1 hour to send. If an hour is not enough, the payment page carries a button that adds another 30 minutes without re-quoting the amount. An order lives at most 4 hours in total.
Keep your API secret secure
Store it in environment variables or a secrets manager. Rotate via the merchant dashboard if compromised.