API Reference
Quropay is a white-label, multi-channel payment gateway API — USDT (TRON/TRC20), plus INR, BDT,
and PKR via local payment channels. Merchants create payin orders for customers
to pay in, and request payouts out to their own destination. USDT money movement
is on the TRON network using the official USDT TRC20 contract
TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t;
INR/BDT/PKR are each handled by a provider-hosted checkout page. See
Multi-Channel Payments
below — channel access is granted per-merchant, so a new account starts with USDT only.
https://quropays.com
Channels
USDT (TRC20) · INR · BDT · PKR
Auth method
Bearer token
Sandbox & test mode
Every merchant has two API keys: a live api_key and a
sandbox_api_key (both on the API Credentials page in your
merchant portal). Authenticate with sandbox_api_key instead
of api_key to use sandbox mode — no other change needed,
and it behaves the same way across USDT and every channel (INR/BDT/PKR).
-
Payin (
payin/create.php): the order is created and completes instantly, server-side — no real provider or on-chain detection is ever contacted. The webhook fires immediately with"sandbox": trueadded, and the tx_hash/provider reference is unmistakablySANDBOX_-prefixed. Your ledger balance is never credited — the completion is real enough to test your webhook handling end-to-end, but it adds no spendable funds, live or otherwise. -
Payout (
payout/create.php): rejected outright with403 sandbox_key_not_allowed, for USDT and every channel — a payout has no meaningful fake completion to simulate, so sandbox keys can't create one at all. Use your liveapi_keyfor payouts.
This is separate from AUTO_SUCCESS, an operator-side
sandbox mode an admin can enable per merchant (USDT) or per channel, independently of which API
key you use. AUTO_SUCCESS produces the same instant
SANDBOX_-tagged completion and webhook shape as a
sandbox_api_key payin — but unlike
sandbox_api_key, it does credit your ledger
(tagged is_sandbox, so your dashboard behaves like
production), though that sandbox credit still can't be spent on a real payout. In short:
sandbox_api_key is the sandbox switch you control
from your own side; AUTO_SUCCESS is one your operator
controls from theirs, and the two differ specifically in whether the ledger moves.
Separately again: this installation's TRON connectivity points at one network only, configured
by the operator (TRON_NETWORK in
.env) — either mainnet (real funds) or Shasta testnet.
That's an installation-wide setting, unrelated to your per-merchant sandbox key; ask your
gateway operator which network this installation runs on if you're unsure.
Error format
All errors return a JSON body of the form:
{ "error": { "code": "invalid_amount", "message": "amount must be a positive number." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | Request body was not valid JSON |
| 400 | invalid_amount | `amount` missing, non-numeric, or not positive |
| 400 | invalid_order_ref | `order_ref` missing or over 100 characters |
| 400 | invalid_to_address | `to_address` is not a syntactically valid TRON address (USDT payout) |
| 400 | insufficient_balance | Merchant's ledger balance (USDT or the requested channel) can't cover the requested payout amount |
| 400 | missing_order_id | `order_id` query parameter is required but missing |
| 400 | transaction_password_not_set | No transaction password configured yet — set one in the merchant portal Security Center |
| 400 | invalid_channel | `channel` isn't one of USDT, INR, BDT, PKR |
| 400 | invalid_pay_type | `pay_type` (payin) isn't one of that channel's allowed values |
| 400 | invalid_account | `account` (channel payout) is missing — or, for BDT, doesn't resolve to a valid 01XXXXXXXXX number after normalization |
| 400 | invalid_user_name | `user_name` (channel payout beneficiary name) is missing |
| 400 | invalid_ifsc | `ifsc` (INR payout) isn't 11 characters in the correct format |
| 400 | invalid_phone | `phone` (INR/PKR payout) is missing — required by the provider |
| 400 | invalid_pay_out_type | `pay_out_type` (INR payout) isn't IMPS or UPI |
| 400 | invalid_bank_code | `bank_code` (PKR payout) is missing |
| 400 | channel_not_yet_available | The requested channel is planned but not yet available on this installation |
| 400 | amount_below_minimum | `amount` is below this merchant's configured minimum (USDT only) |
| 400 | amount_above_maximum | `amount` is above this merchant's configured maximum (USDT only) |
| 400 | daily_payin_volume_exceeded | This request would exceed the merchant's daily payin volume limit (USDT only) |
| 400 | daily_payout_volume_exceeded | This request would exceed the merchant's daily payout volume limit (USDT only) |
| 401 | missing_authorization | No usable Authorization or X-Api-Key header found |
| 401 | invalid_api_key | The supplied API key doesn't match any merchant |
| 401 | invalid_transaction_password | `transaction_password` is missing or incorrect |
| 403 | merchant_suspended | This merchant account is currently suspended by the operator |
| 403 | ip_not_whitelisted | Caller's IP isn't on the merchant's payout IP whitelist |
| 403 | channel_not_enabled | This merchant doesn't have the requested channel enabled — ask your account contact |
| 403 | sandbox_key_not_allowed | Payouts can't be created with sandbox_api_key, for USDT or any channel |
| 404 | order_not_found | No payin/payout order matches the given id |
| 405 | method_not_allowed | Wrong HTTP verb for this endpoint |
| 422 | channel_provider_error | The channel provider rejected the request — message passed through where available |
| 429 | rate_limited | 20/min for payin creation, 5/min for payout creation |
| 500 | wallet_not_configured | Operator hasn't set a receiving wallet address yet (USDT) |
| 500 | channel_not_configured | Operator hasn't configured this channel's provider account yet |
| 500 | internal_error | Unexpected server-side failure — safe to retry |
| 502 | channel_provider_unreachable | Couldn't reach the channel provider — safe to retry |
Authentication
Every endpoint below (except the customer-facing payin/check.php)
requires your api_key, issued when your merchant account was
created. Send it as a bearer token:
Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Fallback header
Some Apache/FastCGI shared-hosting configurations strip the Authorization
header before it reaches the application layer — a real, observed behavior. If requests
consistently fail with 401 missing_authorization even with a
correctly-formed bearer header, send the same key via a plain custom header instead:
X-Api-Key: key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Your api_secret is never sent on API requests — it is used
only to verify webhook signatures. Treat it like a password; if it's ever exposed, regenerate it
immediately from the API Credentials page in your merchant portal, or ask an admin to do so.
/api/v1/payin/create.php
Generates a unique expected_amount (your
amount plus a random 4-decimal suffix) so this specific
payment can be unambiguously matched on-chain, even if many customers are paying the same
base amount at once. The order expires after 1 hour.
channel omitted, or
sent as "USDT"). The same endpoint also creates INR/BDT/PKR
payins with a different request/response shape — see
Multi-Channel Payments below.
expected_amount
for USDT, amount/pay_amount
for channels) are the gross amount the customer actually pays. A percentage
fee (configured per merchant, and per channel/pay-type where an override exists) is deducted
separately when the payment is credited, so what lands in your spendable ledger balance is
less than the gross figure in the response/webhook — check your merchant portal ledger for
the net credited amount.
sandbox_api_key
request is always exempt. See Error format
for amount_below_minimum/amount_above_maximum/daily_payin_volume_exceeded.
Request body
{ "amount": 100, "order_ref": "MERCHANT-ORDER-123" }
cURL
curl -X POST https://quropays.com/api/v1/payin/create.php \
-H "Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"amount": 100, "order_ref": "MERCHANT-ORDER-123"}'
201 response
{
"gateway_order_id": "ord_2680679af87aaaa75474db6d",
"order_ref": "MERCHANT-ORDER-123",
"pay_address": "TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy",
"expected_amount": "100.7935",
"status": "pending",
"qr_code_url": "https://...",
"payment_page_url": "https://.../pay/?order_id=ord_2680679af87aaaa75474db6d",
"expires_at": "2026-07-04 03:16:57"
}
/api/v1/payin/status.php?order_id=...
Merchant-authenticated status lookup — same auth as every other endpoint above. Returns the full order record, including base_amount, tx_hash, and confirmations.
USDT only — there's no equivalent status-lookup endpoint for INR/BDT/PKR channel payins yet. Use the channel_payment.success/channel_payment.failed webhook to learn those outcomes.
/api/v1/payin/check.php?order_id=...
Called directly by the hosted checkout page's own JavaScript every ~9 seconds while the
customer is watching, so the customer never needs to hold merchant credentials.
order_id itself (24 random hex characters) is the
capability — treat it as unguessable, not as public. Merchants building a custom checkout UI
can poll this too, since it needs no API key. Returns a minimal subset:
gateway_order_id, status,
expected_amount, expires_at.
USDT only — there's no channel-order equivalent of this endpoint.
/api/v1/payout/create.php
Debits your ledger balance immediately (locking the funds) and queues the payout for admin
approval. Fee is added on top of amount, not
deducted from it — amount is exactly what
arrives on-chain once approved, and total_debit
(amount + fee)
is what's actually deducted from your balance. Make sure your balance covers
amount + fee, not
just amount. Wait for the
payout.success / payout.rejected
webhook, or poll your merchant portal, to learn the outcome.
transaction_password
is not required at all: api_key/api_secret
+ the whitelisted IP is treated as sufficient authentication, so your own backend can call this
endpoint unattended. An empty whitelist means unrestricted (no IP check,
transaction_password stays required). If a whitelist is
configured and the caller's IP isn't on it, the call is rejected regardless of whether
transaction_password was supplied.
channel omitted, or
sent as "USDT") — queued for manual admin approval. The
same endpoint also sends INR/BDT/PKR payouts, where the provider attempts the transfer
immediately instead (no manual approval step) — different required fields and
a different response shape. See
Multi-Channel Payments below.
403 sandbox_key_not_allowed, for USDT and every
channel — see Sandbox & test mode
above. Use your live api_key.
channel value or parameter combination on this API
endpoint that triggers it. If your account has cross-currency payout enabled, initiate it
from the merchant portal's payout page, not programmatically.
amount_below_minimum/amount_above_maximum/daily_payout_volume_exceeded.
Request body
{
"amount": 20,
"to_address": "TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy",
"transaction_password": "your-transaction-password"
}
cURL
curl -X POST https://quropays.com/api/v1/payout/create.php \
-H "Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"amount": 20, "to_address": "TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy", "transaction_password": "your-transaction-password"}'
Multi-Channel Payments — Overview
Beyond USDT, Quropay supports three local payment channels: INR (via GalePay),
BDT (via Wpay), and PKR (via okpay). They use the exact same two
endpoints as USDT — Create payin order
and Create payout — selected
with a channel field in the request body. There is no separate
base URL or credential type for channels; your existing api_key
works for all of them.
403 channel_not_enabled. Ask your account contact which
channels your account actually has before integrating one.
The biggest behavioral difference from USDT: channel payins/payouts have no status-polling
endpoint — payin/status.php and
payin/check.php are USDT-only. The
webhook is the only way to
learn a channel order's outcome, so a configured webhook_url
is effectively required if you use any channel beyond USDT.
Channel Reference
Every field not listed here is either shared across all channels (amount on both endpoints, order_ref on payin, transaction_password on payout) or doesn't apply to that channel.
| Channel | Required fields (payin) | Required fields (payout) | Pay-types available |
|---|---|---|---|
| USDT | amount, order_ref | amount, to_address, transaction_password | — |
| INR | amount, order_ref (phone, email optional — placeholder generated if omitted) | amount, account, user_name, ifsc, phone, transaction_password (pay_out_type optional, default IMPS) | IMPS, UPI (payout method only — INR payin takes no pay_type) |
| BDT | amount, order_ref (pay_type optional, default BKASH) | amount, account, user_name, transaction_password (pay_type optional, default BKASH) | BKASH, NAGAD, ROCKET |
| PKR | amount, order_ref (pay_type optional, default JAZZCASH) | amount, account, user_name, bank_code, phone, transaction_password | JAZZCASH, EASYPAISA, SCANCODE (payin only — payout is bank-transfer via bank_code, no pay_type) |
{your webhook_url}
This isn't an endpoint the gateway exposes — it documents the request you
will receive at the webhook_url configured on your
Profile Settings page.
Events — USDT
payment.success— a payin order was matched and creditedpayment.expired— a payin order's 1-hour window closed unpaidpayout.success— an admin-approved payout was signed and broadcastpayout.rejected— an admin rejected a payout (already refunded to your ledger)
Events — INR / BDT / PKR channels
No expired equivalent — channel orders carry no
Quropay-side expiry. Channel payouts settle synchronously with the provider rather than via
manual admin approval, so success/failed
covers the full outcome for each.
-
channel_payment.success— a channel payin was confirmed paid and credited{ "channel": "INR", "provider": "galepay", "out_trade_no": "chn_2b054aa2c12eb5c881bd955", "order_ref": "MERCHANT-ORDER-124", "amount": 1500, "pay_amount": 1500, "status": "completed" } -
channel_payment.failed— the provider reported the payin failed{ "channel": "INR", "provider": "galepay", "out_trade_no": "chn_2b054aa2c12eb5c881bd955", "order_ref": "MERCHANT-ORDER-124", "amount": 1500, "status": "failed" } -
channel_payout.success— the provider confirmed the payout completed (amountis what the beneficiary received,total_debitis what left your channel balance){ "channel": "INR", "provider": "galepay", "out_trade_no": "chn_9f1c2a0e7b3d4568112a", "amount": 1000, "net_amount": 1000, "total_debit": 1050, "status": "completed" } -
channel_payout.failed— the provider reported the payout failed (the locked amount was already refunded to your channel ledger by the time this arrives){ "channel": "INR", "provider": "galepay", "out_trade_no": "chn_9f1c2a0e7b3d4568112a", "amount": 1000, "status": "failed" } -
channel_payout.reversed— rare: the provider had already told us this payout completed, then later reversed/clawed it back (the beneficiary evidently never actually received the funds).total_debithas been refunded to your channel balance by the time this arrives, same aschannel_payout.failed{ "channel": "INR", "provider": "galepay", "out_trade_no": "chn_9f1c2a0e7b3d4568112a", "amount": 1000, "total_debit": 1050, "status": "reversed" }
Verifying the signature
Every delivery includes:
X-Webhook-Event: payment.success
X-Webhook-Signature: sha256=<hex-encoded HMAC-SHA256 of the raw request body>
Compute the HMAC using your api_secret as the key,
over the raw request body bytes — not a re-serialized/re-indented version
of the JSON, which won't match.
<?php
$body = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $yourApiSecret);
if (!hash_equals($expected, $signatureHeader)) {
http_response_code(401);
exit('bad signature');
}
$payload = json_decode($body, true);
// $payload['event'], $payload['data'] ...
http_response_code(200); // anything else triggers a retry
PHP SDK Example
PHP
A minimal reusable client — wrap your api_key once,
then call createPayin() /
checkPayinStatus() from anywhere in your codebase.
<?php
class UsdtGatewayClient
{
public function __construct(
private string $baseUrl, // e.g. https://quropays.com
private string $apiKey
) {}
public function createPayin(float $amount, string $orderRef): array
{
return $this->request('POST', '/api/v1/payin/create.php', [
'amount' => $amount,
'order_ref' => $orderRef,
]);
}
public function checkPayinStatus(string $orderId): array
{
return $this->request('GET', '/api/v1/payin/status.php?order_id=' . urlencode($orderId));
}
// Same endpoint, a "channel" field selects INR/BDT/PKR instead of USDT.
// Response has no payment_page_url/qr_code_url — redirect to checkout_url
// instead, and rely on the channel_payment.* webhook for the outcome
// (there's no channel equivalent of checkPayinStatus() above).
public function createChannelPayin(string $channel, float $amount, string $orderRef, array $extra = []): array
{
return $this->request('POST', '/api/v1/payin/create.php', array_merge([
'channel' => $channel,
'amount' => $amount,
'order_ref' => $orderRef,
], $extra));
}
public function createPayout(float $amount, string $toAddress, string $transactionPassword): array
{
return $this->request('POST', '/api/v1/payout/create.php', [
'amount' => $amount,
'to_address' => $toAddress,
'transaction_password' => $transactionPassword,
]);
}
private function request(string $method, string $path, ?array $body = null): array
{
$ch = curl_init($this->baseUrl . $path);
$headers = ['Authorization: Bearer ' . $this->apiKey];
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response;
}
}
// Usage — USDT
$client = new UsdtGatewayClient('https://quropays.com', 'key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
$order = $client->createPayin(100, 'MERCHANT-ORDER-123');
header('Location: ' . $order['payment_page_url']); // send the customer to the hosted checkout
exit;
An INR payin, using the same client (requires the INR channel to be enabled on your account —
see Multi-Channel Payments).
Note the redirect target is checkout_url, not
payment_page_url — that field doesn't exist on a
channel payin's response:
// Usage — INR
$order = $client->createChannelPayin('INR', 1500, 'MERCHANT-ORDER-124', [
'phone' => '9876543210', // optional — a placeholder is generated if omitted
'email' => 'customer@example.com',
]);
header('Location: ' . $order['checkout_url']); // GalePay's own hosted page, not ours
exit;
// Outcome arrives later via the channel_payment.success / channel_payment.failed webhook.
Node.js / JavaScript Integration
JSCreate a payin order (fetch)
Node 18+ has fetch built in — an axios.post(...) call with the same headers/body works identically if that's what your project already uses.
async function createPayin(amount, orderRef) {
const res = await fetch('https://quropays.com/api/v1/payin/create.php', {
method: 'POST',
headers: {
'Authorization': 'Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount, order_ref: orderRef }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
return res.json(); // { payment_page_url, gateway_order_id, expected_amount, ... }
}
Create an INR payin (fetch)
Same endpoint, a channel field — see
Multi-Channel Payments
for the other channels and their required fields. The response shape differs from USDT's:
redirect to checkout_url (the provider's own hosted
page), and there's no status-polling endpoint for channel orders — listen for the
channel_payment.success/channel_payment.failed
webhook instead.
async function createInrPayin(amount, orderRef, phone, email) {
const res = await fetch('https://quropays.com/api/v1/payin/create.php', {
method: 'POST',
headers: {
'Authorization': 'Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel: 'INR',
amount,
order_ref: orderRef,
phone, // optional — a placeholder is generated if omitted
email, // optional — a placeholder is generated if omitted
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
return res.json(); // { channel, out_trade_no, order_ref, amount, status, checkout_url, created_at }
}
Express webhook receiver + HMAC verification
The signature is computed over the raw request body bytes, so this route
must read the raw body before any JSON body-parser touches it —
express.raw(), scoped to just this route, does that.
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/webhooks/quropay',
express.raw({ type: 'application/json' }), // raw Buffer, not parsed JSON
(req, res) => {
const signatureHeader = req.get('X-Webhook-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.USDT_GATEWAY_API_SECRET)
.update(req.body) // raw Buffer — must match byte-for-byte
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))) {
return res.status(401).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// payload.event, payload.data ...
res.sendStatus(200); // anything else triggers a retry
}
);
React: poll status & render the QR code
Building your own checkout UI instead of the hosted payment page? Poll
payin/check.php — it needs no API key, since
order_id itself is the capability (see
Poll payin (public) above).
function PaymentStatus({ orderId, qrCodeUrl, expectedAmount }) {
const [status, setStatus] = React.useState('pending');
React.useEffect(() => {
if (status !== 'pending') return;
const poll = async () => {
const res = await fetch(
`https://quropays.com/api/v1/payin/check.php?order_id=${orderId}`
);
const data = await res.json();
setStatus(data.status);
};
poll();
const id = setInterval(poll, 9000);
return () => clearInterval(id);
}, [status, orderId]);
if (status === 'completed') return <p>Payment received!</p>;
return (
<div>
<img src={qrCodeUrl} alt="Payment QR code" />
<p>Send exactly {expectedAmount} USDT · status: {status}</p>
</div>
);
}
Quickstart
This walkthrough uses USDT, the default channel — it's the one every merchant
account has from day one. Integrating INR, BDT, or PKR instead? The steps are the same shape
(get credentials → create a payin → redirect the customer → handle the webhook), just with a
channel field and different required fields — see
Multi-Channel Payments
for the specifics once you've got the USDT flow working end to end.
-
1
Get your
api_keyandapi_secretfrom the API Credentials page in your merchant portal. Same credentials work for every channel. -
2
Call Create payin order with an
amountand your ownorder_ref(USDT — nochannelfield needed). -
3
Redirect your customer to the returned
payment_page_url— a hosted checkout page handles the rest. -
4
Set a
webhook_urlon your Profile Settings page and verify its signature — see Webhooks & signatures above. Required if you use any channel beyond USDT, since those have no status-polling endpoint. -
5
Set a transaction password from Security Center, then call Create payout whenever you need to move funds to your own TRON wallet.
-
6
Need INR, BDT, or PKR? Confirm the channel is enabled on your account, then read Multi-Channel Payments for the field differences and a worked INR example.