r.K
Back to blog

Razorpay UPI Intent Flow for Recurring Payments on Android & iOS with Capacitor

/
RazorpayUPICapacitorAndroidiOSPayments

Disclaimer: The code examples below are from a working production app but are simplified for clarity. Test thoroughly in your own environment before shipping.

Razorpay's documentation for recurring payments is scattered, incomplete, and at times contradictory. If you're trying to implement UPI Intent flow for charge-at-will (token-based) recurring payments inside a Capacitor hybrid app, you're mostly on your own. We spent weeks figuring this out. This post is what we wish existed when we started.

The Problem

We run a subscription service where we charge users at will — we create a Razorpay order 24 hours before the billing date and charge against a stored UPI mandate token. The initial mandate authorization (where the user sets up autopay) needs to happen via UPI Intent — meaning the user picks their UPI app (Google Pay, PhonePe, etc.), the app opens, they approve the mandate, and they return to our app.

Razorpay offers two SDKs for mobile:

  • Standard Checkout — a full-screen modal that handles everything (UI, payment method selection, UPI app picker). Easy to integrate, but you have zero control over the UX.
  • Custom Checkout (Custom UI SDK) — you build the UI, the SDK handles the payment submission. Full control, but the docs are sparse.

We needed Custom Checkout because Standard Checkout's UPI flow was unreliable in our Capacitor WebView setup (it would try to open Chrome, crash on emulators, or lose callbacks). Custom Checkout lets us launch UPI apps directly via Android Intents / iOS URL schemes.

Architecture Overview

The flow works like this:

  1. JS layer calls our server to create a Razorpay order (with mandate/token setup)
  2. JS layer shows a UPI app picker (native plugin detects installed apps)
  3. User taps an app → JS calls native plugin → plugin uses Razorpay Custom UI SDK to submit the payment
  4. SDK launches the UPI app via intent
  5. User approves mandate in UPI app → returns to our app
  6. Razorpay SDK fires success/error callback → native plugin resolves/rejects the JS promise
  7. Server receives webhook confirming the token is created

The key pieces:

  • A Capacitor native plugin (Java for Android, Swift for iOS) wrapping Razorpay's Custom UI SDK
  • A TypeScript plugin interface bridging JS ↔ native
  • A React UPI app picker component with payment status overlay
  • A server endpoint creating recurring orders

Part 1: The Capacitor Plugin Interface (TypeScript)

This defines the contract between JS and native. Both Android and iOS plugins implement these methods.

// plugins/UPIPayment.ts
import { registerPlugin } from '@capacitor/core';

export interface UPIApp {
  packageName: string;
  appName: string;
  icon: string; // base64 encoded PNG
  iconUrl?: string;
}

export interface CustomPaymentOptions {
  key: string;
  order_id: string;
  customer_id: string;
  recurring?: string;
  currency?: string;
  amount?: number;
  method: string;
  email: string;
  contact: string;
  upi_app_package_name?: string;
  notes?: Record<string, string>;
}

export interface CheckoutResult {
  razorpay_payment_id: string;
  razorpay_order_id: string;
  razorpay_signature: string;
}

export interface UPIPaymentPlugin {
  getInstalledUPIApps(): Promise<{ apps: UPIApp[] }>;
  submitCustomPayment(options: CustomPaymentOptions): Promise<CheckoutResult>;
  openCheckout(options: CheckoutOptions): Promise<CheckoutResult>; // fallback
}

export const UPIPayment = registerPlugin<UPIPaymentPlugin>('UPIPayment', {
  web: () => import('./UPIPaymentWeb').then(m => new m.UPIPaymentWeb()),
});

The web implementation returns empty apps (which triggers the Standard Checkout fallback on web). Native platforms return real data.

Part 2: Android Plugin (Java)

This is the core of the Android implementation. Three key methods:

Detecting Installed UPI Apps

@CapacitorPlugin(name = "UPIPayment")
public class UPIPayment extends Plugin {

    // Apps to exclude from UPI picker
    private static final Set<String> UPI_APP_BLOCKLIST = new HashSet<>(Arrays.asList(
        "com.whatsapp",        // WhatsApp — poor UPI mandate support
        "com.whatsapp.w4b",
        "in.amazon.mShop.android.shopping"  // Amazon — doesn't handle recurring
    ));

    @PluginMethod
    public void getInstalledUPIApps(PluginCall call) {
        Intent upiIntent = new Intent(Intent.ACTION_VIEW);
        upiIntent.setData(Uri.parse("upi://pay"));

        PackageManager pm = getContext().getPackageManager();
        List<ResolveInfo> resolveInfoList = pm.queryIntentActivities(upiIntent, 0);

        List<JSObject> appList = new ArrayList<>();
        Set<String> seen = new HashSet<>();

        for (ResolveInfo info : resolveInfoList) {
            String packageName = info.activityInfo.packageName;
            if (seen.contains(packageName)) continue;
            seen.add(packageName);
            if (UPI_APP_BLOCKLIST.contains(packageName)) continue;

            JSObject app = new JSObject();
            app.put("packageName", packageName);
            app.put("appName", info.loadLabel(pm).toString());

            // Convert app icon to base64 PNG for display in JS
            Drawable icon = info.loadIcon(pm);
            Bitmap bitmap = drawableToBitmap(icon);
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
            app.put("icon", Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP));

            appList.add(app);
        }

        // Sort by payment success rate ranking
        appList.sort((a, b) -> {
            int rankA = UPI_APP_RANK.getOrDefault(a.getString("packageName"), Integer.MAX_VALUE);
            int rankB = UPI_APP_RANK.getOrDefault(b.getString("packageName"), Integer.MAX_VALUE);
            return Integer.compare(rankA, rankB);
        });

        JSArray apps = new JSArray();
        for (JSObject app : appList) apps.put(app);

        JSObject result = new JSObject();
        result.put("apps", apps);
        call.resolve(result);
    }
}

Key detail: We query for upi://pay intent handlers, deduplicate by package name, filter out blocklisted apps, encode icons as base64 (so JS can render them without network requests), and sort by a priority ranking based on payment success rates.

Submitting Custom Payments (UPI Intent)

This is where it gets tricky. The Razorpay Custom UI SDK has several undocumented requirements:

// Static references — survive the UPI app roundtrip
private static PluginCall pendingCheckoutCall;
private static Razorpay razorpayInstance;
private static android.webkit.WebView paymentWebView;

@PluginMethod
public void submitCustomPayment(PluginCall call) {
    String key = call.getString("key");

    JSONObject payload = new JSONObject();
    payload.put("order_id", call.getString("order_id"));
    payload.put("customer_id", call.getString("customer_id"));
    payload.put("recurring", call.getString("recurring"));
    payload.put("currency", call.getString("currency"));
    payload.put("amount", call.getData().get("amount"));
    payload.put("method", call.getString("method"));
    payload.put("email", call.getString("email"));
    payload.put("contact", call.getString("contact"));

    // UPI Intent flow — these two fields are critical
    if (call.getString("upi_app_package_name") != null) {
        payload.put("upi_app_package_name", call.getString("upi_app_package_name"));
        payload.put("_[flow]", "intent");  // undocumented but required
    }

    pendingCheckoutCall = call;

    getActivity().runOnUiThread(() -> {
        // GOTCHA #1: Custom UI SDK requires a WebView in the view hierarchy.
        // An unattached WebView can't process JS or lifecycle events, so
        // the SDK never fires callbacks when returning from UPI app.
        paymentWebView = new android.webkit.WebView(getActivity());
        paymentWebView.getSettings().setJavaScriptEnabled(true);
        paymentWebView.getSettings().setDomStorageEnabled(true);
        paymentWebView.setVisibility(android.view.View.INVISIBLE);

        android.view.ViewGroup rootView = (android.view.ViewGroup)
            getActivity().findViewById(android.R.id.content);
        rootView.addView(paymentWebView, new android.widget.FrameLayout.LayoutParams(1, 1));

        razorpayInstance = new Razorpay(getActivity());
        razorpayInstance.setWebView(paymentWebView);
        razorpayInstance.submit(payload, (PaymentResultWithDataListener) getActivity());
    });
}

Gotcha #1: The hidden WebView. The Custom UI SDK internally uses a WebView for its JS bridge. If you don't provide one that's attached to the view hierarchy, the SDK silently fails — no callbacks fire when the user returns from the UPI app. We add an invisible 1x1px WebView to the root layout. This is not documented anywhere.

Gotcha #2: Static references. When the UPI app opens, Android may garbage-collect your plugin instance's local variables. The Razorpay instance, the WebView, and the pending PluginCall must all be static to survive the roundtrip.

Gotcha #3: The _[flow] parameter. To trigger UPI Intent (where the SDK launches the UPI app directly), you must pass "_[flow]": "intent" in the payload. This is barely mentioned in Razorpay's docs and easy to miss.

MainActivity Integration

Your MainActivity must implement PaymentResultWithDataListener and forward callbacks:

public class MainActivity extends BridgeActivity implements PaymentResultWithDataListener {

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        // Forward to Razorpay SDK so it can process the UPI app's return
        UPIPayment.handleActivityResult(requestCode, resultCode, data);
    }

    @Override
    public void onPaymentSuccess(String paymentId, PaymentData data) {
        UPIPayment.handlePaymentSuccess(paymentId, data);
    }

    @Override
    public void onPaymentError(int code, String description, PaymentData data) {
        UPIPayment.handlePaymentError(code, description);
    }
}

Gotcha #4: onActivityResult forwarding. When the user returns from the UPI app, Android calls onActivityResult on the Activity. You must forward this to the Razorpay instance so it can process the result and fire onPaymentSuccess/onPaymentError. Without this, the SDK hangs indefinitely.

Cleanup

Always clean up the WebView after payment completes:

private static void cleanupPaymentWebView() {
    if (paymentWebView != null) {
        android.view.ViewParent parent = paymentWebView.getParent();
        if (parent instanceof android.view.ViewGroup) {
            ((android.view.ViewGroup) parent).removeView(paymentWebView);
        }
        paymentWebView.destroy();
        paymentWebView = null;
    }
    razorpayInstance = null;
}

Gradle Dependencies

// build.gradle (app)
dependencies {
    implementation 'com.razorpay:customui:3.9.22'   // Custom UI SDK for UPI Intent
    implementation 'com.razorpay:checkout:1.6.40'    // Standard Checkout for fallback
}

You need both — Custom UI for the UPI Intent flow, Standard Checkout as a fallback when no UPI apps are detected.

Part 3: iOS Plugin (Swift)

iOS has its own set of challenges. The Razorpay iOS SDK works differently from Android.

Detecting UPI Apps on iOS

On Android, you query the package manager. On iOS, Razorpay provides an SDK method:

@objc func getInstalledUPIApps(_ call: CAPPluginCall) {
    DispatchQueue.main.async {
        // IMPORTANT: Use getAppsWhichSupportUpiRecurring, NOT getAppsWhichSupportUpi
        // The "recurring" variant returns apps that support mandate/autopay mode
        RazorpayCheckout.getAppsWhichSupportUpiRecurring { sdkApps in
            var apps: [[String: Any]] = []

            for sdkApp in sdkApps {
                apps.append([
                    "packageName": sdkApp["appPackageName"] as? String ?? "",
                    "appName": sdkApp["appName"] as? String ?? "",
                    "icon": sdkApp["appIconBase64"] as? String ?? "",
                    "iconUrl": sdkApp["appLogo"] as? String ?? "",
                ])
            }

            call.resolve(["apps": apps])
        }
    }
}

Gotcha #5: getAppsWhichSupportUpiRecurring vs getAppsWhichSupportUpi. These are two different SDK methods. The regular one returns apps for one-time payments. The recurring variant returns apps that support mandate authorization (autopay). If you use the wrong one, the UPI app will open in regular payment mode and mandate creation fails silently.

Custom Payment Submission on iOS

@objc func submitCustomPayment(_ call: CAPPluginCall) {
    guard let key = call.getString("key"), !key.isEmpty else {
        call.reject("key is required")
        return
    }

    pendingCall = call

    var payload: [String: Any] = [:]
    if let orderId = call.getString("order_id") { payload["order_id"] = orderId }
    if let customerId = call.getString("customer_id") { payload["customer_id"] = customerId }

    // GOTCHA #6: iOS SDK expects recurring as integer 1, NOT string "1"
    if call.getString("recurring") != nil { payload["recurring"] = 1 }

    if let currency = call.getString("currency") { payload["currency"] = currency }
    if let amount = call.getDouble("amount") { payload["amount"] = amount }
    if let method = call.getString("method") { payload["method"] = method }
    if let email = call.getString("email") { payload["email"] = email }
    if let contact = call.getString("contact") { payload["contact"] = contact }

    if let upiApp = call.getString("upi_app_package_name") {
        payload["upi_app_package_name"] = upiApp
        payload["_[flow]"] = "intent"
    }

    DispatchQueue.main.async { [weak self] in
        guard let self = self else { return }

        // Hidden WKWebView required by Custom UI SDK (same concept as Android)
        let webView = WKWebView(frame: .zero)
        webView.isHidden = true
        if let vc = self.bridge?.viewController {
            vc.view.addSubview(webView)
        }
        self.paymentWebView = webView

        // GOTCHA #7: Custom UI uses RazorpayPaymentCompletionProtocol (non-nullable data),
        // Standard uses RazorpayPaymentCompletionProtocolWithData (nullable data).
        // You need separate delegate objects for each.
        let delegate = CustomPaymentDelegate()
        delegate.onSuccess = { [weak self] paymentId, data in
            self?.handlePaymentSuccess(paymentId, data: data)
        }
        delegate.onError = { [weak self] code, description, data in
            self?.handlePaymentError(code, description: description, data: data)
        }
        self.customDelegate = delegate

        let razorpay = RazorpayCheckout.initWithKey(key, andDelegate: delegate, withPaymentWebView: webView)
        self.razorpayInstance = razorpay
        razorpay.authorize(payload)
    }
}

Gotcha #6: recurring type mismatch. On Android, "1" (string) works fine. On iOS, the SDK expects 1 (integer). Pass a string and the SDK silently ignores the recurring flag — the payment goes through as a one-time payment, no mandate is created, and you discover this days later when the charge-at-will payment fails with "token not found."

Gotcha #7: Two different delegate protocols. The Custom UI SDK uses RazorpayPaymentCompletionProtocol where the response data is non-nullable. The Standard Checkout uses RazorpayPaymentCompletionProtocolWithData where it's nullable. Your plugin class can conform to one; for the other, you need a separate delegate helper object.

The Custom Delegate Helper

private class CustomPaymentDelegate: NSObject, RazorpayPaymentCompletionProtocol {
    var onSuccess: ((_ paymentId: String, _ data: [AnyHashable: Any]) -> Void)?
    var onError: ((_ code: Int32, _ description: String, _ data: [AnyHashable: Any]) -> Void)?

    @objc(onPaymentSuccess:andData:)
    func onPaymentSuccess(_ payment_id: String, andData response: [AnyHashable: Any]) {
        onSuccess?(payment_id, response)
    }

    @objc(onPaymentError:description:andData:)
    func onPaymentError(_ code: Int32, description str: String, andData response: [AnyHashable: Any]) {
        onError?(code, str, response)
    }
}

CocoaPods Setup

# Podfile
pod 'razorpay-pod', '~> 1.3.10'

Gotcha #8: The iOS Razorpay SDK pod is called razorpay-pod, not Razorpay or RazorpayCheckout. And you import it as import Razorpay in Swift. The naming is inconsistent.

Part 4: The UPI App Picker Component (React)

The picker serves double duty — it shows the list of installed UPI apps AND transforms into a payment status overlay during the payment lifecycle.

'use client';

import React, { useEffect, useState, useCallback, useRef } from 'react';
import { App } from '@capacitor/app';
import { UPIPayment, type UPIApp } from '@/plugins/UPIPayment';

type PaymentStage = 'opening' | 'waiting' | 'verifying';

const STAGE_CONFIG: Record<PaymentStage, { message: string; subtext: string }> = {
  opening: {
    message: 'Opening UPI app...',
    subtext: 'Please wait',
  },
  waiting: {
    message: 'Complete payment',
    subtext: 'Waiting for you to approve the payment in UPI app',
  },
  verifying: {
    message: 'Verifying payment...',
    subtext: 'Please do not close the app',
  },
};

export const UPIAppPicker: React.FC<UPIAppPickerProps> = ({
  isOpen, onClose, onAppSelected, isLoading, onFallback
}) => {
  const [apps, setApps] = useState<UPIApp[]>([]);
  const [paymentStage, setPaymentStage] = useState<PaymentStage | null>(null);
  const wentToBackground = useRef(false);

  // Fetch installed UPI apps when picker opens
  useEffect(() => {
    if (!isOpen) return;
    let cancelled = false;

    const fetchApps = async () => {
      const result = await UPIPayment.getInstalledUPIApps();
      if (cancelled) return;

      if (!result.apps || result.apps.length === 0) {
        onFallback(); // No UPI apps → fall back to Standard Checkout
        return;
      }
      setApps(result.apps);
    };
    fetchApps();

    return () => { cancelled = true; };
  }, [isOpen, onFallback]);

  // Track app lifecycle to update payment stage
  useEffect(() => {
    if (!paymentStage) return;

    const listener = App.addListener('appStateChange', ({ isActive }) => {
      if (!isActive) {
        // User switched to UPI app
        wentToBackground.current = true;
        setPaymentStage('waiting');
      } else if (wentToBackground.current) {
        // User returned from UPI app
        setPaymentStage('verifying');
      }
    });

    return () => { listener.then(l => l.remove()); };
  }, [paymentStage]);

  const handleAppClick = useCallback((app: UPIApp) => {
    setPaymentStage('opening');
    onAppSelected(app);
  }, [onAppSelected]);

  // When paymentStage is set, render status overlay instead of app list
  if (paymentStage) {
    const stage = STAGE_CONFIG[paymentStage];
    return (
      <StatusOverlay message={stage.message} subtext={stage.subtext} />
    );
  }

  // Default: render the app grid
  return (
    <BottomSheet>
      {apps.map(app => (
        <AppButton key={app.packageName} app={app} onClick={handleAppClick} />
      ))}
    </BottomSheet>
  );
};

The key UX insight: we use Capacitor's App.addListener('appStateChange') to track when the user leaves our app (→ "waiting") and returns (→ "verifying"). This gives real-time feedback without polling.

Part 5: Calling the Plugin from Your Payment Flow

const handleUPIAppSelected = async (app: UPIApp) => {
  try {
    const result = await UPIPayment.submitCustomPayment({
      key: orderData.key_id,
      order_id: orderData.id,
      customer_id: orderData.customer_id,
      recurring: '1',
      currency: 'INR',
      amount: orderData.amount || 100, // mandate auth = ₹1 = 100 paise
      method: 'upi',
      email: currentUser?.email || '',
      contact: currentUser?.mobile || '',
      upi_app_package_name: app.packageName,
    });

    // Payment succeeded
    router.push('/payment-success');
  } catch (error) {
    router.push('/payment-failed');
  }
};

Gotcha #9: The amount field. Standard Checkout reads amount from the order automatically. Custom UI SDK does NOT — you must pass it explicitly. If you omit it, you get "The amount field is required" from Razorpay. For mandate authorization, the amount is the auth charge (₹1 = 100 paise in our case), not the subscription amount.

Part 6: Server-Side Order Creation

The server creates a Razorpay order configured for recurring/token-based payments:

// Server endpoint: POST /subscription/create-recurring
const order = await razorpayRecurring.orders.create({
  amount: 100, // ₹1 auth charge in paise
  currency: 'INR',
  payment_capture: true,
  notes: {
    student_id: studentId,
    subscription_tag: tag,
  },
  token: {
    max_amount: 100000, // ₹1000 max per charge
    expire_at: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60),
    frequency: 'as_presented', // charge-at-will
  },
});

return {
  id: order.id,
  key_id: process.env.RAZORPAY_RECURRING_KEY,
  customer_id: razorpayCustomerId,
  amount: order.amount,
};

Gotcha #10: frequency: 'as_presented' is required for charge-at-will. Other valid values are monthly, weekly, etc., but those are for auto-debit mandates. With as_presented, you control when charges happen via your server.

Summary of Gotchas

#GotchaPlatform
1Custom UI SDK needs a WebView attached to the view hierarchyAndroid
2Razorpay instance, WebView, and PluginCall must be staticAndroid
3_[flow]: "intent" is required for UPI IntentBoth
4onActivityResult must be forwarded to Razorpay SDKAndroid
5Use getAppsWhichSupportUpiRecurring not getAppsWhichSupportUpiiOS
6recurring must be integer 1 on iOS, string "1" on AndroidiOS
7Custom UI and Standard Checkout use different delegate protocolsiOS
8Pod name is razorpay-pod, import is RazorpayiOS
9Custom UI requires explicit amount field (Standard reads from order)Both
10frequency: 'as_presented' for charge-at-will mandatesServer

If you're implementing this and get stuck, the code examples above are from a working production app. The biggest lesson: Razorpay's mobile SDKs are capable but under-documented. Expect to read SDK source code and use ADB/Xcode logs heavily. Good luck.

Written by Claude, with ❤️