openapi: 3.0.3 info: title: Quropay API version: "1.0.0" description: | Quropay is a white-label, multi-channel payment gateway API. Merchants create **payin** orders for customers to pay in, and request **payouts** out to their own destination. Four channels are supported: | Channel | Rail | Settlement | |---------|------|------------| | `USDT` (default) | TRON (TRC20) on-chain | Self-hosted checkout page, on-chain match | | `INR` | GalePay | Provider-hosted checkout page | | `BDT` | Wpay | Provider-hosted checkout page | | `PKR` | okpay | Provider-hosted checkout page | **Channel access is granted per-merchant by the platform operator, USDT included** — a new merchant starts with zero channels enabled until the operator explicitly grants some. Ask your account contact which channels your account actually has enabled before integrating one, since calling a channel you don't have access to returns `403 channel_not_enabled`. USDT's request/response shape and behavior are unchanged by any of this — the documentation below only expands on what already existed. For USDT, all money movement is on the TRON network using the official USDT TRC20 contract (`TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`). INR/BDT/PKR are each handled by a single, shared, provider-hosted checkout page (GalePay/Wpay/okpay respectively) — the customer is redirected to the provider's own page, not a Quropay-hosted one, and payment outcome is delivered **only via webhook** (see the Webhooks section below — there is currently no status-polling endpoint for channel orders the way `payin/status.php`/`payin/check.php` exist for USDT). ## Authentication Every endpoint below (except the customer-facing `/api/v1/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 ever reaches the application layer (this is a real, observed behavior, not a hypothetical). If your requests consistently fail with `401 missing_authorization` even though you're sending a correctly-formed bearer header, send the same key via a plain custom header instead — no other change needed: ``` X-Api-Key: key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Your `api_secret` is never sent on API requests — it is used only to verify webhook signatures (see below). Treat it with the same care as a password; if it's ever exposed, regenerate it immediately from your merchant portal (Settings page) or ask an admin to do so. ## Sandbox / test mode Every merchant has two API keys: a live `api_key` and a `sandbox_api_key` (both shown 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 `payment.success`/`channel_payment.success` webhook fires immediately with `"sandbox": true` added to its payload, 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 (money either leaves or it doesn't), 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. ## Errors All errors return a JSON body of the form: ```json { "error": { "code": "invalid_amount", "message": "amount must be a positive number." } } ``` | HTTP 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 `amount` + `fee` (the total that will be debited) | | 400 | `missing_order_id` | `order_id` query parameter is required but missing | | 400 | `transaction_password_not_set` | No transaction password configured yet — set one from the merchant portal Security Center first | | 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 (BDT: `BKASH`/`NAGAD`/`ROCKET`; PKR: `JAZZCASH`/`EASYPAISA`/`SCANCODE`) | | 400 | `invalid_account` | `account` (channel payout) is missing — or, for BDT, doesn't resolve to a valid `01XXXXXXXXX` mobile 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 (e.g. `HDFC0001234`) | | 400 | `invalid_phone` | `phone` (INR/PKR payout) is missing — required by the provider, unlike USDT where it's not collected | | 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 — fetch valid codes via the admin-cached bank list | | 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 for this operation (USDT only — see per-merchant limits below) | | 400 | `amount_above_maximum` | `amount` is above this merchant's configured maximum for this operation (USDT only — see per-merchant limits below) | | 400 | `daily_payin_volume_exceeded` | This request would exceed the merchant's configured daily payin volume limit (USDT only) | | 400 | `daily_payout_volume_exceeded` | This request would exceed the merchant's configured 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 (only enforced once a whitelist has at least one entry) | | 403 | `channel_not_enabled` | This merchant account 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 — see Sandbox / test mode above | | 404 | `order_not_found` | No payin/payout order matches the given id (or it belongs to another merchant) | | 405 | `method_not_allowed` | Wrong HTTP verb for this endpoint | | 422 | `channel_provider_error` | The channel provider (GalePay/Wpay/okpay) rejected the request — message is passed through from the provider where available | | 429 | `rate_limited` | Too many requests — 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` | The operator hasn't configured this channel's provider account yet (admin config page) | | 500 | `internal_error` | Unexpected server-side failure — safe to retry | | 502 | `channel_provider_unreachable` | Couldn't reach the channel provider — safe to retry | servers: - url: https://quropays.com description: Replace with your actual deployment URL security: - BearerAuth: [] - ApiKeyHeader: [] components: securitySchemes: BearerAuth: type: http scheme: bearer description: "`Authorization: Bearer {api_key}`. See the fallback note above if this is stripped by your host." ApiKeyHeader: type: apiKey in: header name: X-Api-Key description: Fallback for hosts that strip the Authorization header. schemas: Error: type: object properties: error: type: object properties: code: { type: string, example: invalid_amount } message: { type: string, example: "amount must be a positive number." } PayinOrder: type: object properties: gateway_order_id: { type: string, example: ord_2680679af87aaaa75474db6d } order_ref: { type: string, example: MERCHANT-ORDER-123 } pay_address: { type: string, example: TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy } expected_amount: { type: string, example: "100.7935" } status: { type: string, enum: [pending, completed, expired, manual_review] } qr_code_url: { type: string, format: uri } payment_page_url: { type: string, format: uri } expires_at: { type: string, example: "2026-07-04 03:16:57" } PayinStatus: allOf: - $ref: '#/components/schemas/PayinOrder' - type: object properties: base_amount: { type: string, example: "100.0000" } tx_hash: { type: string, nullable: true } confirmations: { type: integer } created_at: { type: string } completed_at: { type: string, nullable: true } PayinCheckResult: type: object description: The minimal, unauthenticated status payload polled by the hosted payment page's own JS. properties: gateway_order_id: { type: string } status: { type: string, enum: [pending, completed, expired, manual_review] } expected_amount: { type: string } expires_at: { type: string } PayoutOrder: type: object description: | USDT payout — queued for manual admin approval/signing. "Fee added on top": `amount` is exactly what the beneficiary receives; `fee` is charged in addition, not carved out of it. `total_debit` (`amount` + `fee`) is what's actually deducted from your ledger balance. properties: payout_id: { type: string, example: pout_0257e6e4837c2dd6cc3b595d } amount: { type: string, example: "100.0000", description: What the beneficiary receives — exactly what you requested. } fee: { type: string, example: "8.0000", description: "3% + a flat fee, computed on `amount` (example: 3% + 5 flat on 100 = 8)." } net_amount: { type: string, example: "100.0000", description: "Legacy field, equal to `amount` — kept for backward compatibility." } total_debit: { type: string, example: "108.0000", description: "`amount` + `fee` — what's deducted from your balance." } to_address: { type: string } status: { type: string, enum: [processing, completed, rejected] } created_at: { type: string } ChannelPayinOrder: type: object description: | INR/BDT/PKR payin — note the shape differs from `PayinOrder`: there's no self-hosted `pay_address`/`qr_code_url`, since the customer pays on the provider's own hosted page at `checkout_url`. `pay_type` is `null`/absent for INR (GalePay takes no pay_type), and echoes back whatever was resolved (including the default) for BDT/PKR. properties: channel: { type: string, enum: [INR, BDT, PKR] } out_trade_no: { type: string, example: chn_2b054aa2c12eb5c881bd955, description: This channel's equivalent of gateway_order_id — the identifier webhooks and any future lookups use. } order_ref: { type: string, example: MERCHANT-ORDER-124 } amount: { type: string, example: "150.00" } status: { type: string, enum: [pending, completed, failed], description: "`completed` at creation time itself is possible under AUTO_SUCCESS (see below); otherwise starts `pending`." } checkout_url: { type: string, format: uri, nullable: true, description: "The provider's own hosted checkout page — redirect the customer here directly. `null` for an AUTO_SUCCESS sandbox completion, since there's nothing to redirect to." } created_at: { type: string } ChannelPayoutOrder: type: object description: | INR/BDT/PKR payout — unlike USDT, the provider attempts the transfer immediately (synchronously debiting the channel ledger at request time), rather than queuing for manual admin approval. Final success/failure still arrives asynchronously via webhook; a synchronous `201` here only means the provider *accepted* the request. Same "fee added on top" model as USDT's `PayoutOrder` — see that schema's description. properties: channel: { type: string, enum: [INR, BDT, PKR] } out_trade_no: { type: string, example: chn_9f1c2a0e7b3d4568112a } amount: { type: string, example: "500.00", description: What the beneficiary receives. } fee: { type: string, example: "20.00" } net_amount: { type: string, example: "500.00", description: "Legacy field, equal to `amount` — kept for backward compatibility." } total_debit: { type: string, example: "520.00", description: "`amount` + `fee` — what's deducted from your channel balance." } status: { type: string, enum: [processing, completed, failed, reversed], description: "No `rejected` — that's USDT-only. A provider-side rejection here shows as `failed` (never completed), refunded automatically. `reversed` is different: the provider had already reported this completed, then clawed it back later — also refunded, but from a prior `completed` state rather than `processing`. See `channel_payout.reversed` under Webhooks." } created_at: { type: string } WebhookPayload: type: object description: | The JSON body POSTed to your `webhook_url` on each event. Verify `X-Webhook-Signature` before trusting `data` — see the guide below. The envelope (`event`/`created_at`/`data`) is identical for every event type, USDT or channel; only `data`'s shape varies by event. properties: event: type: string enum: [payment.success, payment.expired, payout.success, payout.rejected, channel_payment.success, channel_payment.failed, channel_payout.success, channel_payout.failed, channel_payout.reversed] created_at: { type: string, format: date-time } data: type: object description: Event-specific fields — see the Webhooks section below for the exact shape per event. responses: Unauthorized: description: Missing/invalid credentials content: application/json: schema: { $ref: '#/components/schemas/Error' } Forbidden: description: Merchant suspended content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: Order not found (or belongs to a different merchant) content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: Too many requests content: application/json: schema: { $ref: '#/components/schemas/Error' } BadRequest: description: Validation error content: application/json: schema: { $ref: '#/components/schemas/Error' } paths: /api/v1/payin/create.php: post: tags: [Payin] summary: Create a payin order description: | `channel` selects which of the 4 supported channels this order is for — omit it (or send `"USDT"`) for the original, unchanged USDT behavior: a unique `expected_amount` (your `amount` plus a random 4-decimal suffix) so this specific payment can be unambiguously matched on-chain, a self-hosted checkout page, and a 1-hour expiry. For `INR`/`BDT`/`PKR`, the response shape is different (see `ChannelPayinOrder` below) — there's no `expected_amount` suffix trick or self-hosted page; the customer pays on the provider's own hosted page at the returned `checkout_url`, and there is **no expiry** enforced by Quropay (the provider's own checkout session may still time out on its side — that's outside Quropay's control). Each channel now also respects the same admin-controlled sandbox modes USDT has long had — set independently per channel, not tied to the merchant's USDT `payment_mode`. Under `AUTO_SUCCESS`, the real provider is never called: the order returns already `completed`, `checkout_url: null`, and the `channel_payment.success` webhook carries `"sandbox": true`. Fields that only apply to specific channels: - `pay_type` — required-with-a-default for `BDT` (`BKASH`/`NAGAD`/`ROCKET`, defaults to `BKASH` if omitted) and `PKR` (`JAZZCASH`/`EASYPAISA`/`SCANCODE`, defaults to `JAZZCASH`). Not used at all for `INR` (GalePay takes no pay_type). - `email` / `phone` — optional for `INR`. If omitted, a plausible placeholder is generated server-side (GalePay's own docs confirm this is acceptable — they don't validate these belong to the real customer). Provide the real customer's values if you have them. **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 rather than assuming gross == credited. **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 (no limit). These are enforced only for USDT payins, not yet for INR/BDT/PKR channel payins, and a `sandbox_api_key` request is always exempt from them. See `amount_below_minimum`/`amount_above_maximum`/ `daily_payin_volume_exceeded` below. security: - BearerAuth: [] - ApiKeyHeader: [] requestBody: required: true content: application/json: schema: type: object required: [amount, order_ref] properties: amount: { type: number, example: 100 } order_ref: { type: string, example: MERCHANT-ORDER-123, maxLength: 100 } channel: { type: string, enum: [USDT, INR, BDT, PKR], default: USDT } pay_type: { type: string, description: "BDT: BKASH/NAGAD/ROCKET. PKR: JAZZCASH/EASYPAISA/SCANCODE. Ignored for USDT/INR." } email: { type: string, description: "INR only, optional — placeholder generated if omitted." } phone: { type: string, description: "INR only, optional — placeholder generated if omitted." } examples: usdt: summary: USDT (default — channel omitted) value: { amount: 100, order_ref: "MERCHANT-ORDER-123" } inr: summary: INR value: { amount: 1500, order_ref: "MERCHANT-ORDER-124", channel: "INR", phone: "9876543210", email: "customer@example.com" } bdt: summary: BDT value: { amount: 500, order_ref: "MERCHANT-ORDER-125", channel: "BDT", pay_type: "BKASH" } pkr: summary: PKR value: { amount: 500, order_ref: "MERCHANT-ORDER-126", channel: "PKR", pay_type: "JAZZCASH" } responses: '201': description: Order created — shape depends on `channel` content: application/json: schema: oneOf: - $ref: '#/components/schemas/PayinOrder' - $ref: '#/components/schemas/ChannelPayinOrder' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/RateLimited' } x-codeSamples: - lang: cURL (USDT) source: | 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"}' - lang: cURL (INR) source: | curl -X POST https://quropays.com/api/v1/payin/create.php \ -H "Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 1500, "order_ref": "MERCHANT-ORDER-124", "channel": "INR", "phone": "9876543210", "email": "customer@example.com"}' - lang: PHP source: | true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 100, 'order_ref' => 'MERCHANT-ORDER-123', ]), ]); $response = json_decode(curl_exec($ch), true); // Redirect your customer to $response['payment_page_url'] /api/v1/payin/status.php: get: tags: [Payin] summary: Check a payin order's status (merchant-authenticated, USDT only) description: | **USDT only.** There is currently no equivalent status-lookup endpoint for INR/BDT/PKR channel payins — use the `channel_payment.success`/ `channel_payment.failed` webhook to learn the outcome of those. security: - BearerAuth: [] - ApiKeyHeader: [] parameters: - name: order_id in: query required: true schema: { type: string } example: ord_2680679af87aaaa75474db6d responses: '200': description: Order status content: application/json: schema: { $ref: '#/components/schemas/PayinStatus' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } /api/v1/payin/check.php: get: tags: [Payin] summary: Lightweight status poll (unauthenticated — used by the hosted payment page, USDT only) description: | **USDT only** — there is no channel-order equivalent of this endpoint. Called directly by the hosted checkout page's own JavaScript every ~9 seconds while the customer is watching, to detect payment without the customer ever holding merchant credentials. `order_id` itself (24 random hex characters) is the capability — treat it as unguessable, not as public. Merchants integrating their own custom checkout UI can poll this too instead of `status.php`, since it needs no API key. security: [] parameters: - name: order_id in: query required: true schema: { type: string } responses: '200': description: Current status content: application/json: schema: { $ref: '#/components/schemas/PayinCheckResult' } '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } /api/v1/payout/create.php: post: tags: [Payout] summary: Request a payout description: | `channel` selects which of the 4 supported channels this payout is for — omit it (or send `"USDT"`) for the original, unchanged USDT behavior: debits your ledger balance immediately (locking the funds) and queues the payout for **manual admin approval/signing**. **Fee is added on top of `amount`, not deducted from it.** `amount` is exactly what the beneficiary receives once approved — `fee` is charged in addition, and `total_debit` (`amount` + `fee`) is what's actually deducted from your ledger balance. `fee` = a percentage of `amount` **plus a flat fee** (both configured per merchant, and per channel/pay-type where an override exists) — e.g. 3% + 5 flat on a 100 USDT payout is an 8 total fee, 108 debited, 100 sent to the beneficiary. Make sure your balance covers `amount` + `fee`, not just `amount` — see `insufficient_balance` below. Wait for `payout.success`/`payout.rejected`. For `INR`/`BDT`/`PKR`, the flow is different: the provider attempts the transfer **immediately** (no manual admin approval step) — a `201` here means the provider *accepted* the request, not that money has arrived. If the provider rejects it outright, the locked amount is refunded automatically within the same request. Final outcome still arrives asynchronously via `channel_payout.success`/ `channel_payout.failed`. Rarely, a provider can reverse/claw back a payout it already reported as completed (the beneficiary's bank rejects it after the fact, etc.) — watch for `channel_payout.reversed` too, not just the two above, if you're tracking payout state from webhooks alone. **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 (converting an INR/BDT/PKR channel balance into a real USDT payout) is a merchant portal feature only — it is not available through this API endpoint.** There is no `channel` value or parameter combination here that triggers it; if your account has cross-currency payout enabled, it's initiated 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 the payin endpoint above can also be set for payouts, independently — see `amount_below_minimum`/`amount_above_maximum`/ `daily_payout_volume_exceeded` below. Not yet enforced for INR/BDT/PKR channel payouts. 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 (merchant portal Security Center) and this call comes from one of those IPs, 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 as before). If a whitelist is configured and the caller's IP is **not** on it, the call is rejected with `ip_not_whitelisted` regardless of whether `transaction_password` was supplied. Fields required differ by channel: - **INR**: `account`, `user_name`, `ifsc` (11 chars, e.g. `HDFC0001234`), `phone` (required — unlike USDT, the provider rejects an empty phone). `pay_out_type` optional, `IMPS` or `UPI`, defaults to `IMPS`. - **BDT**: `account` (the wallet number — accepts `+880`/`880`-prefixed or punctuated input and normalizes it to `01XXXXXXXXX` before sending to Wpay; rejected with `invalid_account` if it doesn't resolve to a valid Bangladeshi mobile number), `user_name`, `pay_type` optional (`BKASH`/`NAGAD`/`ROCKET`, defaults to `BKASH`). - **PKR**: `account`, `user_name`, `bank_code` (fetch valid codes from the admin-cached bank list), `phone` (required). security: - BearerAuth: [] - ApiKeyHeader: [] requestBody: required: true content: application/json: schema: type: object required: [amount] description: "`to_address` is required for USDT. `account`/`user_name` are required for every channel payout; `ifsc`+`phone` additionally for INR, `bank_code`+`phone` additionally for PKR — see the description above. `transaction_password` is required unless the call is authenticated via a whitelisted IP (see the description above)." properties: amount: { type: number, example: 20, description: "What the beneficiary should receive — fee is charged in addition, see the description above." } transaction_password: { type: string, example: "your-transaction-password", description: "Required unless calling from a whitelisted IP — see the description above." } channel: { type: string, enum: [USDT, INR, BDT, PKR], default: USDT } to_address: { type: string, example: TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy, description: USDT only. } account: { type: string, description: "Channel payout only — bank account number (INR/PKR) or wallet number (BDT)." } user_name: { type: string, description: "Channel payout only — beneficiary name." } ifsc: { type: string, example: HDFC0001234, description: INR payout only. } phone: { type: string, description: Required for INR/PKR payout. } pay_out_type: { type: string, enum: [IMPS, UPI], description: "INR payout only, optional, defaults to IMPS." } pay_type: { type: string, enum: [BKASH, NAGAD, ROCKET], description: "BDT payout only, optional, defaults to BKASH." } bank_code: { type: string, description: PKR payout only — from the admin-cached bank list. } examples: usdt: summary: USDT (default — channel omitted) value: { amount: 20, to_address: "TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy", transaction_password: "your-transaction-password" } inr: summary: INR value: { amount: 1000, channel: "INR", account: "0235456782", user_name: "Jane Doe", ifsc: "HDFC0001234", phone: "9876543210", pay_out_type: "IMPS", transaction_password: "your-transaction-password" } bdt: summary: BDT value: { amount: 500, channel: "BDT", account: "01712345678", user_name: "Jane Doe", pay_type: "BKASH", transaction_password: "your-transaction-password" } pkr: summary: PKR value: { amount: 500, channel: "PKR", account: "1234567890123", user_name: "Jane Doe", bank_code: "HBL", phone: "03001234567", transaction_password: "your-transaction-password" } responses: '201': description: Payout accepted — shape and settlement behavior depend on `channel` (see description above) content: application/json: schema: oneOf: - $ref: '#/components/schemas/PayoutOrder' - $ref: '#/components/schemas/ChannelPayoutOrder' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '429': { $ref: '#/components/responses/RateLimited' } x-codeSamples: - lang: cURL (USDT) source: | 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"}' - lang: cURL (INR) source: | curl -X POST https://quropays.com/api/v1/payout/create.php \ -H "Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"amount": 1000, "channel": "INR", "account": "0235456782", "user_name": "Jane Doe", "ifsc": "HDFC0001234", "phone": "9876543210", "transaction_password": "your-transaction-password"}' - lang: PHP source: | true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer key_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 20, 'to_address' => 'TKgghtGSS9k28P9NFGwhsJJznJ1VCqqnCy', 'transaction_password' => 'your-transaction-password', ]), ]); $response = json_decode(curl_exec($ch), true); /webhooks/{your-endpoint}: post: tags: [Webhooks] summary: "(Your server) — receiving gateway webhooks" description: | This isn't an endpoint this gateway exposes — it documents the request *you* will receive at the `webhook_url` you configure in your merchant portal 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) INR/BDT/PKR channels — note there's no `expired` equivalent (channel orders don't carry a Quropay-side expiry the way USDT does), and payouts settle synchronously with the provider rather than via manual admin approval, so `success`/`failed` covers both outcomes for each: - `channel_payment.success` — a channel payin was confirmed paid and credited. `data`: ```json { "channel": "INR", "provider": "galepay", "out_trade_no": "chn_2b054aa2c12eb5c881bd955", "order_ref": "MERCHANT-ORDER-124", "amount": 1500, "pay_amount": 1500, "status": "completed" } ``` Under `AUTO_SUCCESS` sandbox mode, this same event fires instantly with `"sandbox": true` added and no real provider ever contacted — mirrors USDT's own `payment.success` sandbox behavior. - `channel_payment.failed` — the provider reported the payin failed. `data`: ```json { "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. `data`: (`amount` is what the beneficiary received; `total_debit` — `amount` + `fee` — is what left your channel balance) ```json { "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). `data`: ```json { "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`. `data`: ```json { "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= ``` Compute the HMAC using your `api_secret` (shown once at merchant creation, or when you regenerate it) as the key, over the **raw** request body bytes — not a re-serialized/re-indented version of the JSON, which won't match. ```php