UroPay Merchant API
The UroPay Merchant API lets you create payment orders and pull transaction reports server-to-server. Every request must be signed with HMAC-SHA256 - see Authentication.
Quickstart
POST /v1/orders with a unique tenantOrderRef, amount, and currency.openUrl in the customer's browser to complete payment.GET /v1/orders/{orderId} or listen for the order status webhook (courtesy notification, not authoritative).Authentication
All requests require four headers: your API key, a timestamp, a nonce, and an HMAC-SHA256 signature computed over a canonical string.
YOUR_API_KEYX-Timestamp:
<unix-seconds>X-Nonce:
<client-generated-uuid>X-Signature:
<hex-hmac-sha256, 64 chars>
| Header | Required | Description |
|---|---|---|
X-Api-Key |
required | Your merchant API key |
X-Timestamp |
required | Unix timestamp in seconds, at request-send time |
X-Nonce |
required | Client-generated UUID, unique per request. Reusing a nonce is rejected. |
X-Signature |
required | Hex-encoded HMAC-SHA256 of the canonical string below, using your secret |
Canonical string
Join these fields with newlines, in this exact order:
${method}\n${path}\n${timestamp}\n${nonce}\n${queryString}\n${rawBody}
| Field | Description |
|---|---|
method | HTTP method, e.g. POST |
path | URL pathname only, e.g. /v1/orders - never includes the query string |
timestamp | Same value as the X-Timestamp header |
nonce | Same value as the X-Nonce header |
queryString | Raw query string with no leading ?; empty string if none |
rawBody | Raw request body bytes; empty string for GET requests with no body |
Signature: HMAC-SHA256(canonicalString, tenantSecret), hex-encoded.
401 if now - timestamp > 300 seconds (too old) or timestamp - now > 30 seconds (too far in the future). Each nonce may be used once per merchant. Never send an unsigned request or reuse a nonce.
Signing a request
const crypto = require('crypto'); function signRequest(method, path, query, body, secret) { const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = crypto.randomUUID(); const canonical = [method, path, timestamp, nonce, query, body].join('\n'); const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex'); return { 'X-Api-Key': apiKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature }; } // path is the URL pathname only (e.g. '/v1/orders'), query has no leading '?', body is the raw JSON string
import hmac, hashlib, time, uuid def sign_request(method, path, query, body, secret): timestamp = str(int(time.time())) nonce = str(uuid.uuid4()) canonical = '\n'.join([method, path, timestamp, nonce, query, body]) signature = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() return {'X-Api-Key': api_key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature}
function sign_request($method, $path, $query, $body, $secret) { $timestamp = (string) time(); $nonce = bin2hex(random_bytes(16)); $canonical = implode("\n", [$method, $path, $timestamp, $nonce, $query, $body]); $signature = hash_hmac('sha256', $canonical, $secret); return ['X-Api-Key: ' . $apiKey, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature]; }
Environments
You have two separate API key/secret pairs - one for TEST, one for PRODUCTION. There is no environment header or request parameter: whichever key you sign a request with determines the environment for that request. TEST and PRODUCTION orders are fully isolated (separate order IDs, separate GET lookups, separate payment gateway credentials) - a TEST key can never see or affect a PRODUCTION order and vice versa.
PRODUCTION requests are rejected with 403 until your account has passed KYC review (or explicitly opted out of review under the flat-commission track). TEST requests have no such requirement, so integrate and test end-to-end before requesting PRODUCTION access.
The order status webhook payload includes an environment field ("TEST" or "PRODUCTION") confirming which environment created the order; the GET/POST /v1/orders responses don't repeat it since you already know which key you signed the request with.
Testing Payments
Sign a request with your TEST key, create an order (see Create Order), open the returned openUrl in a browser, and complete the payment on the hosted checkout page using the test instruments below. No real money moves for a TEST order.
In TEST, UroPay routes each order to one of several upstream payment gateways. The hosted checkout page shows which one you landed on - use the matching section below. You only ever enter these values on that page; you never call the upstream gateway yourself.
PRODUCTION-only. TEST orders are never routed to Zoho Payments. Its values below apply only once your account is live and a PRODUCTION order happens to route to Zoho; they are listed for completeness.
These are the upstream gateways' own published sandbox values and can change without notice - each gateway's own documentation is authoritative.
Cashfree
Test cards - expiry 03/2028, CVV 123, cardholder name Test, OTP 111000 for every card.
| Scheme | Type | Sub-type | Card number |
|---|---|---|---|
| Visa | Debit | Retail | 4706131211212123 |
| Visa | Credit | Retail | 4576238912771450 |
| Visa | Credit | Premium | 4466050254381183 |
| Visa | Credit | Corporate | 4074970084343075 |
| Mastercard | Debit | Retail | 5409162669381034 |
| Mastercard | Credit | Retail | 5105105105105100 |
| Mastercard | Credit | Premium | 5242535837492075 |
| Mastercard | Credit | Corporate | 5552190758372734 |
| RuPay | Debit | Retail | 6074825972083818 |
| RuPay | Credit | Retail | 6528591234543575 |
| Test UPI VPA | Result |
|---|---|
testsuccess@gocash | Success |
testfailure@gocash | Failure |
testinvalid@gocash | Invalid VPA |
Net banking: bank TEST Bank, payment code 3333.
Wallet: a single generic test wallet (no provider choice).
Pay Later / Cardless EMI: mobile 8714268343; if a PAN is asked use 1234, if an OTP is asked use 777777. Cardless EMI minimum amount 1000.
Razorpay
Test mode shows a mock bank page with Success and Failure buttons. Use any random CVV and any future expiry. On the card OTP page, an OTP of 4-10 digits succeeds and an OTP under 4 digits fails.
| Network | Card number | Type |
|---|---|---|
| Visa | 4100 2800 0000 1007 | Debit / Consumer |
| Mastercard | 5555 5100 0008 1006 | Credit / Business |
| Mastercard | 5180 2872 0009 1001 | Prepaid / Consumer |
| RuPay | 6527 6589 0000 1005 | Credit / Consumer |
| Diners | 3608 2800 0910 07 | Credit / Consumer |
| Amex | 3402 5600 0401 007 | Credit / Consumer |
International cards: Mastercard 5555 5555 5555 4444, Visa 4012 8888 8888 1881, Mastercard 5105 1051 0510 5100.
Subscription cards: domestic Visa 4718 6091 0820 4366; international Mastercard 5104 0155 5555 5558 (credit) / 5104 0600 0000 0008 (debit).
EMI card: Mastercard 5241 8100 0000 0000.
Error-scenario cards: Razorpay publishes a Visa 4100 2800 000X 000X / Mastercard 5305 6200 000X 000X series whose trailing digits select the failure reason (insufficient_fund, card_declined, payment_timed_out, ...); see Razorpay's test card docs.
| Test UPI VPA | Result |
|---|---|
success@razorpay | Success |
failure@razorpay | Failure |
In test mode a UPI payment cancellation is recorded as success. With failure@razorpay, specific paise amounts trigger specific UPI error codes (204 incorrect PIN, 205 PIN not set, ...); see Razorpay's UPI error-code docs.
PayU
Use any cardholder name. The character X in a card number is any digit 1-9.
| Card number | Network | Expiry | CVV | OTP |
|---|---|---|---|---|
5123456789012346 | Mastercard | 05/30 | 123 | 123456 |
4012001037141112 | Visa | 05/30 | 123 | 123456 |
6082015309577308 | RuPay | 05/30 | 123 | 123456 |
370295061673669 | Amex | 03/30 | 1234 | 725356 |
5497774415170603 | Mastercard (server-to-server) | 05/30 | 412 | 123456 |
5118 7000 0000 0003 | Mastercard (debit) | 05/30 | 123 | 123456 |
4594 5380 5063 9999 | Visa (debit) | 05/30 | 123 | 123456 |
International / DCC: USD 4755964453587236 (CVV 596), EUR 4020419926566936 (CVV 041), both expiry 12/2030, OTP 725356.
Saved / tokenized cards: Mastercard 5506900480000008, Visa 4895370077346937; expiry 05/2030, CVV 123, OTP 123456.
UPI: VPA anything@payu or 999999999@payu (sandbox only).
Net banking: username payu, password payu, OTP 123456.
Wallet: Paytm wallet - mobile 7777777777, OTP 888888.
BNPL: LazyPay bankcode=LAZYPAY, mobile 9123412345; HDFC bankcode=HDFCF15|HDFCF30|HDFCF60|HDFCF90, mobile 9123412345, card 4234567890056334.
EMI cards: e.g. HDFC CC EMI 4453341065876437, ICICI CC EMI 4808557848741463, Axis DC EMI 4011510000000007 (expiry 05/30, OTP 123456, mobile mandatory); full per-bank list on PayU's docs.
Zoho Payments
Applies to PRODUCTION only - see the note above. CVV any 3-digit number, expiry any future date.
| Network | Card number |
|---|---|
| Visa | 4111 1111 1111 1111 |
| Mastercard | 5299 9202 1000 0277 |
| RuPay | 6071 4898 7654 3212 |
| Corporate card | 4000 1800 0000 0002 |
Cardholder name drives the outcome - name Failure gives an authentication failure; any other name succeeds.
UPI: the payment amount selects the outcome - any amount up to ₹500 succeeds (₹222 / ₹333 / ₹444 simulate the RuPay-credit-card / wallet / credit-line rails), ₹501-₹1000 fails. Refund: up to ₹300 succeeds, ₹301-₹500 fails.
Net banking: Success Test Bank succeeds, Failure Test Bank fails, Refund Failure Test Bank fails on refund.
Paytm
Card and net-banking payments redirect to a Paytm page with Success and Failure buttons.
Card: any Visa or Mastercard number, any future expiry, CVV 123, OTP 489871.
Wallet: mobile 7777777777, password Paytm12345, OTP 489871.
Net banking: pick any bank from the list on the Paytm page, then choose Success or Failure.
UPI: collect - VPA 7777777777@paytm, then approve in Paytm's test UPI app; intent - use Paytm's test UPI app.
Errors
All responses use the envelope { code, status, message, data }. Error responses omit data and may include a details array for validation errors.
| Status | When it occurs | Sample response |
|---|---|---|
| 400 | Malformed JSON body, invalid query parameters, invalid cursor, or (single-object payment link request) validation/business-rule failure | {"code": 400, "status": "error", "message": "Malformed JSON body"} |
| 401 | Missing/malformed/expired/replayed signature, invalid API key, or reused nonce | {"code": 401, "status": "error", "message": "Signature verification failed"} |
| 403 | PRODUCTION API key used before your account has passed KYC review |
{"code": 403, "status": "error", "message": "Production access requires KYC approval"} |
| 404 | Order or payment link not found (including ones belonging to a different merchant or environment) | {"code": 404, "status": "error", "message": "Order not found"} |
| 409 | tenantOrderRef already exists with a different payload than the original request, or a payment link is CANCELLED and cannot be suspended |
{"code": 409, "status": "error", "message": "..."} |
| 500 | Internal server error | {"code": 500, "status": "error", "message": "Internal server error"} |
Create a payment order. tenantOrderRef is your own idempotency key - retry-safe only when the payload matches the original call.
Request
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
X-Api-Key | header | string | required | Your merchant API key |
X-Timestamp | header | string | required | Unix seconds |
X-Nonce | header | string | required | Unique UUID per request |
X-Signature | header | string | required | Hex HMAC-SHA256 of the canonical string |
tenantOrderRef | body | string | required | Your own idempotency key. Same key + same payload returns the same order. |
amount | body | number | required | Order amount. PRODUCTION mode enforces a minimum - see Minimum order amount below. No minimum applies in TEST mode. |
currency | body | string | required | Defaults to INR |
paymentMethods | body | array | optional | Restrict checkout to: upi, card, netbanking, wallet, paylater, emi. Omitted or empty = all methods shown. |
customerEmail | body | string | optional | Customer's email |
customerPhone | body | string | optional | Customer's phone |
metaData | body | object | optional | Key-value strings, at most 5 keys, 100 chars each. Forwarded best-effort to the payment gateway; returned on this order's GET response and webhook notifications. A tenant_id key, if supplied, is ignored and replaced with your account's own merchant ID - it cannot be used to spoof merchant attribution. |
returnUrl | body | string | optional | HTTPS-only. Overrides your account's default return URL for this order. |
webhookUrl | body | string | optional | HTTPS-only. Overrides your account's default webhook URL for this order. |
Minimum order amount
A PRODUCTION order must be large enough to cover your commission, the flat per-transaction fee and the GST charged on both, and still settle a positive amount to you. An order below that threshold is rejected with 400 and a message naming your current minimum, for example:
{
"code": 400,
"status": "error",
"message": "Minimum order amount is INR 8. The order value must cover the commission, the per-transaction fee and tax."
}The threshold is derived, not fixed, so it moves with your own terms:
minimum = ceil( (transaction_fee * (1 + gst_rate) + 1) / (1 - commission_rate * (1 + gst_rate)) )
Your commission rate and per-transaction fee are shown in your dashboard, and your commission rate changes when your KYC is approved - so read the minimum from the error message rather than hardcoding a figure. TEST-mode orders are exempt: they never settle, so any positive amount is accepted there.
Response
201 - a new order was created. 200 - tenantOrderRef was replayed with a payload matching the original call; the existing order is returned, not re-created. 409 - tenantOrderRef was replayed with a different payload. 400 - malformed JSON, failed validation, or an amount below the minimum order amount.
| Field | Type | Description |
|---|---|---|
data.id | string | Order identifier |
data.tenantOrderRef | string | Echoed back |
data.status | string | PENDING, PAID, FAILED, EXPIRED, or CANCELLED |
data.statusReason | string | Why the order reached its status: USER_DROPPED, USER_CANCELLED, PG_CANCELLED, CHECKOUT_CANCEL_BUTTON, LIFETIME_EXPIRED, or PG_EXPIRED. Present only when the order carries one. |
data.amount | number | |
data.currency | string | |
data.checkoutType | string | Always "redirect" |
data.openUrl | string | Open in the customer's browser to complete payment. Omitted once the order reaches a terminal status (PAID, FAILED, EXPIRED, CANCELLED). |
data.metaData | object | Merchant-supplied key-value metadata, present only when set at order creation. |
data.createdAt | string (ISO 8601) |
metaData is echoed back on the order response and webhook notification. If you didn't supply any (or sent an empty object), it defaults to { "tenantOrderRef": "<your tenantOrderRef>" } on the webhook only - the GET response omits the field entirely in that case. A tenant_id key is always the platform's own value, never merchant-supplied.
Code examples
const path = '/v1/orders'; const body = JSON.stringify({ tenantOrderRef: 'order-123', amount: 500, currency: 'INR' }); const headers = { ...signRequest('POST', path, '', body, secret), 'Content-Type': 'application/json' }; const response = await fetch('https://api.uropai.in' + path, { method: 'POST', headers, body }); const data = await response.json();
import requests path = '/v1/orders' body = '{"tenantOrderRef":"order-123","amount":500,"currency":"INR"}' headers = {**sign_request('POST', path, '', body, secret), 'Content-Type': 'application/json'} response = requests.post('https://api.uropai.in' + path, data=body, headers=headers) data = response.json()
$path = '/v1/orders'; $body = json_encode(['tenantOrderRef' => 'order-123', 'amount' => 500, 'currency' => 'INR']); $headers = array_merge(sign_request('POST', $path, '', $body, $secret), ['Content-Type: application/json']); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl -X POST https://api.uropai.in/v1/orders \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>" \ -H "Content-Type: application/json" \ -d '{"tenantOrderRef":"order-123","amount":500,"currency":"INR"}'
Sample response
{
"code": 201,
"status": "success",
"message": "Order created",
"data": {
"id": "URPY-ALPHA-123456",
"tenantOrderRef": "order-123",
"status": "PENDING",
"amount": 500,
"currency": "INR",
"checkoutType": "redirect",
"openUrl": "https://api.uropai.in/checkout/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"createdAt": "2026-07-17T10:30:00.000Z"
}
}Get an order's current status. orderId is the id value returned by Create Order.
Request
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
X-Api-Key | header | string | required | Your merchant API key |
X-Timestamp | header | string | required | Unix seconds |
X-Nonce | header | string | required | Unique UUID per request |
X-Signature | header | string | required | Hex HMAC-SHA256 of the canonical string |
orderId | path | string | required | Order identifier returned by Create Order |
Response
200 found. 404 not found - including orders belonging to a different merchant or environment. Never 403: existence across merchants is never confirmed. Same response shape as Create Order's data.
Code examples
const path = '/v1/orders/URPY-ALPHA-123456'; const headers = signRequest('GET', path, '', '', secret); const response = await fetch('https://api.uropai.in' + path, { headers }); const data = await response.json();
import requests path = '/v1/orders/URPY-ALPHA-123456' headers = sign_request('GET', path, '', '', secret) response = requests.get('https://api.uropai.in' + path, headers=headers) data = response.json()
$path = '/v1/orders/URPY-ALPHA-123456'; $headers = sign_request('GET', $path, '', '', $secret); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl https://api.uropai.in/v1/orders/URPY-ALPHA-123456 \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>"
Sample response
{
"code": 200,
"status": "success",
"message": "Order found",
"data": {
"id": "URPY-ALPHA-123456",
"tenantOrderRef": "order-123",
"status": "PAID",
"amount": 500,
"currency": "INR",
"checkoutType": "redirect",
"createdAt": "2026-07-17T10:30:00.000Z"
}
}openUrl is only present while the order is still open for checkout (e.g. PENDING) - it's omitted once the order reaches a terminal status (PAID, FAILED, EXPIRED, CANCELLED).
Mobile App Integration
A native mobile app uses the same flow as any server integration - POST /v1/orders, open openUrl, then poll GET /v1/orders/{orderId} - with two mobile-specific rules.
Run checkout in the system browser
Open openUrl in the device's system browser (Safari on iOS, Chrome on Android), never in an in-app WebView.
UPI apps (PhonePe, Paytm, Google Pay, ...) are launched by UPI deep-link intents that the checkout page fires. An embedded WebView (WKWebView, android.webkit.WebView, webview_flutter, React Native WebView) does not dispatch those intents, so the UPI app never opens and the customer cannot pay by UPI. Only the OS browser fires UPI intents.
iOS: SFSafariViewController, ASWebAuthenticationSession, or UIApplication.open(_:) (external Safari).
Android: Chrome Custom Tabs (androidx.browser) or an ACTION_VIEW intent to the default browser.
Set returnUrl (or your account default) to a custom URL scheme or App Link / Universal Link so the browser returns control to your app after payment.
Track payment status from the app
The redirect back to your app is a UX signal, not a settlement signal - treat it like the webhook: advisory only. Confirm the real status by polling until the order reaches a terminal state (PAID, FAILED, EXPIRED, CANCELLED).
Poll your own backend. Your server receives the order status webhook and your app polls a status endpoint you expose. Preferred - the HMAC secret stays on your server.
Poll UroPay directly. The app calls GET /v1/orders/{orderId} on a background timer with backoff.
Polling snippets
Each snippet signs GET /v1/orders/{orderId} inline (canonical string GET\n/v1/orders/{orderId}\n{timestamp}\n{nonce}\n\n) and loops with backoff until the status is no longer PENDING.
Kotlin (Android)
import kotlinx.coroutines.*
import okhttp3.*
import org.json.JSONObject
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
val client = OkHttpClient()
const val BASE = "https://api.uropai.in"
fun sign(method: String, path: String, secret: String): Headers {
val ts = (System.currentTimeMillis() / 1000).toString()
val nonce = UUID.randomUUID().toString()
val canonical = listOf(method, path, ts, nonce, "", "").joinToString("\n")
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
val sig = mac.doFinal(canonical.toByteArray()).joinToString("") { "%02x".format(it.toInt() and 0xFF) }
return Headers.headersOf("X-Api-Key", apiKey, "X-Timestamp", ts, "X-Nonce", nonce, "X-Signature", sig)
}
suspend fun pollOrder(orderId: String): String = withContext(Dispatchers.IO) {
val path = "/v1/orders/$orderId"
var delayMs = 2000L
while (true) {
val req = Request.Builder().url(BASE + path).headers(sign("GET", path, secret)).build()
val body = client.newCall(req).execute().use { it.body!!.string() }
val status = JSONObject(body).getJSONObject("data").getString("status")
if (status != "PENDING") return@withContext status
delay(delayMs)
delayMs = (delayMs * 3 / 2).coerceAtMost(15000L)
}
}Swift (iOS)
import Foundation
import CryptoKit
let base = "https://api.uropai.in"
func sign(method: String, path: String, secret: String) -> [String: String] {
let ts = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
let canonical = [method, path, ts, nonce, "", ""].joined(separator: "\n")
let key = SymmetricKey(data: Data(secret.utf8))
let mac = HMAC<SHA256>.authenticationCode(for: Data(canonical.utf8), using: key)
let sig = mac.map { String(format: "%02x", $0) }.joined()
return ["X-Api-Key": apiKey, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig]
}
func pollOrder(_ orderId: String) async throws -> String {
let path = "/v1/orders/\(orderId)"
var delay: UInt64 = 2_000_000_000
while true {
var req = URLRequest(url: URL(string: base + path)!)
for (k, v) in sign(method: "GET", path: path, secret: secret) { req.setValue(v, forHTTPHeaderField: k) }
let (data, _) = try await URLSession.shared.data(for: req)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let status = (json["data"] as! [String: Any])["status"] as! String
if status != "PENDING" { return status }
try await Task.sleep(nanoseconds: delay)
delay = min(delay * 3 / 2, 15_000_000_000)
}
}Flutter (Dart)
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
const base = 'https://api.uropai.in';
Map<String, String> sign(String method, String path, String secret) {
final ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
final nonce = const Uuid().v4();
final canonical = [method, path, ts, nonce, '', ''].join('\n');
final sig = Hmac(sha256, utf8.encode(secret)).convert(utf8.encode(canonical)).toString();
return {'X-Api-Key': apiKey, 'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Signature': sig};
}
Future<String> pollOrder(String orderId) async {
final path = '/v1/orders/$orderId';
var delayMs = 2000;
while (true) {
final res = await http.get(Uri.parse(base + path), headers: sign('GET', path, secret));
final status = jsonDecode(res.body)['data']['status'] as String;
if (status != 'PENDING') return status;
await Future.delayed(Duration(milliseconds: delayMs));
delayMs = (delayMs * 3 ~/ 2).clamp(0, 15000);
}
}React Native (JavaScript)
import CryptoJS from 'crypto-js';
import uuid from 'react-native-uuid';
const BASE = 'https://api.uropai.in';
function sign(method, path, secret) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = uuid.v4();
const canonical = [method, path, ts, nonce, '', ''].join('\n');
const sig = CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Hex);
return { 'X-Api-Key': apiKey, 'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Signature': sig };
}
async function pollOrder(orderId) {
const path = `/v1/orders/${orderId}`;
let delayMs = 2000;
while (true) {
const res = await fetch(BASE + path, { headers: sign('GET', path, secret) });
const { data } = await res.json();
if (data.status !== 'PENDING') return data.status;
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 1.5, 15000);
}
}Create one payment link, or an array of 1-20 of them in one call. environment is never part of the body - it always comes from the API key you signed the request with.
Request body (single object, or an array of these)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | optional | Up to 100 characters |
amount | number | depends | Required unless allowCustomerAmount is true. In PRODUCTION, must clear the same derived minimum as Create Order. |
allowCustomerAmount | boolean | optional | Defaults to false. If true, the customer sets their own amount on the hosted page, bounded by minAmount/maxAmount. |
minAmount | number | optional | In PRODUCTION with allowCustomerAmount: true and no minAmount supplied, defaults up to the derived minimum. |
maxAmount | number | optional | |
description | string | optional | Up to 500 characters |
isReUseable | boolean | optional | Defaults to false. If false, the link auto-cancels after its first successful payment. |
expiresAt | string (ISO 8601) | optional | Must be in the future |
askForCustomerData | boolean | optional | Defaults to true |
successRedirectUrl | string | optional | HTTPS-only |
failureRedirectUrl | string | optional | HTTPS-only |
notes | object | optional | Key-value strings, at most 5 keys, 100 chars each |
Response
Single-object request: 201 with the created payment link, or 400 with a single error body if that one object failed validation or a business rule (tenant readiness, minimum amount).
Array request: always 201. data is an array with one entry per input index, in the same order - each entry is either a payment link or { "error": "<message>" } for the items that failed. A batch always partially succeeds rather than failing the whole request; only a malformed body or an array outside the 1-20 item range returns 400 for the whole request.
| Field | Type | Description |
|---|---|---|
data.linkId | string | Payment link identifier |
data.environment | string | TEST or PRODUCTION - matches the API key used, never the (ignored) request body |
data.status | string | ACTIVE, CANCELLED, or SUSPENDED |
data.createdAt | string (ISO 8601) | |
data.url | string | The customer-facing hosted checkout URL for this link |
Code examples
const path = '/v1/payment-links'; const body = JSON.stringify({ name: 'Test Invoice', amount: 500, allowCustomerAmount: false }); const headers = { ...signRequest('POST', path, '', body, secret), 'Content-Type': 'application/json' }; const response = await fetch('https://api.uropai.in' + path, { method: 'POST', headers, body }); const data = await response.json();
import requests path = '/v1/payment-links' body = '{"name":"Test Invoice","amount":500,"allowCustomerAmount":false}' headers = {**sign_request('POST', path, '', body, secret), 'Content-Type': 'application/json'} response = requests.post('https://api.uropai.in' + path, data=body, headers=headers) data = response.json()
$path = '/v1/payment-links'; $body = json_encode(['name' => 'Test Invoice', 'amount' => 500, 'allowCustomerAmount' => false]); $headers = array_merge(sign_request('POST', $path, '', $body, $secret), ['Content-Type: application/json']); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl -X POST https://api.uropai.in/v1/payment-links \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>" \ -H "Content-Type: application/json" \ -d '{"name":"Test Invoice","amount":500,"allowCustomerAmount":false}'
Sample response (single object)
{
"code": 201,
"status": "success",
"message": "Payment link created",
"data": {
"linkId": "aB3xY9",
"environment": "TEST",
"name": "Test Invoice",
"amount": 500,
"allowCustomerAmount": false,
"status": "ACTIVE",
"isReUseable": false,
"askForCustomerData": true,
"createdAt": "2026-07-17T10:30:00.000Z",
"url": "https://p.urpy.link/aB3xY9"
}
}Code examples (array - create 3 links in one call)
const path = '/v1/payment-links'; const body = JSON.stringify([ { name: 'Invoice #101', amount: 500 }, { name: 'Invoice #102', amount: 1200 }, { name: 'Invoice #103' }, // invalid: amount is required when allowCustomerAmount is not set - fails independently ]); const headers = { ...signRequest('POST', path, '', body, secret), 'Content-Type': 'application/json' }; const response = await fetch('https://api.uropai.in' + path, { method: 'POST', headers, body }); const { data } = await response.json(); // data[i] lines up with the request array by index - check each entry for .error data.forEach((entry, i) => { if (entry.error) console.error(`item ${i} failed:`, entry.error); else console.log(`item ${i} created:`, entry.url); });
import requests import json path = '/v1/payment-links' body = json.dumps([ {'name': 'Invoice #101', 'amount': 500}, {'name': 'Invoice #102', 'amount': 1200}, {'name': 'Invoice #103'}, # invalid: amount is required when allowCustomerAmount is not set - fails independently ]) headers = {**sign_request('POST', path, '', body, secret), 'Content-Type': 'application/json'} response = requests.post('https://api.uropai.in' + path, data=body, headers=headers) data = response.json()['data'] for i, entry in enumerate(data): if 'error' in entry: print(f'item {i} failed:', entry['error']) else: print(f'item {i} created:', entry['url'])
$path = '/v1/payment-links'; $body = json_encode([ ['name' => 'Invoice #101', 'amount' => 500], ['name' => 'Invoice #102', 'amount' => 1200], ['name' => 'Invoice #103'], // invalid: amount is required when allowCustomerAmount is not set - fails independently ]); $headers = array_merge(sign_request('POST', $path, '', $body, $secret), ['Content-Type: application/json']); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($response['data'] as $i => $entry) { if (isset($entry['error'])) { echo "item $i failed: {$entry['error']}\n"; } else { echo "item $i created: {$entry['url']}\n"; } }
curl -X POST https://api.uropai.in/v1/payment-links \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>" \ -H "Content-Type: application/json" \ -d '[ {"name":"Invoice #101","amount":500}, {"name":"Invoice #102","amount":1200}, {"name":"Invoice #103"} ]'
Sample response (array, partial success)
Matches the 3-item batch request above - two succeed, the third (no amount) fails validation independently without blocking the other two:
{
"code": 201,
"status": "success",
"message": "Payment links processed",
"data": [
{ "linkId": "aB3xY9", "environment": "TEST", "name": "Invoice #101", "amount": 500, "status": "ACTIVE", "url": "https://p.urpy.link/aB3xY9" },
{ "linkId": "kP7mZ2", "environment": "TEST", "name": "Invoice #102", "amount": 1200, "status": "ACTIVE", "url": "https://p.urpy.link/kP7mZ2" },
{ "error": "amount is required unless allowCustomerAmount is true" }
]
}Narrow, purpose-built transition: ACTIVE -> SUSPENDED only. No request body. Scoped to your own account - a linkId belonging to another merchant always returns 404, never 403.
Response
200 - suspended, or already SUSPENDED (idempotent no-op) - either way returns the current payment link. 404 - no such link on your account. 409 - the link is CANCELLED (terminal: it already completed a payment, for a single-use link) and can never be suspended.
Code examples
const path = '/v1/payment-links/aB3xY9/suspend'; const headers = signRequest('POST', path, '', '', secret); const response = await fetch('https://api.uropai.in' + path, { method: 'POST', headers }); const data = await response.json();
import requests path = '/v1/payment-links/aB3xY9/suspend' headers = sign_request('POST', path, '', '', secret) response = requests.post('https://api.uropai.in' + path, headers=headers) data = response.json()
$path = '/v1/payment-links/aB3xY9/suspend'; $headers = sign_request('POST', $path, '', '', $secret); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl -X POST https://api.uropai.in/v1/payment-links/aB3xY9/suspend \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>"
Sample response
{
"code": 200,
"status": "success",
"message": "Payment link suspended",
"data": {
"linkId": "aB3xY9",
"environment": "TEST",
"amount": 500,
"status": "SUSPENDED",
"url": "https://p.urpy.link/aB3xY9"
}
}Pull a paginated transaction report for a date range.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string (date) | required | Inclusive start date, e.g. 2026-01-01 |
to | string (date) | required | Inclusive end date |
cursor | string | optional | Pass the previous response's nextCursor to page forward |
Request
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
X-Api-Key | header | string | required | Your merchant API key |
X-Timestamp | header | string | required | Unix seconds |
X-Nonce | header | string | required | Unique UUID per request |
X-Signature | header | string | required | Hex HMAC-SHA256 of the canonical string. Query params are covered by the signature. |
Response 200 OK
The response envelope's top-level data field is itself an object with a nested results array ({ code, status, message, data: { results: Order[], nextCursor: string | null } }) - page size is 50. Each item in the nested results array has the same shape as Create Order's data.
| Field | Type | Description |
|---|---|---|
data.results | Order[] | Page of matching transactions |
data.nextCursor | string | null | Pass as cursor to fetch the next page; null when there are no more results |
400 for invalid/missing query params or an invalid cursor.
Code examples
const path = '/v1/reports/transactions'; const query = 'from=2026-01-01&to=2026-01-31'; const headers = signRequest('GET', path, query, '', secret); const response = await fetch(`https://api.uropai.in${path}?${query}`, { headers }); const data = await response.json();
import requests path = '/v1/reports/transactions' query = 'from=2026-01-01&to=2026-01-31' headers = sign_request('GET', path, query, '', secret) response = requests.get(f'https://api.uropai.in{path}?{query}', headers=headers) data = response.json()
$path = '/v1/reports/transactions'; $query = 'from=2026-01-01&to=2026-01-31'; $headers = sign_request('GET', $path, $query, '', $secret); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path . '?' . $query, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl "https://api.uropai.in/v1/reports/transactions?from=2026-01-01&to=2026-01-31" \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>"
Sample response
{
"code": 200,
"status": "success",
"message": "Transactions fetched",
"data": {
"results": [
{
"id": "URPY-ALPHA-123456",
"tenantOrderRef": "order-123",
"status": "PAID",
"amount": 500,
"currency": "INR",
"checkoutType": "redirect",
"openUrl": "https://api.uropai.in/checkout/a1b2c3d4e5f60718293a4b5c6d7e8f90",
"createdAt": "2026-01-15T10:30:00.000Z"
}
],
"nextCursor": null
}
}Pull a paginated report of finalized settlements for a date range. Only FINALIZED settlements are returned - settlements still pending or cancelled are not included.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string (date) | required | Inclusive start date, e.g. 2026-01-01. Bounds settlement dates in IST (Indian Standard Time) calendar days. |
to | string (date) | required | Inclusive end date. Bounds settlement dates in IST (Indian Standard Time) calendar days. |
cursor | string | optional | Pass the previous response's nextCursor to page forward |
Request
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
X-Api-Key | header | string | required | Your merchant API key |
X-Timestamp | header | string | required | Unix seconds |
X-Nonce | header | string | required | Unique UUID per request |
X-Signature | header | string | required | Hex HMAC-SHA256 of the canonical string. Query params are covered by the signature. |
Response 200 OK
The response envelope's top-level data field is itself an object with a nested results array ({ code, status, message, data: { results: Settlement[], nextCursor: string | null } }) - page size is 50.
| Field | Type | Description |
|---|---|---|
data.results | Settlement[] | Page of matching settlements |
data.nextCursor | string | null | Pass as cursor to fetch the next page; null when there are no more results |
Each item in data.results has:
| Field | Type | Description |
|---|---|---|
id | string | The settlement reference - unique identifier for this settlement |
amount | number | Final settled amount paid to your bank account |
status | string | Always "finalized" for this endpoint |
settledAt | string (date-time) | When the settlement was paid out |
bankTransactionRef | string | Bank UTR/reference for the payout. Present only when available. |
grossAmount | number | Total order value before deductions |
totalCommission | number | UroPay commission deducted |
totalTransactionFee | number | Payment gateway transaction fee deducted |
totalTax | number | Tax deducted |
calculatedAmount | number | System-calculated settlement amount before any manual override at finalize time |
400 for invalid/missing query params or an invalid cursor.
Code examples
const path = '/v1/reports/settlements'; const query = 'from=2026-01-01&to=2026-01-31'; const headers = signRequest('GET', path, query, '', secret); const response = await fetch(`https://api.uropai.in${path}?${query}`, { headers }); const data = await response.json();
import requests path = '/v1/reports/settlements' query = 'from=2026-01-01&to=2026-01-31' headers = sign_request('GET', path, query, '', secret) response = requests.get(f'https://api.uropai.in{path}?{query}', headers=headers) data = response.json()
$path = '/v1/reports/settlements'; $query = 'from=2026-01-01&to=2026-01-31'; $headers = sign_request('GET', $path, $query, '', $secret); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.uropai.in' . $path . '?' . $query, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, ]); $response = json_decode(curl_exec($ch), true); curl_close($ch);
curl "https://api.uropai.in/v1/reports/settlements?from=2026-01-01&to=2026-01-31" \ -H "X-Api-Key: YOUR_API_KEY" \ -H "X-Timestamp: 1700000000" \ -H "X-Nonce: <uuid>" \ -H "X-Signature: <computed-signature>"
Sample response
{
"code": 200,
"status": "success",
"message": "Settlements fetched",
"data": {
"results": [
{
"id": "STL-ALPHA-123456",
"amount": 4820,
"status": "finalized",
"settledAt": "2026-01-16T05:00:00.000Z",
"bankTransactionRef": "UTR123456789",
"grossAmount": 5000,
"totalCommission": 150,
"totalTransactionFee": 25,
"totalTax": 5,
"calculatedAmount": 4820
}
],
"nextCursor": null
}
}Order Status Webhook
UroPay sends a courtesy POST to your configured webhookUrl when an order transitions to PAID, FAILED, EXPIRED, or CANCELLED.
GET /v1/orders/{orderId} as authoritative, and make your webhook handler idempotent on eventId since duplicate deliveries are possible.
pending - poll GET /v1/orders/{orderId} to get the current status.
The request is signed with the same HMAC scheme as inbound requests, using path = '/tenant-webhook' and an empty query string. Headers: X-Api-Key, X-Timestamp, X-Nonce, X-Signature, content-type: application/json.
Payload
| Field | Type | Description |
|---|---|---|
eventId | string (UUID) | Unique per delivery attempt group |
occurredAt | string (ISO 8601) | |
orderId | string | |
tenantOrderRef | string | |
status | string | PAID, FAILED, EXPIRED, or CANCELLED |
amount_captured | number | 0 for FAILED/EXPIRED/CANCELLED. For PAID, the real captured amount (falls back to the requested amount only if the gateway didn't report one). |
currency | string | |
commission | number | 0 for FAILED/EXPIRED/CANCELLED. Rounded to paise. |
transaction_fee | number | 0 for FAILED/EXPIRED/CANCELLED. Flat INR fee charged per successful order (merchant-specific, contact UroPay to change). |
tax | number | 0 for FAILED/EXPIRED/CANCELLED. GST charged on UroPay's own revenue (commission + transaction_fee), not on your transaction amount. |
net_amount | number | 0 for FAILED/EXPIRED/CANCELLED. amount_captured - commission - transaction_fee - tax, rounded to paise. Does not sum back to amount_captured exactly once transaction_fee/tax are nonzero. |
environment | string | TEST or PRODUCTION |
metaData | object | Merchant-supplied key-value metadata, present only when set at order creation. |
CANCELLED. An order reaches CANCELLED when the customer explicitly abandoned or cancelled the payment; it means no money moved, never that money was returned. If your webhook handler switches on status, add a default branch. statusReason is returned on GET /v1/orders/{orderId} but is deliberately not sent on the webhook.
metaData is echoed back on the order response and webhook notification. If you didn't supply any (or sent an empty object), it defaults to { "tenantOrderRef": "<your tenantOrderRef>" } on the webhook only - the GET response omits the field entirely in that case. A tenant_id key is always the platform's own value, never merchant-supplied.
Verifying the signature
const crypto = require('crypto'); function verifyWebhook(headers, rawBody, tenantSecret) { const canonical = ['POST', '/tenant-webhook', headers['x-timestamp'], headers['x-nonce'], '', rawBody].join('\n'); const expected = crypto.createHmac('sha256', tenantSecret).update(canonical).digest('hex'); const expectedBuf = Buffer.from(expected, 'hex'); const actualBuf = Buffer.from(headers['x-signature'], 'hex'); return expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf); }
import hmac, hashlib def verify_webhook(headers, raw_body, tenant_secret): canonical = '\n'.join(['POST', '/tenant-webhook', headers['x-timestamp'], headers['x-nonce'], '', raw_body]) expected = hmac.new(tenant_secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, headers['x-signature'])
function verify_webhook($headers, $rawBody, $tenantSecret) { $canonical = implode("\n", ['POST', '/tenant-webhook', $headers['x-timestamp'], $headers['x-nonce'], '', $rawBody]); $expected = hash_hmac('sha256', $canonical, $tenantSecret); return hash_equals($expected, $headers['x-signature']); }
# Signature verification happens in your webhook receiver code, not at the # command line - use the Node.js, Python, or PHP sample to compute and # compare the expected signature against the received X-Signature header.
Sample payload
{
"eventId": "7c2f9b1a-3e4d-4a5b-8c6d-1f2e3d4c5b6a",
"occurredAt": "2026-07-17T10:31:05.000Z",
"orderId": "URPY-ALPHA-123456",
"tenantOrderRef": "order-123",
"status": "PAID",
"amount_captured": 500,
"currency": "INR",
"commission": 10,
"transaction_fee": 5,
"tax": 2.7,
"net_amount": 482.3,
"environment": "PRODUCTION"
}