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

1
Get your API credentials
Contact UroPay to receive your merchant API key and secret. Both are required to sign every request. Keep the secret private - never expose it in client-side code.
2
Create an order
Sign and call POST /v1/orders with a unique tenantOrderRef, amount, and currency.
3
Redirect the customer
Open the returned openUrl in the customer's browser to complete payment.
4
Confirm the result
Poll 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.

X-Api-Key: YOUR_API_KEY
X-Timestamp: <unix-seconds>
X-Nonce: <client-generated-uuid>
X-Signature: <hex-hmac-sha256, 64 chars>
HeaderRequiredDescription
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}
FieldDescription
methodHTTP method, e.g. POST
pathURL pathname only, e.g. /v1/orders - never includes the query string
timestampSame value as the X-Timestamp header
nonceSame value as the X-Nonce header
queryStringRaw query string with no leading ?; empty string if none
rawBodyRaw request body bytes; empty string for GET requests with no body

Signature: HMAC-SHA256(canonicalString, tenantSecret), hex-encoded.

Replay protection: requests are rejected with 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 access requires KYC: 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.

Zoho Payments is 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.

SchemeTypeSub-typeCard number
VisaDebitRetail4706131211212123
VisaCreditRetail4576238912771450
VisaCreditPremium4466050254381183
VisaCreditCorporate4074970084343075
MastercardDebitRetail5409162669381034
MastercardCreditRetail5105105105105100
MastercardCreditPremium5242535837492075
MastercardCreditCorporate5552190758372734
RuPayDebitRetail6074825972083818
RuPayCreditRetail6528591234543575
Test UPI VPAResult
testsuccess@gocashSuccess
testfailure@gocashFailure
testinvalid@gocashInvalid 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.

NetworkCard numberType
Visa4100 2800 0000 1007Debit / Consumer
Mastercard5555 5100 0008 1006Credit / Business
Mastercard5180 2872 0009 1001Prepaid / Consumer
RuPay6527 6589 0000 1005Credit / Consumer
Diners3608 2800 0910 07Credit / Consumer
Amex3402 5600 0401 007Credit / 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 VPAResult
success@razorpaySuccess
failure@razorpayFailure

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 numberNetworkExpiryCVVOTP
5123456789012346Mastercard05/30123123456
4012001037141112Visa05/30123123456
6082015309577308RuPay05/30123123456
370295061673669Amex03/301234725356
5497774415170603Mastercard (server-to-server)05/30412123456
5118 7000 0000 0003Mastercard (debit)05/30123123456
4594 5380 5063 9999Visa (debit)05/30123123456

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.

NetworkCard number
Visa4111 1111 1111 1111
Mastercard5299 9202 1000 0277
RuPay6071 4898 7654 3212
Corporate card4000 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.

StatusWhen it occursSample 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"}

POST /v1/orders

Create a payment order. tenantOrderRef is your own idempotency key - retry-safe only when the payload matches the original call.

Request

ParameterInTypeRequiredDescription
X-Api-KeyheaderstringrequiredYour merchant API key
X-TimestampheaderstringrequiredUnix seconds
X-NonceheaderstringrequiredUnique UUID per request
X-SignatureheaderstringrequiredHex HMAC-SHA256 of the canonical string
tenantOrderRefbodystringrequiredYour own idempotency key. Same key + same payload returns the same order.
amountbodynumber requiredOrder amount. PRODUCTION mode enforces a minimum - see Minimum order amount below. No minimum applies in TEST mode.
currencybodystringrequiredDefaults to INR
paymentMethodsbodyarrayoptionalRestrict checkout to: upi, card, netbanking, wallet, paylater, emi. Omitted or empty = all methods shown.
customerEmailbodystringoptionalCustomer's email
customerPhonebodystringoptionalCustomer's phone
metaDatabodyobjectoptionalKey-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.
returnUrlbodystringoptionalHTTPS-only. Overrides your account's default return URL for this order.
webhookUrlbodystringoptionalHTTPS-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.

FieldTypeDescription
data.idstringOrder identifier
data.tenantOrderRefstringEchoed back
data.statusstringPENDING, PAID, FAILED, EXPIRED, or CANCELLED
data.statusReasonstringWhy 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.amountnumber
data.currencystring
data.checkoutTypestringAlways "redirect"
data.openUrlstringOpen in the customer's browser to complete payment. Omitted once the order reaches a terminal status (PAID, FAILED, EXPIRED, CANCELLED).
data.metaDataobjectMerchant-supplied key-value metadata, present only when set at order creation.
data.createdAtstring (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 /v1/orders/{orderId}

Get an order's current status. orderId is the id value returned by Create Order.

Request

ParameterInTypeRequiredDescription
X-Api-KeyheaderstringrequiredYour merchant API key
X-TimestampheaderstringrequiredUnix seconds
X-NonceheaderstringrequiredUnique UUID per request
X-SignatureheaderstringrequiredHex HMAC-SHA256 of the canonical string
orderIdpathstringrequiredOrder 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.

Never ship the merchant secret in a distributed app. It can be extracted from an installed binary and used to forge signed requests. If you poll UroPay directly from app code, do so knowingly or proxy the call through your backend.

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);
  }
}



GET /v1/reports/transactions

Pull a paginated transaction report for a date range.

Query parameters

ParameterTypeRequiredDescription
fromstring (date)requiredInclusive start date, e.g. 2026-01-01
tostring (date)requiredInclusive end date
cursorstringoptionalPass the previous response's nextCursor to page forward

Request

ParameterInTypeRequiredDescription
X-Api-KeyheaderstringrequiredYour merchant API key
X-TimestampheaderstringrequiredUnix seconds
X-NonceheaderstringrequiredUnique UUID per request
X-SignatureheaderstringrequiredHex 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.

FieldTypeDescription
data.resultsOrder[]Page of matching transactions
data.nextCursorstring | nullPass 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
  }
}

GET /v1/reports/settlements

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

ParameterTypeRequiredDescription
fromstring (date)requiredInclusive start date, e.g. 2026-01-01. Bounds settlement dates in IST (Indian Standard Time) calendar days.
tostring (date)requiredInclusive end date. Bounds settlement dates in IST (Indian Standard Time) calendar days.
cursorstringoptionalPass the previous response's nextCursor to page forward

Request

ParameterInTypeRequiredDescription
X-Api-KeyheaderstringrequiredYour merchant API key
X-TimestampheaderstringrequiredUnix seconds
X-NonceheaderstringrequiredUnique UUID per request
X-SignatureheaderstringrequiredHex 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.

FieldTypeDescription
data.resultsSettlement[]Page of matching settlements
data.nextCursorstring | nullPass as cursor to fetch the next page; null when there are no more results

Each item in data.results has:

FieldTypeDescription
idstringThe settlement reference - unique identifier for this settlement
amountnumberFinal settled amount paid to your bank account
statusstringAlways "finalized" for this endpoint
settledAtstring (date-time)When the settlement was paid out
bankTransactionRefstringBank UTR/reference for the payout. Present only when available.
grossAmountnumberTotal order value before deductions
totalCommissionnumberUroPay commission deducted
totalTransactionFeenumberPayment gateway transaction fee deducted
totalTaxnumberTax deducted
calculatedAmountnumberSystem-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.

This webhook is advisory only, not the source of truth. Delivery is best-effort - failed or SSRF-blocked deliveries are logged and dropped, never retried indefinitely. Always treat GET /v1/orders/{orderId} as authoritative, and make your webhook handler idempotent on eventId since duplicate deliveries are possible.
Test environment delivery can be delayed. In the test environment the bank does not give test webhooks high priority, so they sometimes do not fire on time. Until the webhook arrives, the order status keeps showing 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

FieldTypeDescription
eventIdstring (UUID)Unique per delivery attempt group
occurredAtstring (ISO 8601)
orderIdstring
tenantOrderRefstring
statusstringPAID, FAILED, EXPIRED, or CANCELLED
amount_capturednumber0 for FAILED/EXPIRED/CANCELLED. For PAID, the real captured amount (falls back to the requested amount only if the gateway didn't report one).
currencystring
commissionnumber0 for FAILED/EXPIRED/CANCELLED. Rounded to paise.
transaction_feenumber0 for FAILED/EXPIRED/CANCELLED. Flat INR fee charged per successful order (merchant-specific, contact UroPay to change).
taxnumber0 for FAILED/EXPIRED/CANCELLED. GST charged on UroPay's own revenue (commission + transaction_fee), not on your transaction amount.
net_amountnumber0 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.
environmentstringTEST or PRODUCTION
metaDataobjectMerchant-supplied key-value metadata, present only when set at order creation.
New in this release: 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"
}