developer

How to Accept UPI in Headless Commerce: Next.js, Medusa & Shopify

Learn how to build a high-performance headless checkout in Next.js 15, Medusa.js, and Shopify Storefront API with native direct UPI intents and webhooks.

GS Gaurav Sharma Headless E-Commerce & Full-Stack Architect 2 min read
How to Accept UPI in Headless Commerce: Next.js, Medusa & Shopify guide
headless commerce upi payments medusa js upi payment gateway nextjs headless shopify upi headless ecommerce architecture react server components

Modern direct-to-consumer (D2C) brands and tech-first retailers are rapidly moving away from monolithic platforms toward Headless Commerce. By pairing a high-performance frontend framework like Next.js 15 (React 19) with modular backend engines like Medusa.js or the Shopify Storefront GraphQL API, engineering teams achieve instantaneous sub-second page loads and bespoke checkout experiences.

However, integrating Indian payment methods into headless architectures often introduces challenges. Traditional gateway SDKs (like Razorpay standard checkout) rely on client-side modal iframes that degrade performance and disrupt custom headless design systems.

Here is the comprehensive engineering architecture guide for implementing a pure headless UPI checkout in Next.js 15 and Medusa.js with zero third-party commissions.


The Headless Commerce Revolution in India

In high-growth e-commerce, every 100 milliseconds of page latency directly reduces checkout conversion:

Monolithic Storefront (Traditional):
Browser ──► Heavy Server Template Engine ──► Legacy Database ──► Third-Party Iframe Modal (Slow, Bloated)

Headless Storefront (Modern Next.js 15 + Edge):
Browser ──► Static Edge HTML / React Server Components ──► GraphQL / REST ──► Native UPI Intent (Sub-Second)

By decoupling the frontend, your engineering team can design custom checkout modals that match your brand identity while keeping bundle sizes small.


The Challenge: Native UPI in Decoupled Frontends

When implementing UPI in a headless environment, two requirements must be satisfied:

  1. Device-Aware Intent Resolution: On iOS and Android mobile browsers, clicking “Pay” must invoke the OS-level URI scheme (upi://pay?...) to launch Google Pay or PhonePe directly without intermediary redirects.
  2. Asynchronous Order State Synchronization: The frontend needs a real-time reactive trigger (such as WebSockets or Server-Sent Events) to transition the checkout screen to “Order Confirmed” the millisecond the bank webhook arrives.

System Architecture: Medusa.js, VyaparGateway & Next.js 15

[Next.js 15 App Router Frontend]
               │ (1. POST /api/checkout/initiate)
               ▼
[Medusa.js Backend (v2) / Node Engine]
               │ (2. Calls VyaparGateway REST API)
               ▼
       [VyaparGateway Engine] ──► Returns { qrCode, intentUrl, orderId }
               │
               ▼
[Next.js displays Dynamic QR on Desktop / Launches Intent on Mobile]
               │
               ▼ (Customer Authenticates PIN in PhonePe / GPay)
   [Direct Real-Time Bank Settlement (T+0)]
               │
               ▼ (3. Bank Webhook)
[Medusa.js Webhook Handler] ──► Completes Cart & Invoices Order
               │
               ▼ (4. WebSocket / SSE Event: `payment.authorized`)
[Next.js Frontend transitions to /order/confirmed Screen]

Medusa.js Custom Payment Processor Plugin (TypeScript)

In Medusa.js v2, custom payment providers are implemented by extending the AbstractPaymentProcessor class:

import {
  AbstractPaymentProcessor,
  PaymentProcessorContext,
  PaymentProcessorError,
  PaymentProcessorSessionResponse,
  PaymentSessionStatus,
} from "@medusajs/medusa";
import axios from "axios";

export default class VyaparUpiPaymentProcessor extends AbstractPaymentProcessor {
  static identifier = "vyapar-upi";

  protected readonly apiKey: string;
  protected readonly baseUrl: string;

  constructor(container: any, options: any) {
    super(container);
    this.apiKey = options.apiKey || process.env.VYAPAR_API_KEY;
    this.baseUrl = "https://api.vyapargateway.com/v1";
  }

  // 1. Initialize Payment Session when Cart is created
  async initiatePayment(
    context: PaymentProcessorContext
  ): Promise<PaymentProcessorError | PaymentProcessorSessionResponse> {
    const { amount, currency_code, resource_id } = context;

    try {
      const response = await axios.post(
        `${this.baseUrl}/orders/create`,
        {
          amount: amount, // Medusa stores in minor units (divide by 100 if needed)
          orderId: `MEDUSA_${resource_id}`,
          currency: currency_code.toUpperCase(),
        },
        { headers: { Authorization: `Bearer ${this.apiKey}` } }
      );

      return {
        session_data: {
          vyapar_order_id: response.data.orderId,
          qr_code_url: response.data.qr_image_url,
          intent_url: response.data.upi_intent_url,
        },
      };
    } catch (err: any) {
      return {
        error: "Failed to initiate Vyapar UPI transaction",
        code: "VYAPAR_INIT_ERROR",
      };
    }
  }

  // 2. Query status from VyaparGateway
  async getPaymentStatus(
    paymentSessionData: Record<string, unknown>
  ): Promise<PaymentSessionStatus> {
    const orderId = paymentSessionData.vyapar_order_id as string;
    const res = await axios.get(`${this.baseUrl}/orders/${orderId}`, {
      headers: { Authorization: `Bearer ${this.apiKey}` },
    });

    if (res.data.status === "SUCCESS") return PaymentSessionStatus.AUTHORIZED;
    if (res.data.status === "FAILED") return PaymentSessionStatus.ERROR;
    return PaymentSessionStatus.PENDING;
  }
}

Next.js 15 App Router Checkout Component

Here is a clean React Server / Client component pair in Next.js 15 (app/checkout/upi/page.tsx):

"use client";

import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";

interface UpiCheckoutProps {
  orderId: string;
  amount: number;
  qrUrl: string;
  intentUrl: string;
}

export default function HeadlessUpiCheckout({
  orderId,
  amount,
  qrUrl,
  intentUrl,
}: UpiCheckoutProps) {
  const router = useRouter();
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    // Detect if client is a mobile device
    const userAgent = navigator.userAgent || navigator.vendor;
    if (/android|iphone|ipad|ipod/i.test(userAgent.toLowerCase())) {
      setIsMobile(true);
    }

    // Set up SSE / Polling listener for instant payment verification
    const pollInterval = setInterval(async () => {
      const res = await fetch(`/api/checkout/status?orderId=${orderId}`);
      const data = await res.json();

      if (data.status === "PAID") {
        clearInterval(pollInterval);
        router.push(`/order/success?orderId=${orderId}&utr=${data.utr}`);
      }
    }, 2500);

    return () => clearInterval(pollInterval);
  }, [orderId, router]);

  return (
    <div className="flex flex-col items-center justify-center p-8 bg-white rounded-3xl shadow-xl max-w-md mx-auto">
      <h2 className="text-2xl font-bold text-slate-900">Scan to Pay via UPI</h2>
      <p className="text-slate-600 mt-1 text-sm">Amount: ₹{amount.toLocaleString("en-IN")}</p>

      {isMobile ? (
        <div className="mt-6 w-full">
          <a
            href={intentUrl}
            className="flex items-center justify-center w-full py-4 bg-blue-600 text-white font-semibold rounded-2xl shadow-lg active:scale-95 transition-transform"
          >
            ⚡ Pay via Google Pay / PhonePe / Paytm
          </a>
        </div>
      ) : (
        <div className="mt-6 p-4 border border-slate-200 rounded-2xl">
          <Image
            src={qrUrl}
            alt="Dynamic UPI QR Code"
            width={240}
            height={240}
            priority
            className="rounded-lg"
          />
          <p className="text-center text-xs text-slate-400 mt-3 font-mono">
            Auto-expires in 04:59
          </p>
        </div>
      )}
    </div>
  );
}

By connecting your Next.js 15 frontend directly to VyaparGateway, you maintain total design freedom, deliver lightning-fast checkout experiences, and collect customer payments with 0% transaction fees directly into your corporate bank account.

Direct answers

Frequently asked questions

Why do high-growth brands choose headless commerce over monolithic platforms?
Headless commerce separates the presentation frontend (built with Next.js or Remix) from the backend commerce database (Medusa.js or Shopify). This yields sub-second page loads, higher Google Lighthouse scores, custom checkout layouts, and full developer control.
Can Medusa.js accept Indian UPI payments natively?
Medusa.js comes with Stripe and manual payment modules by default. To accept UPI payments with 0% commissions, you can install or write a lightweight custom Medusa payment processor plugin that interacts with VyaparGateway.
How does headless Shopify handle custom UPI checkouts?
With headless Shopify, the cart and catalog live in Next.js using the Shopify Storefront GraphQL API. When the buyer clicks 'Checkout', your Next.js application initiates a direct UPI payment intent via VyaparGateway, captures the bank webhook, and creates the finalized order in Shopify via the Admin API.

Build your payment flow

Explore the API and browser-only merchant tools.

Create UPI checkout orders, verify signed events, or test the free calculators and generators without exposing credentials.