Quropay
API v1.0.0 OpenAPI spec (YAML)

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.

Base URL 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": true added, and the tx_hash/provider reference is unmistakably SANDBOX_-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 with 403 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 live api_key for 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." } }
StatusCodeMeaning
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.

POST /api/v1/payin/create.php
Payin

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.

This is USDT's default behavior (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.
Fee on payin. Unlike payout, there's no separate fee field in the response — the amounts you see (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.
Per-merchant limits (USDT only). An admin can set a minimum amount, a maximum amount, and/or a maximum daily payin volume on your account — nullable, so any of them may be unset. Enforced only for USDT payins, not yet for INR/BDT/PKR channel payins, and a 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"
}
GET /api/v1/payin/status.php?order_id=...
Payin · USDT only

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.

GET /api/v1/payin/check.php?order_id=...
Payin · unauthenticated · USDT only

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.

POST /api/v1/payout/create.php
Payout

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

Requires your transaction password (separate from your login password, set from the merchant portal Security Center) on every call, for every channel — unless you've configured a payout IP whitelist (also in Security Center) and this call comes from a whitelisted IP, in which case 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.
This is USDT's default behavior (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.
Not available to sandbox_api_key. A payout request authenticated with your sandbox key is rejected outright with 403 sandbox_key_not_allowed, for USDT and every channel — see Sandbox & test mode above. Use your live api_key.
Cross-currency payout is portal-only. Converting an INR/BDT/PKR channel balance into a real USDT payout is a merchant portal feature — there is no 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.
Per-merchant limits (USDT only). The same minimum/maximum amount and maximum daily volume limits described on Create payin order above can also be set for payouts, independently. Not yet enforced for INR/BDT/PKR channel payouts. See Error format for 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.

Channel access is granted per-merchant by the platform operator. A newly created merchant account starts with USDT only — INR/BDT/PKR must be explicitly enabled for your account by an admin before you can use them. Calling a channel you don't have access to returns 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 endpointpayin/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)
Full request/response examples: Create payin order, Create payout. Channel-specific error codes: Error format.
POST {your webhook_url}
Your server

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 credited
  • payment.expired — a payin order's 1-hour window closed unpaid
  • payout.success — an admin-approved payout was signed and broadcast
  • payout.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 (amount is what the beneficiary received, total_debit is 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_debit has been refunded to your channel balance by the time this arrives, same as channel_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
Retries. A non-200 response is retried up to 5 times total, on a backoff schedule of 5, 15, 60, then 240 minutes after the previous attempt (driven by a cron sweep, not held open in a single request). Make your handler idempotent — the same event may arrive more than once if your endpoint was briefly down.

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.
Receiving webhooks in PHP? See Webhooks & signatures above — the raw-body HMAC verification snippet there is the complete PHP receiver, not repeated here to avoid the two copies drifting out of sync.

Node.js / JavaScript Integration

JS

Create 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. 1

    Get your api_key and api_secret from the API Credentials page in your merchant portal. Same credentials work for every channel.

  2. 2

    Call Create payin order with an amount and your own order_ref (USDT — no channel field needed).

  3. 3

    Redirect your customer to the returned payment_page_url — a hosted checkout page handles the rest.

  4. 4

    Set a webhook_url on 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. 5

    Set a transaction password from Security Center, then call Create payout whenever you need to move funds to your own TRON wallet.

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