4 MIN READ

Razorpay with Medusa: Payments for the Indian Market

UPI, netbanking, RuPay and the mandate rules that make Indian payments their own discipline. Integrating Razorpay with Medusa properly.

BY SHUBHAM VERMAUPDATED
Illustration for “Razorpay with Medusa: Payments for the Indian Market” — Payments

We are based in India, so we build on Razorpay often. If your customers are Indian, cards are a minority of your volume — UPI dominates, netbanking and wallets take much of the rest — and a checkout designed around card entry will underperform badly.

Why the mix differs

MethodTypical shareNotes
UPILargestInstant, near-zero cost, app-based approval
CardsModerateTokenisation rules apply
NetbankingModerateBank redirect; slower flow
WalletsSmallerPaytm, PhonePe and others
Cash on deliveryCategory-dependentHandled outside the PSP

Designing for cards first is the most common mistake in a first Indian checkout. Put UPI at the top, make it the default, and treat cards as one option among several.

Configuration

medusa-config.tsts
{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      {
        resolve: "./src/modules/payment-razorpay",
        id: "razorpay",
        options: {
          keyId: process.env.RAZORPAY_KEY_ID,
          keySecret: process.env.RAZORPAY_KEY_SECRET,
          webhookSecret: process.env.RAZORPAY_WEBHOOK_SECRET,
          autoCapture: false,
        },
      },
    ],
  },
}

Razorpay has no first-party Medusa module; use a community provider or write one against their Orders API. Enable it for your India region in the admin.

Amounts

Razorpay works in paise — ₹499.00 is 49900. Medusa also stores minor units, so the mapping is one to one, but verify it with a real transaction in test mode before launch. A factor-of-100 error either charges ₹4.99 for a ₹499 product or ₹49,900, and both are memorable.

Checkout

Razorpay's flow is a hosted modal:

components/RazorpayCheckout.tsxtsx
"use client"

import { useEffect, useState } from "react"

export function RazorpayCheckout({
  cart,
  sessionData,
  onPaid,
}: {
  cart: StoreCart
  sessionData: { razorpay_order_id: string }
  onPaid: (payload: Record<string, string>) => Promise<void>
}) {
  const [ready, setReady] = useState(false)

  useEffect(() => {
    const script = document.createElement("script")
    script.src = "https://checkout.razorpay.com/v1/checkout.js"
    script.onload = () => setReady(true)
    document.body.appendChild(script)
    return () => { document.body.removeChild(script) }
  }, [])

  const open = () => {
    const rzp = new (window as any).Razorpay({
      key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
      order_id: sessionData.razorpay_order_id,
      name: "Your Store",
      prefill: {
        email: cart.email,
        contact: cart.shipping_address?.phone,
        name: `${cart.shipping_address?.first_name ?? ""} ${cart.shipping_address?.last_name ?? ""}`.trim(),
      },
      // Signature verification happens server-side; never trust this payload alone.
      handler: (response: Record<string, string>) => onPaid(response),
      modal: {
        ondismiss: () => {
          // Customer closed the modal. The cart must survive intact.
        },
      },
      theme: { color: "#111111" },
    })
    rzp.open()
  }

  return (
    <button onClick={open} disabled={!ready}>
      {ready ? "Pay with UPI, card or netbanking" : "Loading…"}
    </button>
  )
}

Prefilling email and phone matters more here than elsewhere: UPI flows key off the phone number, and an empty field costs conversions on mobile.

Verify server-side

The handler payload is client-supplied. Verify the signature before treating anything as paid:

lib/verify-razorpay.tsts
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyRazorpaySignature(
  orderId: string,
  paymentId: string,
  signature: string,
  secret: string,
) {
  const expected = createHmac("sha256", secret)
    .update(`${orderId}|${paymentId}`)
    .digest("hex")

  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  return a.length === b.length && timingSafeEqual(a, b)
}

Then authorise the Medusa payment session and complete the cart. Skipping verification means anyone can post a fake success payload and receive goods.

Webhooks

text
Endpoint: https://api.yourdomain.com/hooks/payment/razorpay_razorpay

Events:
  payment.authorized
  payment.captured
  payment.failed
  refund.processed
  order.paid

UPI confirmations are asynchronous and occasionally slow — a customer can approve in their banking app after your page has timed out. Webhooks are what turn that into a completed order rather than a support ticket.

Recurring payments

You cannot store a card and charge it later. Recurring collection requires an e-mandate — UPI AutoPay or a card mandate — that the customer authorises once, with a stated maximum amount, and which sends pre-debit notifications before each charge.

For subscriptions, that means:

  • Create the mandate at signup, not at first renewal.
  • Store the mandate id, not payment details.
  • Handle mandate revocation — customers can cancel from their bank app, and your first sign of it will be a failed charge.
  • Respect the mandate's maximum amount. Price rises above it need a new mandate.

Plan this before building subscriptions in India. Retrofitting mandates onto a card-token model means re-enrolling every subscriber.

Refunds

Refunds go back through Razorpay and settle to the original method. UPI refunds are usually quick; card refunds take the usual banking cycle. Set expectations in your refund emails — "5 to 7 working days" prevents a large share of follow-up tickets.

Compliance notes

Indian payments are more regulated than most markets, and the rules move. Card storage and tokenisation, mandate requirements, and export-of-services documentation for international sales all have specific requirements. Confirm the current position with your PSP and your accountant rather than relying on a blog post — including this one.

Cash on delivery

Still a meaningful share of Indian ecommerce, and it is not a payment provider — it is an order that completes without payment and collects later.

Model it as a manual payment provider:

ts
{
  resolve: "@medusajs/medusa/payment",
  options: {
    providers: [
      { resolve: "@medusajs/medusa/payment-system", id: "manual" },
    ],
  },
}

Then the operational questions, which matter more than the code:

  • Who is eligible? COD fraud and refusal rates are real. Most brands restrict it by order value, pin code, or customer history.
  • When is payment recorded? On courier remittance, not on delivery. Build the reconciliation against the courier's settlement file.
  • What about returns? A refused COD delivery costs you shipping both ways with no revenue. Track the rate per pin code and restrict the worst.

A partial-prepaid model — a small prepaid deposit plus COD balance — cuts refusal rates sharply and is worth testing.

GST and invoicing

Indian sales need a GST-compliant invoice with your GSTIN, HSN codes per line, and the correct split between CGST/SGST for intra-state and IGST for inter-state sales. That split depends on the ship-to state relative to your registered state, which makes it a per-order calculation rather than a fixed rate.

Model HSN codes as a field on a product module rather than in metadata — you will report on them. And confirm current requirements with your accountant; the rules here change more often than most tax regimes. Tax configuration.

Settlement and reconciliation

Razorpay settles on a cycle — typically T+2 or T+3 depending on your account — net of fees and GST on those fees. Two consequences for your books.

Match settlements to orders, not to days. A single settlement covers many orders across a window, so reconcile against the settlement report's transaction list rather than assuming a day's orders equal a day's deposit.

Record the fee per transaction. Razorpay's rate differs by method: UPI is close to free, cards cost more, international cards more again. Storing a blended assumption will misstate per-order margin, and UPI-heavy stores in particular will understate it significantly.

Pull the settlement report daily via API and store it alongside your payments. It is an hour of work and it removes a recurring monthly one.


We build for the Indian market from India. Ask us about it.

Frequently asked questions

Does Medusa support Razorpay?

Not as a first-party module. Use a community provider or write one against Razorpay's Orders API with Medusa's payment provider interface, then register and enable it for your India region.

What payment methods should an Indian checkout offer?

UPI first and as the default, then cards, netbanking and wallets, with cash on delivery if your category expects it. UPI carries the largest share of volume, and a card-first checkout underperforms noticeably.

How are amounts handled with Razorpay?

In paise, the currency's minor unit, which matches how Medusa stores amounts. Verify the mapping with a real test transaction before launch, because a factor-of-100 error is easy to make and expensive to discover.

How do recurring payments work with Razorpay?

Through e-mandates such as UPI AutoPay or card mandates, authorised once by the customer with a maximum amount and accompanied by pre-debit notifications. You store a mandate id rather than payment details, and must handle revocation.

Do I need Razorpay webhooks?

Yes. UPI confirmations arrive asynchronously and can land after the customer has left the page. Without webhooks those orders never complete in Medusa even though the money moved.

How do I verify a Razorpay payment server-side?

Compute an HMAC-SHA256 of `order_id|payment_id` with your key secret and compare it to the returned signature using a timing-safe comparison. Never treat the client-supplied handler payload as proof of payment on its own.

[ Keep reading ]