<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Gokul Krishna]]></title><description><![CDATA[Gokul Krishna]]></description><link>https://gokulkrishnags.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 12:09:20 GMT</lastBuildDate><atom:link href="https://gokulkrishnags.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Webhook Signature Verification for Indian Payment Gateways: Common Mistakes and Fixes]]></title><description><![CDATA[Payment gateways tell your backend that money moved by sending webhooks. If you do not verify those requests, anyone who discovers your endpoint can post a…]]></description><link>https://gokulkrishnags.hashnode.dev/webhook-signature-verification-for-indian-payment-gateways-common-mistakes-and-fixes</link><guid isPermaLink="true">https://gokulkrishnags.hashnode.dev/webhook-signature-verification-for-indian-payment-gateways-common-mistakes-and-fixes</guid><category><![CDATA[webhooks]]></category><category><![CDATA[Security]]></category><category><![CDATA[payments]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Gokul Krishna]]></dc:creator><pubDate>Wed, 28 Oct 2026 06:50:22 GMT</pubDate><content:encoded><![CDATA[<p>Payment gateways tell your backend that money moved by sending webhooks. If you do not verify those requests, anyone who discovers your endpoint can post a fake "payment captured" event and unlock an order for free.</p>
<p>Most Indian gateways, including Razorpay, Cashfree and PayU, sign their notifications so you can check that they are genuine. The exact header and signed string differ by provider, but the mistakes developers make are remarkably consistent. This article covers those mistakes, the fixes, and how to capture the raw request body in Express, Django and Next.js.</p>
<h2>How webhook signatures work</h2>
<p>The gateway and your server share a secret. When the gateway sends a webhook, it computes an HMAC of the request body using that secret and sends the result in a header. Your server recomputes the HMAC over the exact bytes it received and compares.</p>
<p>Razorpay's <a href="https://razorpay.com/docs/webhooks/validate-test/">webhook validation guide</a> describes this concretely: the signature is in the X-Razorpay-Signature header, it is an HMAC with SHA256, the key is your webhook secret, and the message is the raw webhook request body.</p>
<pre><code class="language-javascript">const crypto = require('crypto');

function isValidSignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || '');
  return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);
}
</code></pre>
<p>Short, but each line hides a mistake that is easy to make.</p>
<h2>Mistake 1: Verifying parsed JSON instead of the raw body</h2>
<p>This is the most common cause of "signature mismatch" errors. Body-parsing middleware turns the request into an object. Developers then call JSON.stringify on it and hash the result.</p>
<p>The problem is that re-serialised JSON is rarely byte-identical to what was sent. Key order, whitespace, unicode escaping and number formatting can all change. A single different byte produces a completely different HMAC.</p>
<p>Razorpay's docs say this directly: do not parse or cast the webhook request body before validating. The fix is to capture the raw bytes before any parser touches them, verify, and only then parse.</p>
<h2>Mistake 2: Using the wrong secret</h2>
<p>Gateways often have more than one secret, and they are not interchangeable.</p>
<p>With Razorpay, the webhook secret is the value you set when configuring the webhook. It is different from your API key secret. The API key secret is used for a different check: verifying the checkout response, where the <a href="https://razorpay.com/docs/payments/payment-gateway/web-integration/standard/integration-steps/">standard integration steps</a> describe computing an HMAC SHA256 of order_id + "|" + razorpay_payment_id with the key secret and comparing it to razorpay_signature.</p>
<p>Mixing these up produces failures that look random. Also remember that test mode and live mode use separate credentials, so a staging secret will never validate production events.</p>
<h2>Mistake 3: Weak comparison and encoding mismatches</h2>
<p>Comparing signatures with == leaks timing information. Use a constant-time comparison: crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. Note that timingSafeEqual throws if the buffers have different lengths, so check length first, as in the example above.</p>
<p>Also confirm the encoding your provider uses. Razorpay's signature is compared as a hex digest. Some other gateways send base64 and sign a string that includes a timestamp. Read your provider's spec rather than assuming.</p>
<h2>Mistake 4: Careless secret handling</h2>
<ul>
<li>Keep secrets in environment variables or a secrets manager, never in source control.</li>
<li>Never log the secret, and avoid logging full signatures alongside bodies.</li>
<li>Use a long random value when you set the webhook secret.</li>
<li>When rotating, plan a short window where your code accepts either the old or the new secret, then remove the old one.</li>
</ul>
<h2>Replay and duplicate handling</h2>
<p>A valid signature proves the gateway sent the body. It does not prove the body is new.</p>
<p>Gateways deliver webhooks at least once. Razorpay's <a href="https://razorpay.com/docs/webhooks/best-practices/">webhook best practices</a> state that the same event can arrive multiple times, that any non-2xx response counts as a failure, that a response slower than 5 seconds is treated as a timeout and resent, and that retries follow exponential backoff for 24 hours after the event was created.</p>
<p>Each Razorpay webhook carries an x-razorpay-event-id header that is unique per event. Store it with a unique constraint and skip events you have already processed:</p>
<pre><code class="language-python">def process_webhook(event_id, payload):
    try:
        db.execute(
            "INSERT INTO webhook_events (event_id) VALUES (%s)",
            (event_id,),
        )
    except UniqueViolation:
        return "duplicate"
    enqueue(payload)
    return "accepted"
</code></pre>
<p>Because Razorpay's signature covers only the body, the event ID and your own state checks are your main replay defence. Be careful with timestamp-based rejection: since legitimate retries can arrive up to a day later, a tight freshness window will drop real events.</p>
<h2>Capturing the raw body by framework</h2>
<h3>Express</h3>
<p>Register a raw parser on the webhook route only, before any global JSON parser runs for that path:</p>
<pre><code class="language-javascript">app.post('/webhooks/razorpay',
  express.raw({ type: 'application/json' }),
  (req, res) =&gt; {
    const sig = req.get('X-Razorpay-Signature');
    if (!isValidSignature(req.body, sig, process.env.RZP_WEBHOOK_SECRET)) {
      return res.status(400).end();
    }
    const event = JSON.parse(req.body.toString('utf8'));
    queue.add(event);
    res.status(200).end();
  });
</code></pre>
<p>If you must keep a global express.json(), use its verify option to stash the buffer on the request instead.</p>
<h3>Django</h3>
<p>Django exposes the unparsed bytes as request.body. Read it before touching request.POST, and exempt the endpoint from CSRF, since the gateway cannot send a CSRF token:</p>
<pre><code class="language-python">import hmac, hashlib, json
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse

@csrf_exempt
def razorpay_webhook(request):
    sig = request.headers.get("X-Razorpay-Signature", "")
    digest = hmac.new(settings.RZP_WEBHOOK_SECRET.encode(),
                      request.body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(digest, sig):
        return HttpResponse(status=400)
    enqueue(json.loads(request.body))
    return HttpResponse(status=200)
</code></pre>
<p>In Django REST Framework, read request.body before accessing request.data.</p>
<h3>Next.js</h3>
<p>In the App Router, a route handler receives a standard Request. Call await req.text() and verify that string; do not call req.json() first, because the body stream can only be read once.</p>
<p>In the Pages Router, the default body parser must be disabled for the route with export const config = { api: { bodyParser: false } }, and the raw bytes read from the request stream manually.</p>
<h2>A quick checklist</h2>
<ul>
<li>Verify over raw bytes, then parse.</li>
<li>Use the webhook secret for webhooks and the key secret for checkout signatures.</li>
<li>Compare in constant time, with the encoding your provider documents.</li>
<li>Deduplicate by event ID with a database constraint.</li>
<li>Return 2xx quickly and do heavy work in a background queue.</li>
<li>Do not treat a webhook as the only source of truth; fetch the payment from the API when stakes are high.</li>
</ul>
<p>Razorpay's <a href="https://razorpay.com/docs/webhooks/payments/">payment webhook events reference</a> lists the events you will most often verify, such as payment.captured, payment.failed and order.paid.</p>
<p>Get these basics right once, wrap them in a small shared module, and every new webhook you add inherits the same protection.</p>
<p>Disclosure: The author works with the Razorpay team on content.</p>
]]></content:encoded></item><item><title><![CDATA[Implementing UPI AutoPay Subscriptions in India: Mandates, Webhooks and Retry Logic]]></title><description><![CDATA[Recurring billing in India looks simple from the outside: the customer approves once in their UPI app, and you charge them every month. Underneath, there…]]></description><link>https://gokulkrishnags.hashnode.dev/implementing-upi-autopay-subscriptions-in-india-mandates-webhooks-and-retry-logic</link><guid isPermaLink="true">https://gokulkrishnags.hashnode.dev/implementing-upi-autopay-subscriptions-in-india-mandates-webhooks-and-retry-logic</guid><category><![CDATA[payments]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[UPI]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Gokul Krishna]]></dc:creator><pubDate>Wed, 21 Oct 2026 09:12:34 GMT</pubDate><content:encoded><![CDATA[<p>Recurring billing in India looks simple from the outside: the customer approves once in their UPI app, and you charge them every month. Underneath, there is a mandate with its own lifecycle, regulatory rules about notifying the customer before each debit, and a set of asynchronous events that your backend has to process correctly.</p>
<p>This guide walks through the moving parts in a gateway-neutral way. The patterns apply whether you use Razorpay, Cashfree, PayU or another provider. Where a concrete example helps, it uses Razorpay's documented Subscriptions API and webhooks.</p>
<h2>How a UPI AutoPay mandate works</h2>
<p>A UPI AutoPay subscription has two distinct phases.</p>
<p>First, registration. The customer approves a mandate in their UPI app with their UPI PIN. The mandate records the merchant, a maximum amount, a frequency and a validity period. Gateways usually combine this with an authentication payment.</p>
<p>Second, execution. On each billing date, the gateway debits the customer against that mandate without asking for the PIN again, as long as the debit stays inside the mandate's limits and the regulatory thresholds described below.</p>
<p>Most gateways expose this in one of two ways. A managed subscriptions product (plans, subscriptions, invoices) handles scheduling and retries for you. A lower-level recurring API gives you a token for the mandate, and you create each subsequent debit yourself. Razorpay offers both: Subscriptions, and a server-to-server recurring payments API where you create an order and then call the recurring payment endpoint using the mandate's token.</p>
<p>The rest of this article assumes the managed model, because it is what most SaaS and content businesses use.</p>
<h2>The RBI rules your billing code must respect</h2>
<p>On 21 April 2026 the Reserve Bank of India issued the <a href="https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=13374">Digital Payments E-mandate Framework, 2026 master direction</a>, which consolidates earlier circulars on recurring payments across cards, prepaid instruments and UPI. A few provisions directly shape how you design billing.</p>
<h3>AFA-free limits</h3>
<p>Recurring debits up to Rs 15,000 per transaction can be processed without an additional factor of authentication (AFA). Above that, the customer must authenticate the debit. Insurance premiums, mutual fund subscriptions and credit card bill payments have a higher AFA-free limit of Rs 1,00,000 per transaction.</p>
<p>In practice: if your plan price, plus add-ons, plus any usage component, can cross the applicable limit, the debit will need customer action and may not complete on the billing date. Design pricing tiers and upgrade flows with that ceiling in mind.</p>
<h3>Pre-debit notification</h3>
<p>The issuer must notify the customer at least 24 hours before the actual debit. The notification carries details such as the merchant name, amount, date and time of debit and the mandate reference, and it gives the customer a way to opt out.</p>
<p>For your code, this means a charge is never instant. If you change a customer's amount on the billing date, the notified amount and the debited amount can drift. Lock the amount for a cycle before the notification window opens.</p>
<h3>Customer opt-out and mandate withdrawal</h3>
<p>Customers can opt out of a single debit or withdraw the mandate entirely, from their UPI app or bank, at any time. Your system will learn about this only through gateway events, so treat cancellation as something that can happen outside your product at any moment.</p>
<p>The <a href="https://www.npci.org.in/product/autopay/all-members">NPCI list of banks and apps live on AutoPay</a> is also worth checking when customers report that their app does not support mandates.</p>
<h2>Modelling the subscription lifecycle</h2>
<p>Do not reduce a subscription to an <code>is_active</code> boolean. Mirror the gateway's states so that support, billing and access control all reason about the same thing.</p>
<p>Razorpay documents these <a href="https://razorpay.com/docs/payments/subscriptions/states/">subscription states</a>: created, authenticated, active, pending, halted, paused, cancelled, expired and completed. Other gateways use different names, but the concepts map closely:</p>
<ul>
<li><p><strong>created</strong> and <strong>authenticated</strong>: mandate setup in progress or approved, billing not yet started</p>
</li>
<li><p><strong>active</strong>: charges are succeeding</p>
</li>
<li><p><strong>pending</strong>: the latest charge failed and retries are running</p>
</li>
<li><p><strong>halted</strong>: retries are exhausted</p>
</li>
<li><p><strong>cancelled</strong>, <strong>expired</strong>, <strong>completed</strong>: terminal states</p>
</li>
</ul>
<p>Decide product access per state explicitly. A common choice is to keep access during pending (a grace period) and restrict it at halted. Whatever you choose, write it down and enforce it in one place.</p>
<h2>Subscription webhooks: what to listen for</h2>
<p>Razorpay's <a href="https://razorpay.com/docs/webhooks/subscriptions/">subscription webhook events</a> include <code>subscription.authenticated</code>, <code>subscription.activated</code>, <code>subscription.charged</code>, <code>subscription.pending</code>, <code>subscription.halted</code>, <code>subscription.paused</code>, <code>subscription.resumed</code>, <code>subscription.cancelled</code>, <code>subscription.completed</code> and <code>subscription.updated</code>. Each payload contains the subscription entity, plus a payment entity when a payment attempt was made before the event fired.</p>
<p>A minimal Node.js handler, after signature verification and deduplication (covered in companion articles), might look like this:</p>
<pre><code class="language-javascript">async function handleSubscriptionEvent(evt) {
  const sub = evt.payload.subscription.entity;
  const payment = evt.payload.payment?.entity;

  switch (evt.event) {
    case 'subscription.charged':
      await recordCharge(sub.id, payment.id, payment.amount);
      await setState(sub.id, 'active');
      break;
    case 'subscription.pending':
      await setState(sub.id, 'pending');
      await notifyPaymentIssue(sub.id);
      break;
    case 'subscription.halted':
      await setState(sub.id, 'halted');
      await restrictAccess(sub.id);
      break;
    case 'subscription.cancelled':
    case 'subscription.completed':
      await setState(sub.id, evt.event.split('.')[1]);
      break;
    default:
      await setState(sub.id, sub.status);
  }
}
</code></pre>
<p>Two details matter here. Use the status inside the entity as the source of truth when in doubt, because webhooks can arrive out of order. And record each charge keyed by the payment ID, so a duplicate <code>subscription.charged</code> event does not extend access twice.</p>
<h2>Handling pending, halted and failed charges</h2>
<h3>Know your provider's retry schedule</h3>
<p>When an automatic charge fails, the gateway retries before giving up. Razorpay's <a href="https://razorpay.com/docs/payments/subscriptions/payment-retries/">payment retries documentation</a> describes a T+3 day model for cards: the subscription moves to pending after the first failure, the charge is retried once a day for three days, and if all retries fail, the subscription moves to halted.</p>
<p>Retry behaviour can differ by payment method, and every UPI debit is subject to the pre-debit notification rule, so confirm the UPI-specific behaviour with your provider before you promise customers a timeline.</p>
<h3>Communicate during pending</h3>
<p>The pending window is your best chance to recover revenue. On <code>subscription.pending</code>, send a short message explaining that the payment did not go through and that it will be retried. Common causes include insufficient balance, a paused or revoked mandate, or an amount that needs authentication.</p>
<h3>Recovering from halted</h3>
<p>In Razorpay, a halted subscription still generates invoices for each billing cycle, but no automatic charge is attempted. Recovery requires the customer to authenticate again or to pay older unpaid invoices manually. Once the subscription returns to active, past charges are not retried automatically; only future cycles are charged.</p>
<p>Razorpay's FAQs also note that only card-authorised subscriptions can be updated, not those authorised via UPI or e-mandate. For UPI customers, plan a clean re-subscription flow: a "fix payment" link that creates a new mandate, and logic that settles outstanding invoices separately.</p>
<pre><code class="language-python">def on_halted(sub_id):
    mark_state(sub_id, "halted")
    restrict_access(sub_id)
    unpaid = list_unpaid_invoices(sub_id)
    send_recovery_link(sub_id, outstanding=sum(i.amount for i in unpaid))
</code></pre>
<h2>Testing checklist</h2>
<ul>
<li><p>Simulate each state transition in test mode and confirm access rules update correctly.</p>
</li>
<li><p>Send the same webhook twice and confirm no double credit or double email.</p>
</li>
<li><p>Deliver events out of order, for example charged before activated.</p>
</li>
<li><p>Test a plan price above Rs 15,000 to see how AFA affects the flow.</p>
</li>
<li><p>Cancel the mandate from the UPI app side and confirm your system reacts.</p>
</li>
</ul>
<h2>Wrapping up</h2>
<p>A reliable UPI AutoPay integration is mostly about respecting two clocks: the regulatory clock of pre-debit notifications and authentication limits, and the asynchronous clock of webhooks and retries. Model states explicitly, key every side effect by a stable ID, and design for customers who cancel outside your product.</p>
<p><em>Disclosure: The author works with the Razorpay team on content.</em></p>
]]></content:encoded></item></channel></rss>