How to Test a UPI Payment Gateway API Before Going Live: TEST Keys, Sandbox Cards, and Webhook Signature Verification

UPI moved close to 24.5 billion transactions in August 2026 alone, worth nearly ₹29.8 lakh crore, up about 20% from a year earlier (Siasat, August 2026). Every one of those payments touched a payment gateway API somewhere behind the scenes, and every one of those integrations started the same way: with a developer testing an order flow that hadn't seen a rupee of real money yet. I get a lot of questions from merchants integrating the UroPay API about how to actually test a UPI checkout properly before flipping the switch to production. So this post walks through it end to end: TEST keys, signed requests, sandbox card and UPI values, and the webhook handler you'll want working before your first live transaction.

Why sandbox testing matters more for UPI checkouts than you'd think

A lot of developers treat payment gateway testing as a formality: hit the API once with a test key, see a 200 response, ship it. That works fine for a simple product catalog. It falls apart for payments, because a UPI checkout has more moving parts than a typical CRUD endpoint: your order creation call, the customer's redirect to a UPI app, the asynchronous webhook that tells you what happened, and a status-check call you fall back on if the webhook never arrives. Each of those needs its own test pass, and each fails differently in production if you skip it.

I've seen merchants go live having only tested the happy path: order created, customer pays, webhook fires, done. Then a customer's UPI app times out mid-payment, or a webhook delivery gets dropped by their own firewall, or a customer double-submits a form and creates two orders for the same purchase. None of that shows up if your test plan stops at "did the sandbox order succeed." A proper sandbox pass means testing success, failure, and the in-between states too (pending, expired, cancelled), and confirming your application handles all of them without leaving a customer's money or your inventory in an inconsistent state.

There's a second reason testing deserves real attention on UPI specifically. Unlike a card payment, where a declined card is instantly obvious to the customer, a UPI collect request can sit pending for minutes while a customer opens their banking app, decides whether to approve it, or just doesn't see the notification at all. Your integration needs to be comfortable with that ambiguity, polling or waiting on a webhook rather than assuming an immediate result, and that's a behavior you only really validate by testing it, not by reading about it.

TEST and PRODUCTION are fully separate environments with the UroPay API

The UroPay API gives every merchant two separate key pairs, one TEST and one PRODUCTION, and there's no environment flag or separate base URL to set. Whichever key you sign a request with determines which environment that request runs in. TEST and PRODUCTION orders are fully isolated from each other: separate order IDs, separate GET lookups, even separate upstream payment gateway credentials behind the scenes. A TEST key can never see or touch a PRODUCTION order, and the reverse is true too.

This matters for how you structure your own code. If your application reads an environment variable at startup and picks a key pair, you get real environment isolation for free. A bug in staging literally cannot create a production order, because the staging key doesn't have the authority to. Compare that to setups where "test mode" is a boolean flag inside a single API key: one misconfigured deploy and a test script can fire against production data.

PRODUCTION access itself is gated. The API documentation is explicit that PRODUCTION requests get rejected with a 403 until your account has passed KYC review, or you've opted into the flat-commission non-KYC track. TEST requests carry no such requirement, so you can build and fully exercise your integration (order creation, redirect handling, webhook processing, status polling) before you've even finished onboarding. That sequencing is deliberate. You shouldn't be debugging your webhook handler for the first time against a customer's real payment.

One detail worth flagging if you're moving from a competitor's sandbox: TEST orders on the UroPay API have no minimum amount. PRODUCTION orders do (more on that below), because a production order has to be large enough to net a positive settlement after commission, the flat fee, and GST. TEST mode skips that check entirely since nothing settles.

Every request gets signed: how HMAC-SHA256 and replay protection work

Every call to the UroPay API, in either environment, needs four headers: X-Api-Key, X-Timestamp, X-Nonce, and X-Signature. The signature is a hex-encoded HMAC-SHA256 hash computed over a canonical string built from the request itself: method, path, timestamp, nonce, query string, and raw body, joined with newlines in that exact order, then hashed with your merchant secret.

canonical = `${method}\n${path}\n${timestamp}\n${nonce}\n${queryString}\n${rawBody}`
signature = HMAC-SHA256(canonical, tenantSecret) // hex-encoded

This isn't unusual. It's the same pattern most serious payment APIs use, and it exists for a specific reason: an intercepted request should be worthless to replay. The timestamp and nonce are what make that true. The API rejects a request with a 401 if it arrives more than 300 seconds late or 30 seconds early relative to server time, and each nonce can only be used once per merchant (reuse it and you get rejected too). Security writeups on HMAC-based APIs consistently point to a 3-5 minute freshness window as the sweet spot between tolerating normal clock skew and closing the replay window fast enough to matter (Hooklistener, webhook security fundamentals), and the UroPay API's 300-second window sits right in that range.

Here's the part I'd genuinely encourage you to test on purpose rather than just implement and hope. Get the canonical string wrong in your test environment and confirm you get a clean 401 back, not a 500 or a hang. Try an expired timestamp. Try reusing a nonce. Try signing the query string when the spec says to leave it out of a GET request with no params. Every one of those is a mistake that's easy to make once in your signing helper function and then repeat silently across every request your integration ever sends. TEST mode is exactly where you want to find it, when the cost of a failed request is a wasted minute rather than a merchant wondering why their checkout is down.

If you're verifying webhooks (covered below), the same signing scheme applies in reverse. You compute the expected signature yourself over the payload UroPay sent you, then compare it to the X-Signature header using a constant-time comparison, not a plain string equality check. The Node.js sample in the API docs uses crypto.timingSafeEqual for exactly this reason. A naive === comparison can leak timing information that an attacker uses to guess the correct signature one byte at a time.

Simulating a full payment in TEST mode, gateway by gateway

Here's something that surprises a lot of developers building on the UroPay API for the first time: your TEST orders don't all land on the same simulated payment page. Under the hood, UroPay routes each TEST order to one of several upstream payment gateways (Cashfree, Razorpay, or PayU), and the hosted checkout page tells you which one you landed on. You use that gateway's own published test values on that page; you never call the upstream gateway directly yourself.

A few of the values worth knowing before you start clicking through test payments:

On Cashfree's test rails, the UPI VPAs testsuccess@gocash, testfailure@gocash, and testinvalid@gocash do exactly what their names suggest, and test cards use a fixed expiry (03/2028), CVV (123), and OTP (111000) regardless of which card number you pick from their published list.

On Razorpay's test rails, UPI is even simpler: success@razorpay and failure@razorpay as VPAs, and cards show a mock bank page with literal Success/Failure buttons rather than needing a specific OTP. A useful detail if you're testing UPI-specific failure handling: in Razorpay test mode, cancelling a UPI payment is recorded as a success, which is worth knowing before you assume your cancellation-handling code is broken.

On PayU's test rails, UPI uses anything@payu or the fixed VPA 999999999@payu, both sandbox-only, alongside a full set of test card numbers per network.

None of these values are UroPay's own. They belong to the underlying gateways and can change without notice, so the API documentation is explicit that each gateway's own docs are authoritative if something stops matching. Zoho Payments values are also listed for completeness, but only apply once your account is live, since TEST orders are never routed to Zoho.

The practical workflow: sign a request with your TEST key, call POST /v1/orders, open the returned openUrl in a browser, see which gateway you landed on, and use that gateway's success or failure VPA depending on which path you're testing that session. Run through it enough times to hit success, failure, and, separately, let an order simply expire without completing it, so you've actually seen an EXPIRED status come back rather than assuming your code handles it.

Building a webhook handler you can trust before it touches real money

The order status webhook is the part of a UPI integration most likely to have a bug that only shows up under real traffic, because it's asynchronous, it's best-effort, and duplicate deliveries are an expected part of its design rather than an edge case.

A few facts about the UroPay API's webhook should shape how you build your handler. It's explicitly advisory, not authoritative. The docs are direct about this: "always treat GET /v1/orders/{orderId} as authoritative." Delivery is best-effort, so a failed or blocked delivery gets logged and dropped rather than retried forever, which means your integration needs a fallback path, usually a periodic status poll on any order that's been pending too long, for the case where the webhook never shows up at all. And duplicate deliveries are possible by design, which is why the docs call out making your handler idempotent on eventId specifically.

Concretely, that means your webhook endpoint should do three things every time, in this order: verify the X-Signature header against the payload using the same HMAC scheme as outbound requests (with path fixed to /tenant-webhook), check whether you've already processed this eventId and skip if you have, and only then update your order state. Skip the signature check and anyone who can guess your webhook URL can fake a PAID notification. Skip the idempotency check and a duplicate delivery double-credits an order or double-fulfills a purchase.

Worth testing specifically before going live: what happens in your code when a webhook for CANCELLED arrives. This is a newer status that means the customer explicitly backed out of the payment, not that money moved and was returned. If your handler's logic branches on status with an implicit assumption that only PAID, FAILED, and EXPIRED exist, a CANCELLED event can fall through into whichever branch handles unrecognized input, which is rarely the behavior you actually want. Add an explicit branch, or at minimum a safe default, and test it with a genuinely cancelled TEST order rather than assuming it'll be handled the same as FAILED.

The amount_captured, commission, transaction_fee, tax, and net_amount fields are all zero on non-PAID webhook events by design, and net_amount on a PAID event won't sum back exactly to amount_captured once fee and tax are nonzero. It's amount_captured minus commission minus fee minus tax, rounded to paise. If your reconciliation logic assumes a clean subtraction, test it against a real TEST-mode PAID payload with nonzero fees, not a made-up example.

What changes the moment you flip to PRODUCTION

Testing thoroughly in TEST mode gets you most of the way there, but a few things behave differently once you're signing with a PRODUCTION key, and it's worth knowing about them before you're debugging them live.

The most immediate one: PRODUCTION orders enforce a minimum amount, and TEST orders don't. The minimum isn't a fixed number. It's derived from your own commission rate, flat fee, and GST, specifically so that even the smallest order still settles a positive amount to your bank account after UroPay's cut. A PRODUCTION order below that floor gets rejected with a 400 that names your exact current minimum in the error message, because the threshold moves when your commission rate changes, for instance the moment your KYC gets approved and you move from the 10% to the 5% tier. Read that number from the error response rather than hardcoding a figure you saw once during testing.

The second: PRODUCTION access itself requires your account to have cleared KYC review, or to have explicitly opted into the flat, non-KYC commission track. Signing a request with a PRODUCTION key before that review clears gets you a 403, not a payment, which is a good failure mode to build a graceful error message around in your own app rather than surfacing a raw API error to a customer.

The third is more of a mindset shift than a technical one. TEST-mode UPI payments and card payments are simulated and reversible in the sense that nothing settles. PRODUCTION orders trigger real settlement on the T+3 or T+5 cycle depending on your KYC status, and refunds are handled entirely offline by you, not through the API. A bug that creates a duplicate charge in TEST mode costs you a confusing log entry; the same bug in PRODUCTION costs you an actual refund conversation with a customer.

UroPay API vs traditional payment gateway APIs: a testing-focused comparison

UroPay APITypical mainstream aggregator API (Razorpay, Cashfree, PayU)
Commission5% + ₹5 with KYC, 10% + ₹5 withoutRoughly 2% + GST, generally requires full KYC
TEST-mode accessNo KYC required; TEST keys work immediately after signupUsually available pre-KYC, though full webhook/production parity often needs an approved account
TEST order minimum amountNoneVaries by provider, generally none
PRODUCTION minimum amountDerived from your commission + fee + GST, shown in error messagesProvider-specific minimums, if any
Signing schemeHMAC-SHA256 over a canonical string, timestamp + nonce replay protectionVaries; most major providers use HMAC-based webhook signatures, fewer sign every inbound request
Webhook authorityAdvisory only; GET /v1/orders/{id} is authoritativeSimilarly advisory in most providers' own documentation
SettlementT+3 (KYC) or T+5 (non-KYC)Typically T+1 to T+2 once KYC is complete
Support during integrationDirect contact with me at gaurav@uropay.meUsually ticket-based, with priority support reserved for larger accounts

The technical shape of testing a UPI integration looks similar across most of these providers: sandbox keys, published test VPAs and cards, signed webhooks. Where they diverge is settlement speed once you're live (mainstream aggregators are generally faster) and who you actually reach when your test payload isn't behaving the way the docs say it should.

The real cost trade-off: why merchants still choose the UroPay API

I'd rather be upfront about this than let a comparison table imply something it doesn't. The UroPay API's commission is higher than what you'd pay a mainstream aggregator. Razorpay, Cashfree, and PayU typically run close to 2% plus GST once you're fully KYC-verified. The UroPay API charges 5% + ₹5 per transaction with KYC, or 10% + ₹5 without. That's meaningfully more in raw fee terms, every time, on every transaction.

Here's what that looks like on a ₹2,499 order. With KYC, the UroPay API deducts ₹124.95 in commission (5%) plus the ₹5 flat fee, about ₹129.95 total, netting you close to ₹2,369, settled in three working days. Without KYC, commission doubles to ₹249.90 plus the flat fee, around ₹254.90 total, netting closer to ₹2,244, settled in five working days. A mainstream aggregator at roughly 2% plus 18% GST on that commission takes about ₹58.98 total, netting you close to ₹2,440, and generally settling faster too. On pure math, the mainstream aggregator wins, and I'm not going to pretend otherwise.

What the higher commission buys instead is three things that don't show up in a fee table. First, direct access to me: not a support ticket queue, not a bot, not a tier system that reserves real humans for large accounts. Every UroPay API merchant can email gaurav@uropay.me and get a response from the person who actually runs the platform. Second, meaningfully more lenient KYC. The non-KYC tier is a genuine way to go live without submitting PAN, GST, or bank identity documents. You still go through a website or app audit and sign a contract, so it isn't zero onboarding, but the identity-document bottleneck that stalls so many mainstream applications simply isn't there. Third, a real willingness to onboard businesses and categories that stricter aggregators slow-walk or decline outright, as long as you're not on the 44-category prohibited list published on the API page.

If your business is a straightforward, fully-KYC-ready company selling something no aggregator would blink at, the math favors a mainstream provider and I'd say so plainly. If you're earlier stage, working through KYC, or in a category that's gotten stuck in someone else's review queue before, the trade-off starts to look different. That's genuinely who the UroPay API is built for.

Quick takeaways

  • The UroPay API gives you two fully isolated key pairs, TEST and PRODUCTION, and the key you sign with decides the environment, no separate flag needed.
  • Every request needs four headers (API key, timestamp, nonce, signature) and gets rejected if the timestamp is stale, the nonce is reused, or the HMAC-SHA256 signature doesn't match.
  • TEST orders route to one of several upstream gateways (Cashfree, Razorpay, PayU); use that gateway's own published test VPAs and card numbers on the checkout page you land on.
  • The order status webhook is advisory only. Treat GET /v1/orders/{orderId} as the authoritative source, and make your webhook handler idempotent on eventId since duplicate deliveries happen by design.
  • PRODUCTION orders enforce a minimum amount derived from your commission and fees; TEST orders don't, since nothing settles.
  • The UroPay API's commission (5% + ₹5 with KYC, 10% + ₹5 without) runs higher than mainstream aggregators' roughly 2% + GST. That premium buys direct founder support, easier KYC, and a real shot at onboarding for restricted or edge-case categories.
  • Test your failure paths on purpose: expired orders, cancelled orders, bad signatures, duplicate webhooks, not just the happy path where everything succeeds on the first try.

Getting started

  1. Apply from the dashboard at dashboard.uropai.in. Tell UroPay about your business, your website or app, and your expected volumes.
  2. Go through the website or app audit, where your product gets reviewed against the prohibited-category list before anything moves forward.
  3. Decide on KYC now or later. Complete KYC for the 5% + ₹5 rate and T+3 settlement, or skip it for now and start on the 10% + ₹5, T+5 non-KYC tier. You can upgrade any time.
  4. Sign the merchant contract, which covers commission, settlement, refunds, and chargeback liability, and is required on both tiers.
  5. Get your TEST key and start building. Sign requests with HMAC-SHA256, create test orders, walk through the sandbox VPAs and cards above, and get your webhook handler verifying signatures and checking idempotency.
  6. Move to PRODUCTION once review clears. Swap in your live key, watch for the minimum-order-amount threshold in your first real transactions, and keep the same signing and webhook code you already tested.

Full endpoint details, request and response shapes, and copy-paste signing code for Node.js, Python, and PHP are in the API reference.

Conclusion

Testing a UPI payment gateway integration properly takes longer than the happy-path version most developers ship first, and I think that extra time is worth it every time. A checkout that only works when the customer pays instantly and your webhook arrives on the first try isn't tested, it's untested code that happens to have run successfully once. The UroPay API's TEST environment exists so you can find the gaps (expired orders, cancelled payments, dropped webhooks, replayed requests) before a real customer does.

If you're building UPI checkout into your own website, app, or SaaS product and want a payment partner where you can actually reach the person running it, start with TEST keys at dashboard.uropai.in. No KYC required to begin building. If you'd rather compare it against UroPay's other two products first, UroRelay covers no-code payment links and buttons for sellers who don't want to write integration code at all, and UroCentro is the full-KYC route for businesses that want mainstream settlement speed with a licensed aggregator relationship. Whichever fits, I'm at gaurav@uropay.me if you want to talk through your specific integration before you commit to one.

FAQ

Do I need to complete KYC before I can start testing the UroPay API?No. TEST keys work immediately after signup, with no KYC requirement. KYC only gates PRODUCTION access, so you can build and fully test your integration on the non-KYC track and decide on KYC later.

Is there a minimum transaction amount in TEST mode?No. TEST orders are exempt from the minimum-order-amount rule that applies in PRODUCTION, since TEST orders never settle to a real bank account.

What test UPI ID or card should I use to simulate a failed payment?It depends on which upstream gateway your TEST order routes to; the hosted checkout page tells you. Cashfree uses testfailure@gocash, Razorpay uses failure@razorpay, and PayU accepts anything@payu for its sandbox UPI flow. Check the API documentation for the full current list, since these values belong to the upstream gateways and can change.

Why isn't the order status webhook considered authoritative?Because delivery is best-effort. A failed or blocked delivery gets logged and dropped rather than retried indefinitely, so your integration should always fall back to a GET on the order for the authoritative status rather than assuming the webhook will always arrive.

How is the UroPay API's HMAC signature actually verified?You compute an HMAC-SHA256 hash over a canonical string (method, path, timestamp, nonce, query string, and raw body joined with newlines) using your merchant secret, then compare it to the X-Signature header using a constant-time comparison function, not a standard equality check.


Have you run into a payment integration bug that only showed up after going live, despite testing beforehand? I'd like to hear what slipped through. Reply to this post or reach me directly at gaurav@uropay.me, and feel free to share this with another developer who's mid-integration right now.

References