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

@operatornest/convex-razorpay

v0.1.1

Published

Razorpay payments for Convex: orders, subscriptions, refunds and verified webhooks.

Downloads

252

Readme

@operatornest/convex-razorpay

Razorpay payments for Convex: server-side orders, payments, refunds, customers, plans, subscriptions, Checkout verification, and signed webhook mirroring in Convex's default V8 runtime.

Verified against: Razorpay's official API docs and razorpay-node v2.9.8 source in the 2026-10-04 research snapshot. Live account behavior still requires qualification.

CI Release npm License: MIT

Install

pnpm add @operatornest/convex-razorpay convex@^1.46.0

convex is a peer dependency. This package has no runtime dependencies. Consumers need Node.js 22.19 or newer for their development toolchain.

Configure

Mount the component and bind app environment values to component environment values by reference:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import { v } from "convex/values";
import razorpay from "@operatornest/convex-razorpay/convex.config.js";

const app = defineApp({
  env: {
    RAZORPAY_KEY_ID: v.optional(v.string()),
    RAZORPAY_KEY_SECRET: v.optional(v.string()),
    RAZORPAY_WEBHOOK_SECRET: v.optional(v.string()),
    RAZORPAY_WEBHOOK_SECRET_PREVIOUS: v.optional(v.string()),
    RAZORPAY_TEST_MODE: v.optional(v.string()),
  },
});
app.use(razorpay, {
  env: {
    RAZORPAY_KEY_ID: app.env.RAZORPAY_KEY_ID,
    RAZORPAY_KEY_SECRET: app.env.RAZORPAY_KEY_SECRET,
    RAZORPAY_WEBHOOK_SECRET: app.env.RAZORPAY_WEBHOOK_SECRET,
    RAZORPAY_WEBHOOK_SECRET_PREVIOUS: app.env.RAZORPAY_WEBHOOK_SECRET_PREVIOUS,
    RAZORPAY_TEST_MODE: app.env.RAZORPAY_TEST_MODE,
  },
});
export default app;

| Component env variable | Purpose | | ---------------------------------- | ------------------------------------------------------------------------------------------------ | | RAZORPAY_KEY_ID | Public Checkout key id and REST API credential. Required for live operations. | | RAZORPAY_KEY_SECRET | REST API and checkout-signature secret. Required for live operations and signature verification. | | RAZORPAY_WEBHOOK_SECRET | Current webhook HMAC secret. Required for webhook ingest, including test mode. | | RAZORPAY_WEBHOOK_SECRET_PREVIOUS | Previous webhook secret during rotation. | | RAZORPAY_TEST_MODE | Set to the string true for explicit local test mode. |

The app sets these values on its Convex deployment. The webhook secret is separate from the API key secret. Never put credentials in component arguments or database records.

Quick start

App functions must authenticate callers, derive prices from trusted app data, and authorize reads. This example uses an authenticated identity as a linkage key; configure the consuming app's auth provider before calling it.

// convex/billing.ts
import { Razorpay, sha256Hex } from "@operatornest/convex-razorpay";
import { ConvexError, v } from "convex/values";
import { components } from "./_generated/api";
import { action, query } from "./_generated/server";

const razorpay = new Razorpay(components.razorpay);
const price = 50000; // Server-owned INR amount in paise.

export const createOrder = action({
  args: { cartId: v.string() },
  returns: v.object({
    orderId: v.string(),
    amount: v.number(),
    currency: v.string(),
    keyId: v.optional(v.string()),
  }),
  handler: async (ctx, { cartId }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity)
      throw new ConvexError({ code: "APP_AUTH_REQUIRED", message: "Authentication required" });
    const idempotencyKey = await sha256Hex(`${identity.tokenIdentifier}|${cartId}`);
    const order = await razorpay.orders.create(ctx, {
      amount: price,
      currency: "INR",
      userId: identity.tokenIdentifier,
      idempotencyKey,
    });
    return {
      orderId: order.razorpayId,
      amount: order.amount,
      currency: order.currency,
      ...(order.keyId === undefined ? {} : { keyId: order.keyId }),
    };
  },
});

export const getOrder = query({
  args: { orderId: v.string() },
  returns: v.union(v.object({ status: v.string() }), v.null()),
  handler: async (ctx, { orderId }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity)
      throw new ConvexError({ code: "APP_AUTH_REQUIRED", message: "Authentication required" });
    const order = await razorpay.orders.get(ctx, { orderId });
    return order?.userId === identity.tokenIdentifier ? { status: order.status } : null;
  },
});

An order's keyId is optional in explicit test mode when no key id is configured; supply a real Razorpay test key before opening Checkout.js. Build Checkout.js options with the pure helper checkoutOptions(order, { name: "My app" }) (import it from the package; it needs no client or context) once a key id is available. Send Checkout's callback to an authenticated action using razorpay.verification.checkout(ctx, { orderId, paymentId, signature, userId }). Pass userId from your authenticated identity: when given it must equal the order's userId, and omitting it skips the ownership check. Verification requires a component-created order of the same mode and a payment that is authorized or captured; verification.subscriptionCheckout has the same rules for a component-created subscription. A valid callback signature does not by itself prove capture; fulfill after a captured payment or paid order is confirmed.

Webhooks

Register the route in convex/http.ts, then configure its URL and secret in the Razorpay dashboard. The default path is /razorpay/webhook.

import { registerRoutes } from "@operatornest/convex-razorpay";
import { httpRouter } from "convex/server";
import { components, internal } from "./_generated/api";

const http = httpRouter();
registerRoutes(http, components.razorpay, {
  path: "/razorpay/webhook",
  onEvent: internal.webhookCallback.onEvent,
});
export default http;

Define the app callback and its table. The example callback uses this pattern:

// convex/schema.ts — merge this table into your app schema
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  webhookNotifications: defineTable({ eventId: v.string(), event: v.string() }).index(
    "by_eventId",
    ["eventId"],
  ),
});
// convex/webhookCallback.ts
import { v } from "convex/values";
import { internalMutation } from "./_generated/server";

export const onEvent = internalMutation({
  args: {
    eventId: v.string(),
    event: v.string(),
    entityId: v.optional(v.string()),
    accountId: v.optional(v.string()),
    createdAtProvider: v.number(),
  },
  returns: v.null(),
  handler: async (ctx, { eventId, event }) => {
    const existing = await ctx.db
      .query("webhookNotifications")
      .withIndex("by_eventId", (q) => q.eq("eventId", eventId))
      .unique();
    if (!existing) await ctx.db.insert("webhookNotifications", { eventId, event });
    return null;
  },
});

onEvent is an optional internal mutation accepting the exported RazorpayWebhookEventArgs shape: { eventId, event, entityId?, accountId?, createdAtProvider }. The component invokes it in the same transaction as mirror updates and deduplication. A callback failure rolls the transaction back and returns 500 so the provider may retry. Duplicate event ids or signed bodies return 200 without a second callback. The route returns 413 for oversized bodies, 400 for undecodable bodies, 401 for invalid signatures, and 500 when the secret is missing.

API reference

Create new Razorpay(components.razorpay, { testMode?: boolean, onEvent? }); onEvent is checked against the exported RazorpayWebhookEventArgs type. testMode is tri-state: true enables test mode, an explicit false forces live mode even when the component env has RAZORPAY_TEST_MODE=true, and undefined (the default) defers to that env variable. Every grouped method takes (ctx, args); args is an object. Provider calls use app actions, while local mirror reads use app queries. App linkage fields (userId, externalId, metadata) are metadata, never authorization.

| Client methods | args after ctx | Result | | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | orders.create | { amount, currency, receipt?, notes?, partialPayment?, ...link } | Order mirror with optional keyId. | | orders.fetch, orders.fetchPayments, orders.get | { orderId } | Order mirror, payment mirrors, or mirror/null. | | payments.fetch, payments.capture | { paymentId }; { paymentId, amount, currency } | Payment mirror. | | payments.list, payments.listForOrder | { count?, skip? }; { orderId, limit? } | Payment mirror arrays. | | refunds.create, refunds.fetch | { paymentId, amount?, speed?, receipt?, notes?, ...link }; { refundId } | Refund mirror. Create requires idempotencyKey or receipt. | | customers.create, customers.getOrCreate | { name, email, contact?, gstin?, notes?, ...link } | Customer mirror. | | customers.fetch, customers.edit, customers.getByUser | { customerId }; { customerId, name?, email?, contact? }; { userId } | Customer mirror or mirror/null for local read. | | plans.create, plans.fetch, plans.list | { period, interval, item, notes?, ...link }; { planId }; { count?, skip? } | Plan mirror or list of mirrors. | | subscriptions.create, subscriptions.fetch | { planId, totalCount, quantity?, startAt?, expireBy?, notes?, ...link }; { subscriptionId } | Subscription mirror. | | subscriptions.cancel, subscriptions.pause, subscriptions.resume | { subscriptionId, cancelAtCycleEnd? }; { subscriptionId }; { subscriptionId } | Subscription mirror. | | subscriptions.update | { subscriptionId, planId?, offerId?, quantity?, remainingCount?, startAt?, scheduleChangeAt?, customerNotify? } | Subscription mirror. | | subscriptions.getForUser, subscriptions.listByUser | { userId }; { userId, limit? } | Subscription mirror/null or a bounded list. | | verification.checkout, verification.subscriptionCheckout, verification.paymentLink | { orderId, paymentId, signature, userId? }; { subscriptionId, paymentId, signature, userId? }; { paymentLinkId, referenceId, status, paymentId, signature } | Verified payment mirror or true for payment link. | | webhooks.cleanup, idempotency.release | { before?, batchSize? }; { resource: "order" \| "plan" \| "subscription", key } | Deleted count or release boolean. |

link means optional { userId?, externalId?, metadata?, idempotencyKey? }. checkoutOptions(order, options?) is a standalone pure export that returns Checkout.js options for an orders.create result. Mirrored rows expose a testMode boolean. registerRoutes(http, component, { path?, onEvent? }) is also exported, with client.registerRoutes(http, options?) as a shortcut that carries the client's callback.

The provider list methods accept { count?, skip? }; local list methods use indexed bounded reads. webhooks.ingest is the component action behind registerRoutes; it accepts { body, signature, eventId, callbackHandle? } and returns processed, duplicate, ignored, or failed with an optional safe errorCode. App code should use the route so the exact raw body is verified.

Error codes

Component errors are ConvexError instances with { code, message, retryable? } data. isRazorpayError(error) narrows them; RazorpayErrorCode is the exported code union. The message is sanitized and never includes credentials.

| Code | Meaning | | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | RAZORPAY_INVALID_ARGUMENT | Input failed local validation. | | RAZORPAY_NOT_CONFIGURED | Required API credentials are missing. | | RAZORPAY_LIVE_KEY_TEST_MODE | Explicit test mode was requested with a live key id. | | RAZORPAY_MODE_MISMATCH | A cached create is reused in a different mode, or a test-mode operation targets a live row. | | RAZORPAY_IDEMPOTENCY_REQUIRED, RAZORPAY_IDEMPOTENCY_CONFLICT, RAZORPAY_IDEMPOTENCY_PENDING | A required key is missing; a key was reused with a different request on any create path; or a same-key create is in flight or ambiguous and needs reconciliation. | | RAZORPAY_CUSTOMER_CONFLICT | A provider customer belongs to another app user. | | RAZORPAY_NOT_FOUND | A required mirrored entity (test-mode fetch, checkout order, subscription, or payment, claim mirror) is absent. | | RAZORPAY_INVALID_RESPONSE, RAZORPAY_INVALID_ENTITY | Provider data failed validation. | | RAZORPAY_INVALID_WEBHOOK_SIGNATURE, RAZORPAY_MALFORMED_WEBHOOK, RAZORPAY_WEBHOOK_SECRET_MISSING | Webhook authentication, encoding/envelope, or configuration failed. | | RAZORPAY_NETWORK_ERROR, RAZORPAY_HTTP_ERROR, RAZORPAY_PROVIDER_REJECTED | Provider transport or rejection; inspect retryable before retrying. |

Testing

Register the component with convex-test:

import { register } from "@operatornest/convex-razorpay/test";
import { convexTest } from "convex-test";
import schema from "./schema";

const t = convexTest(schema, import.meta.glob(["./**/*.ts", "!./**/*.test.ts"]));
register(t);

Client testMode: true or component RAZORPAY_TEST_MODE=true enables deterministic local entities; an explicit client testMode: false overrides the env variable. Key prefixes never select the mode, and rzp_live_ keys are refused in test mode. Every mirrored row carries testMode; signed webhook entities are live unless RAZORPAY_TEST_MODE=true, and test-mode operations refuse live rows with RAZORPAY_MODE_MISMATCH. Stub provider fetch and component env with fabricated values in tests. Test mode still verifies webhook and checkout signatures when those paths require them. Run pnpm test for coverage thresholds and pnpm smoke for the real local Convex runtime.

Data retention

The daily cron removes webhook deduplication records and completed or failed idempotency claims older than 30 days in batches. Entity mirrors and unresolved claims have no automatic age cutoff; the consuming app must define reconciliation and erasure workflows. Provider raw passthrough fields may contain personal data; webhook raw bodies are not stored. See ADR 0003.

Known limitations

Razorpay live-provider and deployed-scheduler behavior need separate qualification. Payment links, invoices, disputes, settlements, payouts, and provider token management are outside v0.1. Orders, plans and subscriptions lack a confirmed provider idempotency header; ambiguous creates use a local claim and bounded reconciliation, and a concurrent same-key call receives RAZORPAY_IDEMPOTENCY_PENDING. Subscription payment-method eligibility and certain provider limits remain unverified in the dated research.

Security

The app must authenticate and authorize every call, own trusted prices, and gate fulfillment on confirmed payment state. The component verifies webhooks over the unchanged raw body using constant-time HMAC comparison and accepts a previous secret during rotation. Missing secrets fail closed. Report issues through private vulnerability reporting.

Development

Use .mise.toml for Node 26 and pnpm 12.9.1. Run mise trust && mise install, pnpm install --frozen-lockfile, CONVEX_AGENT_MODE=anonymous pnpm exec convex init, and pnpm build:codegen for a fresh checkout. pnpm check runs formatting, build, type-aware lint, typecheck, Knip, and coverage. pnpm smoke starts an anonymous local backend with fabricated values. For authorized Razorpay test-dashboard verification, .env.live.example and pnpm live:env configure the local deployment; keep .env.live private.

License

MIT © 2026 OperatorNest.