How to Secure Your UPI Payment Gateway API Integration: HMAC-SHA256 Signing and Webhook Verification Explained

I signed a lot of API requests before I ever signed a merchant contract. When I built the UroPay API, the two-endpoint pitch (create an order, check its status) was the easy part. The part that actually took time was making sure a stranger on the internet couldn't forge a payment confirmation and get my merchants' orders marked PAID for free. That's what this post is about: how HMAC-SHA256 request signing and webhook verification work, why they matter more in 2026 than they did two years ago, and what I've seen developers get wrong when they wire this up for the first time.

This is written for anyone integrating a UPI payment gateway API into their own website, app or SaaS product, whether that's the UroPay API, Razorpay, Cashfree, or PayU's own developer APIs. The mechanics are close enough across providers that most of this transfers directly.

Why payment gateway API security is suddenly everyone's problem

RBI's Authentication Mechanisms for Digital Payment Transactions Directions, 2025 (circular RBI/2025-26/79) came into force on April 1, 2026. It extended mandatory two-factor authentication to effectively every customer-initiated digital payment: UPI, wallets, net banking, QR, all of it. That circular is about the customer's side of the transaction, the OTP or biometric check that proves the person paying is who they say they are.

What it doesn't cover, and what nobody talks about as much, is the other half of the transaction: the server-to-server conversation between a payment gateway and your backend. Your customer might complete a flawless, fully authenticated UPI payment, and your server can still get fooled if you're not verifying that the "payment confirmed" webhook hitting your endpoint actually came from your payment gateway and not from someone who found your webhook URL and started sending POST requests to it.

This gap matters more now than it did a year ago. UPI-linked fraud crossed ₹805 crore in FY26 through November, according to figures reported by the government (source: Madhyama), and NPCI's own mule-detection systems have flagged over 4.7 lakh mule accounts tied to fraud rings. Most of that fraud targets end users directly. A smaller, quieter category targets merchants instead: webhook spoofing, replayed payment confirmations, and race conditions on endpoints that don't check who's actually calling them. It doesn't make headlines the way a UPI scam call does, but it's the kind of thing that quietly fills a small business's order queue with fake "paid" orders.

I'm writing this as the founder of a UPI payment gateway API (more on that here if you're curious who's behind this), so take the framing for what it is. The mechanics below still apply whether you're integrating the UroPay API, Razorpay, or Cashfree. Security here isn't a competitive feature so much as a baseline nobody should skip.

What HMAC-SHA256 request signing actually does

Every request the UroPay API sends or receives is signed with HMAC-SHA256, and it's worth being precise about what that buys you, because "signed" gets thrown around loosely in payments talk.

HMAC (Hash-based Message Authentication Code) takes your request, the method, the path, the body, sometimes a timestamp, and your secret key, and produces a short signature. Anyone who has the secret key can produce that exact signature for that exact request. Anyone who doesn't have the key can't, no matter how closely they copy the request's shape. That's the whole trick: it proves the sender knows the shared secret without ever putting the secret itself on the wire.

Here's roughly what that looks like on a create-order call:

// Sign a request the same way the UroPay API expects
import { createHmac } from "crypto";

function sign(method: string, path: string, timestamp: string, body: string, secretKey: string): string {
  // The signed string binds method + path + timestamp + body together
  // so an attacker can't reuse a valid signature on a different request
  const payload = `${method}\n${path}\n${timestamp}\n${body}`;
  return createHmac("sha256", secretKey).update(payload).digest("hex");
}

Two things matter beyond the hashing itself. First, the signature needs to cover more than just the body. It should bind in the HTTP method and the path too, otherwise a signed request for GET /v1/orders/123 could get replayed against POST /v1/orders/123/refund if such an endpoint existed. Second, TEST and PRODUCTION should use entirely separate key pairs. That's how the UroPay API does it by design: the key you sign with is what picks the environment, so there's no config flag to accidentally leave in the wrong state.

None of this is unique to UPI or to India. It's the same pattern AWS uses for its request signing, the same pattern Stripe and Razorpay use for webhooks. The value is in actually implementing it correctly, which is where most integrations quietly fall short.

Verifying a webhook signature, step by step

Signing outbound requests is the easy half. Verifying inbound webhooks is where I see developers cut corners, usually because it's the part that only gets tested once, during integration, and never gets looked at again.

A webhook from a UPI payment gateway API typically arrives with the event payload and a signature in a header. Your job is to recompute that signature yourself, using the same secret, and compare it against what arrived. If they match, the payload came from the gateway and hasn't been altered in transit. If they don't match, reject it. No partial trust, no "close enough."

// Verify an incoming webhook before touching the order record
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(rawBody: string, receivedSignature: string, secretKey: string): boolean {
  const expected = createHmac("sha256", secretKey).update(rawBody).digest("hex");

  // Constant-time comparison: a plain === leaks timing information
  // that an attacker can use to guess the signature byte by byte
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(receivedSignature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Three details separate a correct implementation from one that just looks correct. Verify against the raw request body, not a re-serialized version of it, since JSON.stringify can reorder keys or change whitespace and quietly break the hash. Use a constant-time comparison function rather than ===, because a naive string comparison can leak timing information an attacker uses to guess the correct signature one byte at a time. And reject anything that fails verification before your code touches the database, not after.

I'd add a fourth point, specific to UPI payment gateways: never treat a webhook as the final word on an order's status, signed or not. Signature verification tells you the message is authentic. It doesn't protect you from a message arriving twice, arriving late, or never arriving at all because of a dropped connection.

Why the webhook is advisory, not authoritative

This is the point where signing and idempotency meet, and it's specific enough to payment APIs that it earns its own section.

The UroPay API's webhook, like most payment gateway webhooks, is explicitly best-effort. It tells you an order probably moved to PAID, FAILED or EXPIRED, and in the overwhelming majority of cases that's exactly what happened. But "best-effort" also means it can arrive twice for the same event, arrive out of order, or not arrive at all if your endpoint happened to be down for the ninety seconds that mattered. A webhook is a hint, not a receipt.

The fix is the one every payment gateway documents and almost nobody follows on the first attempt: after a webhook tells you an order changed status, make your own GET call to the order endpoint and treat that response as the authoritative record. Use your own tenantOrderRef as an idempotency key, so that if the same PAID event lands on your endpoint three times, which does happen under network retries, you fulfil the order exactly once instead of three times.

// Handle the webhook, then confirm against the authoritative source
async function handlePaidWebhook(event: { orderId: string; tenantOrderRef: string }) {
  const alreadyProcessed = await orderStore.isFulfilled(event.tenantOrderRef);
  if (alreadyProcessed) return; // idempotency check stops double-fulfilment

  const order = await fetchOrderStatus(event.orderId); // authoritative GET call
  if (order.status === "PAID") {
    await orderStore.markFulfilled(event.tenantOrderRef);
    await fulfilOrder(event.tenantOrderRef);
  }
}

Signature verification and idempotent handling solve two different problems, and you need both. Verification stops a stranger from forging a PAID event. Idempotency stops a genuine, correctly signed event from being processed more than once. Skip either one and you've still got a hole, just a different shape of hole. I've written separately about idempotent order handling and deduplication in more depth. That post is the place to go if duplicate charges are the specific problem you're solving; this one stays focused on proving a webhook is genuine in the first place.

The mistakes I keep seeing in real integrations

A handful of patterns show up often enough in support conversations that they're worth naming directly, because most of them are one line of code away from being fixed.

Logging the raw webhook secret or signing key, usually as a debugging step that never gets removed. Once a secret is sitting in a log aggregator with looser access controls than your production database, it's effectively public inside your own organisation. Rotate the key immediately if this happens. The UroPay API dashboard lets you regenerate keys without downtime, since old and new keys can overlap briefly during rollover.

Comparing signatures with == or === instead of a constant-time function. It looks like a small difference, and it's an even smaller one in a code review, but timing attacks against string comparison are a documented, exploitable class of vulnerability, not a theoretical one.

Verifying against a body that's already been touched by a framework's middleware. Express, Django and Laravel all like to parse JSON before your handler ever sees it, and if your signature verification runs on the re-serialized object rather than the exact bytes that arrived, valid signatures start failing for reasons that have nothing to do with security and everything to do with debugging time you didn't budget for. Capture the raw body before any parsing happens.

Treating TEST keys as close enough to PRODUCTION that webhook testing gets skipped entirely. The two environments are isolated specifically so you can throw test traffic at your webhook handler without financial consequences. Use that instead of finding out your signature verification has a bug on your first live transaction.

Skipping the "what if this arrives twice" question because it seemed unlikely during a demo. Retries are a feature of reliable delivery, not a bug in it, and any payment gateway worth using will retry a webhook delivery that didn't get a 200 response back.

How this stacks up: UroPay API vs the mainstream gateways

Every major UPI payment gateway API signs its requests and webhooks in some form. This isn't a UroPay API exclusive, and I won't pretend otherwise. Where the providers actually differ is everything around that shared security baseline: what it costs, what KYC gets you, and who picks up when your signature verification is failing for a reason you can't figure out at 11pm.

UroPay APIRazorpay / Cashfree / PayU (typical)
Commission5% + ₹5 with KYC, 10% + ₹5 without~2% + GST (KYC required either way)
KYC to go liveOptional, non-KYC tier availableMandatory before any live transaction
SettlementT+3 (KYC) or T+5 (non-KYC)Typically T+1 to T+3
Request signingHMAC-SHA256, TEST/PRODUCTION key pairsHMAC-SHA256 or equivalent (varies by provider)
Integration effortTwo REST endpoints plus one webhookComparable, SDK-driven, more surface area
Who owns the checkoutYou do, on your own domainYou do (API) or their hosted page (checkout)
Support modelDirect email to the founder, gaurav@uropay.meTicket queue, priority tiers for higher-volume accounts
Restricted categoriesCase-by-case audit against a 44-category listGenerally declined or routed to specialised providers

That commission gap is real, and I'd rather say it plainly than bury it in a footnote: the UroPay API costs more per transaction than Razorpay, Cashfree or PayU's headline rates. It is not the cheaper option. I'm not going to argue that it secretly is.

What you're actually paying for at 5 to 10% instead of roughly 2%

Here's the honest math, worked through on a single ₹2,499 transaction, since that's easier to reason about than percentages alone.

At a typical mainstream aggregator rate of roughly 2% plus GST, a ₹2,499 sale nets you close to ₹2,443 after fees, minus whatever GST applies on top. Settlement usually lands T+1 to T+3.

On the UroPay API with KYC completed, the same ₹2,499 sale costs 5% (₹124.95) plus the flat ₹5 fee, netting roughly ₹2,369, settled T+3. Without KYC, it's 10% (₹249.90) plus ₹5, netting roughly ₹2,244, settled T+5.

Run that at volume and the gap stops being small. A business doing ₹10 lakh a month in transactions pays meaningfully more on the UroPay API than on a 2% gateway. I'm not going to dress that up as a rounding error.

What the higher commission buys, concretely, is three things I can actually stand behind rather than marketing copy. A direct line to me, the founder, at gaurav@uropay.me, instead of a ticket queue that reserves human responses for accounts above a certain volume. A non-KYC path that's a real way to start collecting payments before your business documents are in order, not a teaser that dead-ends into the same paperwork everyone else asks for (you still go through a website audit and sign a merchant contract, this isn't zero onboarding, just document-free onboarding). And an actual willingness to review businesses and categories that bigger, stricter aggregators tend to slow-walk or decline outright, so long as you're not on the 44-category prohibited list published on the UroPay API page.

That's the trade as I see it. Pay less and get faster settlement with a mainstream aggregator, or pay more for a founder who answers his own support email, meaningfully easier KYC, and a fairer shot if your business sits in a category other providers won't touch. It's not a trade that makes sense for every merchant, and I'd rather a reader work that out for themselves with the real numbers in front of them than have me insist it's obviously worth it.

Getting live with signed requests and webhooks in 5 working days

If you're starting from scratch, this is the path from application to your first signed webhook.

Apply from dashboard.uropai.in, describing your business, your website or app, and expected volumes. That kicks off a website or app audit, someone actually reviews your product against the 44-category prohibited list before you get keys, which is also why this isn't an instant sign-up.

Decide on KYC now or later. Completing it gets you the 5% + ₹5 rate and T+3 settlement. Skipping it gets you live faster at 10% + ₹5 and T+5, with the option to upgrade whenever your documents are ready. No re-integration required, since the API is identical on both tiers.

Sign the merchant contract, which covers commission, settlement, refunds (handled offline by you) and chargeback liability (yours, plus a contractual penalty if one is raised). The FAQ page has more detail on refunds, chargebacks and onboarding timelines if you want the fine print before you apply.

Once live keys are issued, integrate the two endpoints, POST /v1/orders to create an order, GET /v1/orders/{id} to check its authoritative status, plus the signed webhook using the patterns above. Full request and response schemas, plus signing snippets for Node, Python, PHP and cURL, are in the API reference. Test everything against your TEST key pair first. Nothing about switching to PRODUCTION changes your code, only the key you sign with.

Quick takeaways

  • HMAC-SHA256 signing proves a request came from someone who holds the shared secret. It doesn't replace idempotency, and you need both to be safe.
  • Always verify webhook signatures against the raw request body, using a constant-time comparison, before your code touches any order record.
  • Treat every webhook as advisory: confirm the order's real status with an authoritative GET call, and use your own tenantOrderRef as an idempotency key.
  • The UroPay API's commission, 5% + ₹5 with KYC and 10% + ₹5 without, is higher than mainstream aggregators' roughly 2% + GST rates. That's not hidden in fine print.
  • What the higher commission is meant to buy is direct founder-level support (gaurav@uropay.me), a real non-KYC path to going live, and case-by-case review for categories other aggregators decline, not a cheaper checkout.
  • TEST and PRODUCTION keys are fully isolated on the UroPay API, so signature bugs should get caught in testing, not in production.
  • RBI's 2FA mandate (effective April 1, 2026) covers customer authentication. Request signing and webhook verification are a separate layer that's entirely on the merchant to get right.

Where this leaves you

Security on a payment integration isn't one control, it's a stack of them. Your customer authenticates with two factors because RBI now requires it. Your outbound requests get signed so the gateway knows they're really from you. Your inbound webhooks get verified so you know they're really from the gateway. Your order handling stays idempotent so a duplicate or delayed message doesn't cost you money. Skip any one layer and the others don't fully cover for it.

If you're building UPI checkout into your own product and want signing and verification handled correctly from day one, the UroPay API gives you TEST keys immediately and live keys in about 5 working days once your audit and contract are done. Get started at dashboard.uropai.in, or read the full request and webhook reference at api.uropai.in/documentation before you commit to anything. If your business doesn't need a coded integration at all, if you just want a payment link or button with zero commission and no KYC, UroRelay is the simpler starting point, and you can move to the API later without losing anything you've built.

FAQ

Does HMAC-SHA256 signing replace the need for HTTPS?No. HMAC signing proves the request came from someone holding the secret key. HTTPS/TLS protects the data in transit from being read or tampered with by a network observer. You need both: signing without TLS still lets an attacker read the payload, and TLS without signing still lets anyone who finds your webhook URL send you fake events.

Why does my webhook signature verification keep failing even though I copied the code correctly?The most common cause is verifying against a body that's already been parsed and re-serialized by your web framework, which can change key order or whitespace and produce a different hash than the original bytes. Capture the raw request body before any JSON parsing middleware touches it.

Is UPI payment gateway webhook signing mandatory under RBI rules?Not directly. RBI's Authentication Mechanisms for Digital Payment Transactions Directions, 2025 (effective April 1, 2026) mandates two-factor authentication for customer-initiated transactions, not server-to-server webhook signing. Signing and verification are industry best practice that individual payment gateways implement and merchants are expected to use correctly, not a specific RBI-mandated control on the merchant side.

What's the difference between idempotency and webhook signature verification?Verification confirms a webhook is authentic, that it really came from your payment gateway. Idempotency confirms you only act on a given event once, even if it arrives multiple times due to retries. A forged webhook that isn't caught by verification is a security problem. A genuine webhook processed twice because of missing idempotency is a correctness problem. You need to solve both.

Can I test webhook signing without risking real transactions?Yes. On the UroPay API you get separate TEST and PRODUCTION key pairs from the start. The key you sign a request with determines which environment it hits, so you can fully exercise your signature verification and idempotency logic against TEST orders before any live money is involved.

What's the one payment security mistake you've actually run into in production? A leaked key, a duplicate order, something else? I'd like to know, and if you've got a UPI integration story worth sharing, reply or drop it in the comments. It might save the next developer a bad night. More posts like this one live on the UroPay blog, if you want to keep reading.

References