<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged razorpay</title>
<link>https://coderlegion.com/tag/razorpay</link>
<description>Powered by Question2Answer</description>
<item>
<title>Verifying Razorpay Payments: The Order ID You Shouldn't Trust</title>
<link>https://coderlegion.com/33032/verifying-razorpay-payments-the-order-id-you-shouldnt-trust</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A valid Razorpay signature proves the payment is real. It does &lt;em&gt;not&lt;/em&gt; prove the payment belongs to the order you're about to mark as paid. Verify against the Razorpay order ID you stored on the server, never the one the client sends, and back it up with a webhook.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Razorpay's checkout flow ends with your server checking a signature. Most tutorials show that check in a few lines, and the check itself is correct. But I recently reviewed a store backend before launch where the check passed and still let one payment mark a &lt;em&gt;different&lt;/em&gt; order as paid. The bug wasn't in the cryptography. It was in where one input came from.&lt;/p&gt;
&lt;h3 id=&quot;how-the-flow-works&quot;&gt;How the flow works&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Your server creates a Razorpay order for the amount and stores its ID (&lt;code&gt;order_xyz&lt;/code&gt;) on your own order.&lt;/li&gt;
&lt;li&gt;The browser opens Razorpay Checkout with that order ID.&lt;/li&gt;
&lt;li&gt;On success, Checkout hands the browser three values: &lt;code&gt;razorpay_order_id&lt;/code&gt;, &lt;code&gt;razorpay_payment_id&lt;/code&gt; and &lt;code&gt;razorpay_signature&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The browser sends them to your server, which checks that the signature is &lt;code&gt;HMAC-SHA256(order_id + &quot;|&quot; + payment_id, key_secret)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Only Razorpay and your server know &lt;code&gt;key_secret&lt;/code&gt;, so a valid signature proves that Razorpay processed that payment for that Razorpay order.&lt;/p&gt;
&lt;h3 id=&quot;the-bug&quot;&gt;The bug&lt;/h3&gt;
&lt;p&gt;The endpoint looked roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def verify_payment(request):
    order = Order.objects.get(id=request.data[&quot;order_id&quot;], user=request.user)
    rzp_order_id = request.data[&quot;razorpay_order_id&quot;]      # from the client
    rzp_payment_id = request.data[&quot;razorpay_payment_id&quot;]
    signature = request.data[&quot;razorpay_signature&quot;]

    expected = hmac.new(secret, f&quot;{rzp_order_id}|{rzp_payment_id}&quot;.encode(),
                        hashlib.sha256).hexdigest()
    if expected != signature:
        return error(&quot;Payment verification failed&quot;)

    order.payment_status = &quot;paid&quot;
    order.save()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every Razorpay value is checked, but nothing ties them to &lt;em&gt;this&lt;/em&gt; order. The internal &lt;code&gt;order_id&lt;/code&gt; and the Razorpay order ID both come from the client, and nothing checks that they belong together.&lt;/p&gt;
&lt;p&gt;So a buyer can:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a cheap order A and pay ₹99 for it.&lt;/li&gt;
&lt;li&gt;Create an expensive order B and close the checkout without paying.&lt;/li&gt;
&lt;li&gt;Send A's genuine &lt;code&gt;razorpay_order_id&lt;/code&gt;, &lt;code&gt;razorpay_payment_id&lt;/code&gt; and &lt;code&gt;razorpay_signature&lt;/code&gt; with B's internal &lt;code&gt;order_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The signature is genuine, so it verifies, and order B is marked as paid.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The fix&lt;/h3&gt;
&lt;p&gt;Verify against the Razorpay order ID you stored when you created the order, not one the client sends. Compare in constant time, and make the update idempotent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import hashlib
import hmac

from django.conf import settings
from django.db import transaction


@transaction.atomic  # required: select_for_update() only locks inside a transaction
def verify_payment(request):
    order = Order.objects.select_for_update().get(
        id=request.data[&quot;order_id&quot;], user=request.user
    )
    if order.payment_status == &quot;paid&quot;:
        return ok(order)  # idempotent: retries and double-clicks are fine

    payment_id = request.data.get(&quot;razorpay_payment_id&quot;, &quot;&quot;)
    signature = request.data.get(&quot;razorpay_signature&quot;, &quot;&quot;)
    expected = hmac.new(
        settings.RAZORPAY_KEY_SECRET.encode(),
        f&quot;{order.razorpay_order_id}|{payment_id}&quot;.encode(),  # stored, not sent
        hashlib.sha256,
    ).hexdigest()

    if not order.razorpay_order_id or not hmac.compare_digest(expected, signature):
        return error(&quot;Payment verification failed&quot;)

    order.payment_status = &quot;paid&quot;
    order.razorpay_payment_id = payment_id
    order.save(update_fields=[&quot;payment_status&quot;, &quot;razorpay_payment_id&quot;])
    return ok(order)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you use the official SDK, &lt;code&gt;client.utility.verify_payment_signature(...)&lt;/code&gt; does the HMAC for you. Just pass it &lt;strong&gt;your stored&lt;/strong&gt; &lt;code&gt;razorpay_order_id&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;don-t-rely-on-the-browser-at-all-add-the-webhook&quot;&gt;Don't rely on the browser at all: add the webhook&lt;/h3&gt;
&lt;p&gt;Even a correct verify endpoint depends on the buyer's browser getting back to your site. People close tabs, networks drop, and phones lock mid-redirect. The money arrives, but the order stays &quot;pending&quot;.&lt;/p&gt;
&lt;p&gt;Razorpay webhooks fix that. Subscribe to &lt;code&gt;payment.captured&lt;/code&gt; (or &lt;code&gt;order.paid&lt;/code&gt;) and treat the webhook as the source of truth:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import hashlib
import hmac
import json

from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


@csrf_exempt
@require_POST
def razorpay_webhook(request):
    body = request.body  # raw bytes, before any JSON parsing
    received = request.headers.get(&quot;X-Razorpay-Signature&quot;, &quot;&quot;)
    expected = hmac.new(settings.RAZORPAY_WEBHOOK_SECRET.encode(), body,
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        return HttpResponse(status=400)

    event = json.loads(body)
    if event[&quot;event&quot;] == &quot;payment.captured&quot;:
        payment = event[&quot;payload&quot;][&quot;payment&quot;][&quot;entity&quot;]
        Order.objects.filter(
            razorpay_order_id=payment[&quot;order_id&quot;], payment_status=&quot;pending&quot;
        ).update(payment_status=&quot;paid&quot;, razorpay_payment_id=payment[&quot;id&quot;])
    return HttpResponse(status=200)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The signature is computed over the &lt;strong&gt;raw body&lt;/strong&gt; with the &lt;strong&gt;webhook secret&lt;/strong&gt;, which is a different secret from your API key secret.&lt;/li&gt;
&lt;li&gt;The update is keyed by the Razorpay order ID that Razorpay itself sent, and it only moves &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;paid&lt;/code&gt;, so duplicate deliveries do nothing. (If the handler does more than a status update, such as sending emails or reducing stock, also de-duplicate on the &lt;code&gt;X-Razorpay-Event-Id&lt;/code&gt; header.)&lt;/li&gt;
&lt;li&gt;The browser flow and the webhook can now race safely. Whichever arrives first marks the order paid, and the other is a no-op.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;checklist&quot;&gt;Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;[ ] The amount is set on the server when the Razorpay order is created, never taken from the client.&lt;/li&gt;
&lt;li&gt;[ ] The stored &lt;code&gt;razorpay_order_id&lt;/code&gt; is used for verification, not the one in the request.&lt;/li&gt;
&lt;li&gt;[ ] Signatures are compared with &lt;code&gt;hmac.compare_digest&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Marking an order as paid is idempotent.&lt;/li&gt;
&lt;li&gt;[ ] A &lt;code&gt;payment.captured&lt;/code&gt; webhook with its own secret is the source of truth.&lt;/li&gt;
&lt;li&gt;[ ] There's a reconciliation job for orders stuck in &quot;pending&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Payment bugs rarely come from broken crypto. They come from trusting an input that looked like it was already verified.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I'm Paritosh, a lead full-stack engineer working with React, Next.js, Django and FastAPI. I write about the bugs and design decisions I run into while shipping products. This post first appeared on &lt;a rel=&quot;nofollow&quot; href=&quot;https://paritosh-dev.vercel.app/blog/verifying-razorpay-payments-safely&quot;&gt;my blog&lt;/a&gt;. More of my work: &lt;a rel=&quot;nofollow&quot; href=&quot;https://paritosh-dev.vercel.app&quot;&gt;portfolio&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pk2502&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33032/verifying-razorpay-payments-the-order-id-you-shouldnt-trust</guid>
<pubDate>Fri, 30 Oct 2026 03:57:57 +0000</pubDate>
</item>
</channel>
</rss>