How to Verify UPI Payments Using Webhooks: A Developer's Guide

VT

VyaparGateway Team

Payments Editorial

11 min read
Updated
How to Verify UPI Payments Using Webhooks: A Developer's Guide
#upi webhook verification #hmac signature #payment webhooks #idempotency

Webhooks are how your application learns that a UPI payment succeeded. They are also, if implemented carelessly, the single biggest attack surface in your payment integration. Every week, security researchers find e-commerce sites where attackers can mark orders as paid by sending a forged POST request to a webhook URL discovered via Google or a public JavaScript bundle. This guide walks through how to verify webhooks properly so that only legitimate events from your gateway can trigger fulfilment — with code examples for Node, Python, and PHP.

Why browser callbacks aren't enough

After a customer pays, your frontend often gets a callback (a redirect or a window message) saying 'payment success.' You might be tempted to use that callback to mark the order paid. Don't. Browser callbacks live entirely on the client side — any attacker can trigger them with a curl command or a modified browser request. They're useful for UX (show the success page faster) but they are not authoritative.

The only authoritative source of payment truth is a signed webhook from your gateway, delivered server-to-server, that you have verified cryptographically. Until that webhook arrives and verifies, the payment is unconfirmed.

Code on screen showing webhook handler
A webhook handler that doesn't verify signatures is a 'mark any order paid' endpoint exposed on the open internet.

The five-step verification checklist

A production webhook handler should do all five of these in order, before touching your database:

  1. Read the raw request body — not the parsed JSON. You need the exact bytes that were signed.
  2. Compute the HMAC-SHA256 of the raw body using your webhook secret as the key.
  3. Compare your computed HMAC against the X-VyaparGateway-Signature header using a constant-time comparison (NOT string equality, which is timing-attack vulnerable).
  4. Check the timestamp header (X-VyaparGateway-Timestamp) is within an acceptable skew window (typically ±5 minutes) to prevent replay of old captured events.
  5. Look up the order in your DB by the order ID in the payload. If it's already marked paid (idempotency check), return 200 immediately without re-processing.

Only after all five steps succeed do you mark the order paid, fulfil, send the receipt, and return 200 OK.

Node.js example (Express)

Here's a minimal but production-shaped Express handler for VyaparGateway webhooks:

import express from 'express'; import crypto from 'crypto'; const app = express(); app.post('/webhooks/vyapargateway', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.header('X-VyaparGateway-Signature'); const ts = req.header('X-VyaparGateway-Timestamp'); const body = req.body; const expected = crypto.createHmac('sha256', process.env.VG_WEBHOOK_SECRET).update(body).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).end(); if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.status(401).end(); const event = JSON.parse(body); const order = await db.orders.findUnique({ where: { id: event.order_id } }); if (order.status === 'paid') return res.status(200).end(); await db.orders.update({ where: { id: event.order_id }, data: { status: 'paid', utr: event.utr } }); await fulfill(order); res.status(200).end(); });

Note the use of express.raw() — you need the unparsed body bytes to compute the HMAC. Parsing as JSON first and then stringifying will produce a different byte sequence, breaking the signature check.

Python example (Flask)

import hmac, hashlib, time; from flask import Flask, request, abort; app = Flask(__name__); @app.post('/webhooks/vyapargateway') def vg_webhook(): sig = request.headers.get('X-VyaparGateway-Signature'); ts = request.headers.get('X-VyaparGateway-Timestamp'); body = request.get_data(); expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest(); if not hmac.compare_digest(sig, expected): abort(401); if abs(time.time() - int(ts)) > 300: abort(401); event = request.get_json(); order = Order.get(event['order_id']); if order.status == 'paid': return ('', 200); order.mark_paid(event['utr']); fulfill(order); return ('', 200)

"If you skip the timestamp check, an attacker who captures one valid webhook can replay it forever to mark unrelated orders paid. The five-minute skew window is a hard requirement, not a nice-to-have."

Handling retries and at-least-once delivery

Webhook delivery is at-least-once, never exactly-once. If your server returns a 5xx, a timeout, or simply hangs, the gateway will retry — typically with exponential backoff over 24–72 hours. This is essential for reliability (a momentary outage doesn't lose payments) but it means your handler must be idempotent: receiving the same payment notification twice must produce the same result as receiving it once.

  • Always check 'is this order already marked paid?' before processing. If yes, return 200 immediately.
  • Use the payment intent ID or UTR as the idempotency key — never the request ID alone, which changes on retry.
  • Persist a record of every webhook you've processed so you can answer 'have I seen this before?' even after a restart.
  • Return 2xx only after your database write has committed. Returning 200 and then crashing loses the event.

Common pitfalls and how to avoid them

From auditing production integrations, these are the mistakes that show up most often:

  • Using JSON.stringify on the parsed body and signing that — produces a different byte sequence than the original. Always sign and verify on the raw body.
  • Comparing signatures with === or strcmp — vulnerable to timing attacks. Use constant-time comparison functions.
  • Skipping the timestamp check — opens the door to replay attacks with captured payloads.
  • Returning 200 OK before the database write completes — loses events if the process dies between response and commit.
  • Logging the webhook secret in console output or error tracking — secrets in logs end up in monitoring systems with broader access than the application.
Team reviewing code on shared screen
Webhook verification is the kind of code that benefits enormously from a second pair of eyes during code review.

Testing your webhook handler

Before going live, test against three scenarios: a valid signed payload (should succeed), a payload with a tampered body (should 401), and a payload with a stale timestamp (should 401). VyaparGateway's dashboard includes a webhook replay tool so you can re-send any past event for testing without needing to make a real payment. VyaparGateway helps you issue dynamic UPI QR codes, verify payments, and notify your stack via webhooks—without charging a per-transaction platform fee on top of your plan.

Share this guide

Frequently asked questions

Why do I need to verify webhook signatures?
Without signature verification, any attacker who discovers your webhook URL can send forged POST requests to mark arbitrary orders as paid. Signature verification ensures the request was actually generated by your gateway using your shared secret — it's the only way to trust the contents of an incoming webhook.
What is HMAC and why is it used for webhook signatures?
HMAC (Hash-based Message Authentication Code) is a cryptographic construction that produces a signature using a secret key and the message body. Because it requires the secret to produce a valid signature, only your gateway (which knows the secret) can generate signatures that verify. SHA-256 is the typical hash function used; HMAC-SHA256 is the modern standard for webhook signing.
How do I prevent duplicate webhook processing?
Implement idempotency by checking your database before processing: 'is this order already marked paid?' If yes, return 200 OK without re-running the fulfilment logic. The payment intent ID or UTR makes a good idempotency key. This is essential because webhook delivery is at-least-once — the same event can arrive multiple times.
What should my webhook handler return on success and failure?
Return 2xx (typically 200) only after you've successfully persisted the payment to your database. Return 4xx for signature verification failures (so the gateway knows not to retry — it's a bad request). Return 5xx for transient failures like database outages (so the gateway retries). Never return 2xx before committing the write.

Free tools for Indian merchants

No sign-up, no ads, no data selling

Use our free, browser-only tools whenever you need them. We don't store the values you enter or track you across the web.

See all free tools →
VT

About the Author

VyaparGateway Team

Payments Editorial

The VyaparGateway editorial team writes practical, India-first guides on UPI payments, merchant onboarding, and fintech compliance — informed by what we ship, debug, and operate every day at vyapargateway.com.

Related reading

Start with VyaparGateway

Create an account to connect your merchant profile, get API keys, and ship dynamic UPI checkout in minutes.