npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

openupi-sdk

v1.0.0

Published

Zero-fee self-hosted UPI payment gateway SDK and React checkout widgets

Readme


Table of Contents


Installation

npm install openupi-sdk
# or
yarn add openupi-sdk
# or
pnpm add openupi-sdk

Note: React is an optional peer dependency. If you only use the Node.js backend client, React is not installed automatically.


Subpath Exports

| Import path | Contents | Environment | |---|---|---| | openupi-sdk | OpenUPI client, verifyWebhookSignature, all TypeScript types | Node.js (server) | | openupi-sdk/node | All of root + Express / Next.js / Fastify / generic webhook handlers | Node.js (server) | | openupi-sdk/react | OpenUPIProvider, UPICheckoutModal, UPIQRCode, UPICheckoutButton, UPIMerchantDashboard, OpenUPILogo, useUPIStatus | React (client) |


Environment Variables

Set these in your backend .env file:

# Required — your gateway instance base URL
OPENUPI_GATEWAY_URL=https://pay.yourdomain.com

# Required — merchant API secret key (set in android daemon: Credentials tab)
OPENUPI_API_KEY=your_merchant_secret_key_here

# Optional — your merchant UPI VPA (e.g. yourbusiness@okaxis)
OPENUPI_MERCHANT_VPA=yourbusiness@okaxis

⚠️ Never expose OPENUPI_API_KEY to the browser. The UPIMerchantDashboard component accepts apiKey for admin panels behind auth — do not use it on public-facing pages.


Quick Start: End-to-End Flow

[Customer Clicks "Pay"] → [Your backend creates order] → [Frontend shows QR + listens for SSE]
        ↓                                                           ↓
[Customer scans/pays]   →  [Android Daemon detects SMS credit]  → [SSE fires PAID]
        ↓                                                           ↓
[Webhook sent to your server]  →  [Verify HMAC]  →  [Fulfill order]

Step 1 — Backend creates an order

// pages/api/checkout.ts  (Next.js Pages Router)
// app/api/checkout/route.ts  (Next.js App Router)
import { OpenUPI } from 'openupi-sdk';

const upi = new OpenUPI({
  apiUrl: process.env.OPENUPI_GATEWAY_URL!,
  apiKey: process.env.OPENUPI_API_KEY!,
});

export async function POST(req: Request) {
  const { cartTotal, orderId } = await req.json();

  const order = await upi.orders.create({
    orderId,                                     // your system's order ID (must be unique)
    amount: cartTotal,                           // in INR, e.g. 499
    note: 'Pro Plan Purchase',                   // shown on payer's UPI screen
    callbackUrl: 'https://yourdomain.com/api/webhooks/openupi',
  });

  // order = { orderId, baseAmount, exactAmount, vpa, upiIntent, qrSvg, expiresAt }
  return Response.json(order);
}

Step 2 — Frontend shows checkout

// components/CheckoutDialog.tsx
'use client';
import { UPICheckoutModal } from 'openupi-sdk/react';

export function CheckoutDialog({ order, onClose }) {
  return (
    <UPICheckoutModal
      orderId={order.orderId}
      exactAmount={order.exactAmount}
      qrSvg={order.qrSvg}
      upiIntent={order.upiIntent}
      gatewayUrl={process.env.NEXT_PUBLIC_GATEWAY_URL!}
      onSuccess={({ utr }) => {
        // SSE confirmed payment — redirect or show success UI
        onClose();
        window.location.href = `/success?utr=${utr}`;
      }}
      onExpire={() => {
        alert('QR expired. Please start a new checkout.');
        onClose();
      }}
    />
  );
}

Step 3 — Backend webhook to confirm

// app/api/webhooks/openupi/route.ts
import { createNextWebhookHandler } from 'openupi-sdk/node';
import { db } from '@/lib/db';

export const POST = createNextWebhookHandler({
  secret: process.env.OPENUPI_API_KEY!,
  onPaymentSuccess: async (event) => {
    // event = { orderId, baseAmount, exactAmount, utr, status }
    await db.orders.update({
      where: { id: event.orderId },
      data: { status: 'PAID', paidAt: new Date(), utr: event.utr },
    });
    // send confirmation email, activate subscription, etc.
  },
  onPaymentLate: async (event) => {
    // Payment arrived after QR expiry — still valid, settle manually
    await db.orders.update({
      where: { id: event.orderId },
      data: { status: 'PAID_LATE', utr: event.utr },
    });
  },
});

Backend SDK (Node.js)

1. Initialize the Client

import { OpenUPI } from 'openupi-sdk';

const upi = new OpenUPI({
  apiUrl: process.env.OPENUPI_GATEWAY_URL!,   // required
  apiKey: process.env.OPENUPI_API_KEY!,        // required
  // merchantVpa?: 'yourname@okaxis',           // optional override
  // merchantName?: 'My Store',                 // optional override
});

2. Create a Payment Order

const order = await upi.orders.create({
  orderId: 'ORD_1001',        // unique ID from your system
  amount: 499,                 // in INR (paise slot auto-allocated)
  note: 'Pro Plan',            // optional: shown on payer's UPI screen
  callbackUrl: 'https://yourdomain.com/api/webhooks/openupi',  // optional
  customerVpa: 'customer@okhdfcbank',  // optional: for collect requests
});

// Returns: OrderResponse
// {
//   orderId: 'ORD_1001',
//   baseAmount: 499,
//   exactAmount: 499.04,     // unique paise slot for matching
//   vpa: 'merchant@upi',
//   upiIntent: 'upi://pay?pa=merchant@upi&pn=My+Store&am=499.04&...',
//   qrSvg: '<svg>...</svg>', // embed directly in img or dangerouslySetInnerHTML
//   expiresAt: '2026-08-15T11:30:00.000Z'
// }

3. Poll or Stream Order Status

// One-time poll
const status = await upi.orders.get('ORD_1001');
// status.status === 'PENDING' | 'PAID' | 'EXPIRED' | 'PAID_LATE'
// status.utr    === '422812345678' (when PAID)

// SSE stream URL — pass to frontend or use EventSource server-side
const streamUrl = upi.orders.streamUrl('ORD_1001');
// 'https://pay.yourdomain.com/api/v1/orders/ORD_1001/stream'

4. Manual UTR Claim (Customer Fallback)

// When a customer pays but SSE didn't fire (rare)
const result = await upi.orders.claimUtr('ORD_1001', '422812345678');
// result.success === true if matched
// result.status  === 'PAID'

5. Admin: Stats & Transaction Ledger

// Overall gateway stats
const stats = await upi.admin.stats();
// { settledVolume: 49823.00, settledCount: 101, pendingCount: 3, unmatchedCount: 1 }

// Paginated transaction ledger
const { transactions, total, page, limit } = await upi.admin.transactions({
  limit: 20,
  page: 1,
  status: 'PAID',   // optional filter: 'PAID' | 'PENDING' | 'EXPIRED'
});

// CSV export URL
const csvUrl = upi.admin.exportCsvUrl();

6. Admin: Reconcile Unmatched Bank Credits

// Credits received without a matching order (e.g., paise mismatch)
const { unmatched } = await upi.admin.unmatched();

// Manually link a credit to an order
await upi.admin.reconcile(unmatched[0].id, 'ORD_1001');

7. Verify Webhooks Manually

import { verifyWebhookSignature } from 'openupi-sdk';

const isValid = verifyWebhookSignature({
  rawBody: req.body.toString(),    // MUST be raw bytes string before parsing
  signature: req.headers['x-openupi-signature'] as string,
  timestamp:  req.headers['x-openupi-timestamp'] as string,
  secret: process.env.OPENUPI_API_KEY!,
  toleranceMs: 300_000,           // optional: 5 min replay window (default)
});

if (!isValid) return res.status(401).json({ error: 'Invalid signature' });

const event = JSON.parse(req.body.toString());
// event.orderId, event.utr, event.status, event.baseAmount, event.exactAmount

How the signature works: HMAC-SHA256(body + "." + timestamp, secret) — timing-safe comparison with 5-minute replay protection.

8. Express Webhook Handler

import express from 'express';
import { createExpressWebhookHandler } from 'openupi-sdk/node';

const app = express();

// ⚠️ IMPORTANT: Use express.raw() — NOT express.json() — for this route
app.post(
  '/api/webhooks/openupi',
  express.raw({ type: '*/*' }),
  createExpressWebhookHandler({
    secret: process.env.OPENUPI_API_KEY!,
    onPaymentSuccess: async (event) => {
      console.log(`✅ Order ${event.orderId} paid | UTR: ${event.utr}`);
      await fulfillOrder(event.orderId);
    },
    onPaymentLate: async (event) => {
      // Optional: handle late payments after QR expiry
      await fulfillOrder(event.orderId);
    },
    onError: (err) => {
      console.error('[OpenUPI Webhook Error]', err);
    },
    toleranceMs: 300_000,  // optional, default 5 min
  })
);

9. Next.js App Router Webhook Handler

// app/api/webhooks/openupi/route.ts
import { createNextWebhookHandler } from 'openupi-sdk/node';

export const POST = createNextWebhookHandler({
  secret: process.env.OPENUPI_API_KEY!,
  onPaymentSuccess: async (event) => {
    await db.orders.update({
      where: { id: event.orderId },
      data: { status: 'PAID', utr: event.utr, paidAt: new Date() },
    });
  },
});

Next.js Config: Disable body parsing for this route so the raw body is preserved for HMAC:

// app/api/webhooks/openupi/route.ts — no extra config needed for App Router
// For Pages Router only, export:
export const config = { api: { bodyParser: false } };

10. Fastify Webhook Handler

import Fastify from 'fastify';
import { createFastifyWebhookHandler } from 'openupi-sdk/node';

const app = Fastify();

// Tell Fastify to pass raw Buffer (required for HMAC verification)
app.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body)
);

app.post('/api/webhooks/openupi', createFastifyWebhookHandler({
  secret: process.env.OPENUPI_API_KEY!,
  onPaymentSuccess: async (event) => {
    console.log(`✅ ${event.orderId} paid via UTR ${event.utr}`);
  },
}));

app.listen({ port: 3000 });

11. Framework-Agnostic Webhook Processor

For Hono, Bun.serve, Deno, or any custom HTTP server:

import { processWebhookPayload } from 'openupi-sdk/node';

// Hono example
app.post('/api/webhooks/openupi', async (c) => {
  const rawBody = await c.req.text();

  let event;
  try {
    event = processWebhookPayload({
      rawBody,
      signature: c.req.header('x-openupi-signature') ?? '',
      timestamp: c.req.header('x-openupi-timestamp') ?? '',
      secret: process.env.OPENUPI_API_KEY!,
    });
  } catch {
    return c.json({ error: 'Invalid signature' }, 401);
  }

  await fulfillOrder(event.orderId, event.utr);
  return c.json({ received: true });
});

Frontend SDK (React)

OpenUPI Provider

Initialize once at your app root to avoid passing gatewayUrl as a prop to every component:

// app/layout.tsx  or  pages/_app.tsx
import { OpenUPIProvider } from 'openupi-sdk/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <OpenUPIProvider gatewayUrl={process.env.NEXT_PUBLIC_GATEWAY_URL!}>
          {children}
        </OpenUPIProvider>
      </body>
    </html>
  );
}

Then access the config anywhere:

import { useOpenUPIContext } from 'openupi-sdk/react';

function MyCheckout({ order }) {
  const { gatewayUrl } = useOpenUPIContext();

  return (
    <UPICheckoutModal
      gatewayUrl={gatewayUrl}
      orderId={order.orderId}
      exactAmount={order.exactAmount}
      qrSvg={order.qrSvg}
      upiIntent={order.upiIntent}
      onSuccess={({ utr }) => console.log('Paid!', utr)}
    />
  );
}

UPICheckoutModal

The all-in-one payment widget. Renders the QR code, countdown timer, mobile pay button, and real-time payment status — and auto-triggers onSuccess when payment is confirmed via SSE.

import { UPICheckoutModal } from 'openupi-sdk/react';

<UPICheckoutModal
  orderId="ORD_1001"
  exactAmount={499.04}
  qrSvg={order.qrSvg}
  upiIntent={order.upiIntent}
  gatewayUrl="https://pay.yourdomain.com"
  onSuccess={({ utr }) => {
    // Called automatically when SSE confirms PAID or PAID_LATE
    router.push(`/thank-you?utr=${utr}`);
  }}
  onExpire={() => {
    // Called when the 15-minute QR window expires
    setShowModal(false);
  }}
/>

| Prop | Type | Required | Description | |---|---|---|---| | orderId | string | ✅ | Order ID from upi.orders.create() | | exactAmount | number | ✅ | Exact paise-slotted amount (e.g. 499.04) | | qrSvg | string | ✅ | SVG string from order response | | upiIntent | string | ✅ | Deep-link for mobile UPI apps | | gatewayUrl | string | ✅ | Your gateway base URL | | onSuccess | ({ utr }) => void | ✅ | Called on PAID or PAID_LATE | | onExpire | () => void | ❌ | Called on QR timeout (15 min) |


UPICheckoutButton

A styled anchor tag that deep-links to a UPI payment app on mobile.

import { UPICheckoutButton } from 'openupi-sdk/react';

<UPICheckoutButton
  upiIntent={order.upiIntent}
  label="Pay ₹499 with UPI"
  style={{ width: '100%' }}
/>

| Prop | Type | Default | Description | |---|---|---|---| | upiIntent | string | — | upi:// deep link from order response | | label | string | 'Pay via UPI App' | Button text | | className | string | — | Additional CSS class | | style | CSSProperties | — | Inline style overrides | | onClick | () => void | — | Optional click handler |


UPIQRCode

Standalone QR code display with optional UPI ID copy button.

import { UPIQRCode } from 'openupi-sdk/react';

<UPIQRCode
  qrSvg={order.qrSvg}
  exactAmount={order.exactAmount}
  vpa="yourbusiness@okaxis"   // optional: shows UPI ID with copy button
/>

| Prop | Type | Description | |---|---|---| | qrSvg | string | SVG string from order response | | exactAmount | number | Displayed below QR | | vpa | string | Optional UPI ID shown with copy button | | className | string | Optional CSS class | | style | CSSProperties | Optional style overrides |


UPIMerchantDashboard

Drop a full admin dashboard widget into any authenticated admin page — shows settlement metrics, transaction ledger with pagination, daemon health, and CSV export.

import { UPIMerchantDashboard } from 'openupi-sdk/react';

// In an authenticated admin page only
export default function AdminPage() {
  return (
    <UPIMerchantDashboard
      gatewayUrl="https://pay.yourdomain.com"
      apiKey={process.env.NEXT_PUBLIC_OPENUPI_ADMIN_KEY!}
      title="OpenUPI Console"       // optional, default: 'OpenUPI Merchant Console'
      refreshIntervalMs={10000}     // optional, default: 10000 (10s)
    />
  );
}

| Prop | Type | Default | Description | |---|---|---|---| | gatewayUrl | string | — | Gateway base URL | | apiKey | string | — | Admin API key (behind auth only) | | title | string | 'OpenUPI Merchant Console' | Dashboard header title | | refreshIntervalMs | number | 10000 | Auto-refresh interval |


OpenUPILogo

Official OpenUPI logo as crisp SVG. Supports icon mark and full brand name.

import { OpenUPILogo } from 'openupi-sdk/react';

<OpenUPILogo size={32} variant="full" dark={false} />
// Renders: [hexagon icon] Open**UPI**

| Prop | Type | Default | Description | |---|---|---|---| | size | number \| string | 32 | Width/height in px | | variant | 'mark' \| 'full' | 'mark' | Icon only vs icon + wordmark | | dark | boolean | false | White icon on dark backgrounds | | className | string | — | Optional CSS class | | style | CSSProperties | — | Optional style overrides |


useUPIStatus Hook

Headless SSE hook for building custom payment UIs. Reconnects automatically with exponential backoff on network drops.

import { useUPIStatus } from 'openupi-sdk/react';

function CustomPaymentStatus({ orderId, gatewayUrl }) {
  const { status, utr, error } = useUPIStatus(gatewayUrl, orderId);

  return (
    <div>
      {status === 'PENDING'   && <Spinner label="Waiting for payment..." />}
      {status === 'PAID'      && <Success utr={utr!} />}
      {status === 'PAID_LATE' && <Success utr={utr!} label="Received (Late)" />}
      {status === 'EXPIRED'   && <Alert>QR Expired. Please retry.</Alert>}
      {status === 'ERROR'     && <Alert>Reconnecting... ({error})</Alert>}
    </div>
  );
}

Returned state:

| Field | Type | Description | |---|---|---| | status | 'PENDING' \| 'PAID' \| 'PAID_LATE' \| 'EXPIRED' \| 'ERROR' | Current payment state | | utr | string \| undefined | 12-digit bank UTR (present on PAID / PAID_LATE) | | error | string \| undefined | Error message (present on ERROR) |


TypeScript API Reference

OpenUPIConfig

interface OpenUPIConfig {
  apiUrl: string;          // Gateway base URL (required)
  apiKey: string;          // Merchant API secret key (required)
  merchantVpa?: string;    // Override merchant UPI VPA
  merchantName?: string;   // Override merchant display name
}

CreateOrderParams

interface CreateOrderParams {
  orderId: string;         // Unique order ID from your system
  amount: number;          // Base amount in INR
  note?: string;           // Payment note for payer
  callbackUrl?: string;    // Webhook URL for payment confirmation
  customerVpa?: string;    // Optional: customer UPI ID for collect
}

OrderResponse

interface OrderResponse {
  orderId: string;
  baseAmount: number;      // Original amount (e.g. 499)
  exactAmount: number;     // Paise-slotted amount (e.g. 499.04)
  vpa: string;             // Merchant UPI ID
  upiIntent: string;       // upi:// deep link
  qrSvg: string;           // SVG string for QR rendering
  expiresAt: string;       // ISO 8601 expiry timestamp
}

OrderStatusResponse

interface OrderStatusResponse {
  orderId: string;
  baseAmount: number;
  exactAmount: number;
  status: 'PENDING' | 'PAID' | 'EXPIRED' | 'PAID_LATE';
  utr?: string;            // Bank UTR (present when PAID)
  paidAt?: string;         // ISO 8601 (present when PAID)
  expiresAt: string;
  createdAt: string;
}

PaymentWebhookPayload

interface PaymentWebhookPayload {
  orderId: string;
  baseAmount: number;
  exactAmount: number;
  utr: string;             // 12-digit Unique Transaction Reference
  status: 'PAID' | 'FAILED' | 'EXPIRED' | 'PAID_LATE';
}

WebhookHandlerOptions

interface WebhookHandlerOptions {
  secret: string;
  onPaymentSuccess: (event: PaymentWebhookPayload) => Promise<void> | void;
  onPaymentLate?: (event: PaymentWebhookPayload) => Promise<void> | void;
  onError?: (err: Error) => void;
  toleranceMs?: number;    // default: 300000 (5 min)
}

Troubleshooting

Cannot find module 'openupi-sdk/react'

Ensure you have react >= 18 installed and your bundler resolves the exports field (Webpack 5+, Vite, Rollup, esbuild all do this by default).

❌ Webhook returns 401 Invalid signature

  • Ensure you're using express.raw({ type: '*/*' }) before express.json() for this route.
  • Do not parse the body before the handler reads it. The raw bytes must reach the verifier.
  • In Next.js Pages Router: add export const config = { api: { bodyParser: false } }.

❌ SSE disconnects immediately

Check that your gateway allows long-lived HTTP connections (not timing out at 30s). If behind Nginx, add:

proxy_read_timeout 3600s;
proxy_buffering    off;

useOpenUPIContext() throws

You must wrap your component tree with <OpenUPIProvider gatewayUrl="...">. It can be placed in your root layout or just around the checkout section.

❌ QR amount mismatch

Always pass order.exactAmount (e.g. 499.04) to the UI — not baseAmount (e.g. 499). The paise slot is how OpenUPI identifies which payment belongs to which order.


Building and Testing

npm run build       # Bundles dual ESM/CJS with .d.ts into dist/
npm run test        # Runs Vitest test suite (16 tests)
npm run typecheck   # Validates TypeScript without emitting
npm run dev         # Watch mode build

License

MIT © Sayan Senapati