
If you have integrated Stripe before, you might expect payment gateways to behave consistently: create a customer, attach a payment method, subscribe them to a price ID, and listen for customer.subscription.updated webhooks.
When you integrate Razorpay for the Indian market, that mental model breaks almost immediately.
Between Reserve Bank of India (RBI) e-mandate regulations on recurring card transactions, asynchronous webhook delivery delays, cryptographic signature quirks where parameters are silently inverted between endpoints, and Fastify/NestJS stream parsing bugs, getting Razorpay to run reliably in production requires defensive engineering that the official documentation barely touches.
In this guide, we break down the exact production architecture we built for ZyVOP: a dual-mode subscription engine in NestJS and Fastify that handles recurring e-mandates, falls back to fixed-term orders when cards fail, enforces constant-time signature verification, and guarantees idempotent webhook processing.
1. The Two Indian Payment Realities: Subscriptions vs. Orders
The single biggest mistake teams make when launching recurring billing in India is assuming every customer can be placed on an automated recurring subscription.
Under RBI guidelines:
Automated recurring debits require a compliant e-mandate registration.
Many Indian debit cards, prepaid cards, corporate credit cards, and UPI apps reject auto-debit mandates during checkout.
Mandates above ₹15,000 require additional factor authentication (AFA) for every single billing cycle.
If your checkout only supports Razorpay Subscriptions (/v1/subscriptions), you will encounter a 30% to 50% checkout drop-off rate simply because your users' bank instruments cannot register an e-mandate.
The Dual-Mode Architecture
To solve this, our backend implements a dual-mode strategy:
flowchart TD
User["User clicks Upgrade to Pro"] --> Check{"Has recurring plan<br/>configured & supported?"}
Check -->|Yes: Full Subscription| SubFlow["POST /v1/subscriptions<br/>(plan_id: plan_MONTHLY)<br/>Status: APPROVAL_PENDING"]
Check -->|No / Unsupported Card| OrderFlow["POST /v1/orders<br/>(amount: 49900 paise, INR)<br/>Receipt: rcpt_usr_..."]
SubFlow --> SDK["Razorpay Checkout SDK<br/>(subscription_id)"]
OrderFlow --> SDK2["Razorpay Checkout SDK<br/>(order_id)"]
SDK --> VerifySub["POST /api/v1/billing/razorpay/verify<br/>(paymentId, subscriptionId, signature)"]
SDK2 --> VerifyOrder["POST /api/v1/billing/razorpay/verify<br/>(paymentId, orderId, signature)"]
VerifySub --> ActiveSub["Activate Ongoing Subscription"]
VerifyOrder --> ActiveOrder["Activate Fixed-Term Access<br/>(currentPeriodEnd = now + 30 days)"]Recurring Subscriptions (
sub_...): For users with auto-debit cards. The plan automatically renews every month or year.Fixed-Term Orders (
order_...): For users paying with UPI QR, NetBanking, or non-mandate debit cards. We generate a one-time order for the exact period amount (e.g., ₹499 or ₹4,999) and set an explicit expiration timestamp (currentPeriodEnd = Date.now() + 30 days). When the period ends, the user simply renews.
This single design decision eliminated failed payment drop-offs across Indian customers.
2. The Flipped Signature Verification Trap
To prevent clients from tampering with checkout outcomes, Razorpay returns a cryptographic HMAC-SHA256 signature when checkout completes in the browser. You must verify this signature on your backend before granting access.
Here is the trap: The payload format is inverted depending on whether you created an Order or a Subscription.
The Order Signature Payload
When verifying a one-time Order: $$\text{payload} = \text{order_id} + \text{"|"} + \text{payment_id}$$
const expected = createHmac('sha256', secret)
.update(`${orderId}|${paymentId}`)
.digest('hex');The Subscription Signature Payload
When verifying a recurring Subscription: $$\text{payload} = \text{payment_id} + \text{"|"} + \text{subscription_id}$$
// Notice paymentId comes FIRST!
const expected = createHmac('sha256', secret)
.update(`${paymentId}|${subscriptionId}`)
.digest('hex');If you use the order format (subscriptionId|paymentId) to verify a subscription, the signature will fail every time, even though all IDs are valid and the user was successfully charged.
Constant-Time Verification
Never compare cryptographic signatures using standard JavaScript equality (expected === signature). That creates a timing side-channel attack where an attacker can deduce bytes of your secret by measuring microsecond response latency. Always use timingSafeEqual:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySubscriptionSignature(
paymentId: string,
subscriptionId: string,
signature: string,
keySecret: string,
): void {
const expected = createHmac('sha256', keySecret)
.update(`${paymentId}|${subscriptionId}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature || '', 'hex');
if (
expectedBuf.length === 0 ||
expectedBuf.length !== receivedBuf.length ||
!timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new UnauthorizedException('Invalid Razorpay subscription signature');
}
}3. Fastify & NestJS: Preserving Raw Buffers for Webhooks
Webhooks are the ultimate source of truth for payment status. If a user closes their browser tab right after payment, the client-side checkout callback will never fire. Only the webhook can tell you that the payment succeeded.
Razorpay signs every webhook payload with the X-Razorpay-Signature header. The signature is calculated as:
$$\text{HMAC-SHA256}(\text{rawRequestBody}, \text{RAZORPAY_WEBHOOK_SECRET})$$
Why Naive NestJS/Fastify Implementations Fail
In Fastify and Express, body parsers automatically parse incoming JSON payloads into JavaScript objects before your controller runs:
Raw Network Stream --> JSON.parse() --> req.body (JS Object)If your controller attempts to verify the signature by re-serializing the body:
// ❌ CRITICAL BUG: This will fail verification randomly!
const raw = JSON.stringify(body);
const expected = createHmac('sha256', secret).update(raw).digest('hex');This fails because JSON.stringify() does not preserve:
Whitespace or indentation from the original HTTP body.
The original key order of JSON fields.
Escaped Unicode characters or slash formatting.
Even a single missing space alters the SHA-256 hash entirely.
The Fix: Fastify Raw Body Preservation
In NestJS with the Fastify adapter (@nestjs/platform-fastify), you must enable raw body buffering at the application root:
// main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
bufferLogs: true,
rawBody: true, // 👈 Required: Attaches raw Buffer to req.rawBody
}),
);
// ...
}Then in your billing controller, enforce that the raw buffer exists before passing it to verification:
// billing.controller.ts
@Post('webhooks/razorpay')
async razorpayWebhook(
@Headers() headers: Record<string, any>,
@Req() req: FastifyRequest,
@Body() event: any,
) {
const rawBody = (req as any).rawBody;
if (!Buffer.isBuffer(rawBody)) {
throw new BadRequestException('Raw Razorpay webhook body is unavailable');
}
return this.billing.handleRazorpayWebhook(headers, rawBody, event);
}And in your verification service:
verifyWebhookSignature(rawBody: string | Buffer, signature: string): void {
const secret = this.required('RAZORPAY_WEBHOOK_SECRET');
const expected = createHmac('sha256', secret)
.update(rawBody) // 👈 Digests the exact binary stream
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature || '', 'hex');
if (
expectedBuf.length === 0 ||
expectedBuf.length !== receivedBuf.length ||
!timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new UnauthorizedException('Invalid Razorpay webhook signature');
}
}4. The Client-Server Handshake & Race Conditions
When a payment succeeds, two events happen almost simultaneously:
Client-side: The Razorpay modal triggers the
handler()callback with payment IDs and signature. The frontend callsPOST /api/v1/billing/razorpay/verify.Server-side: Razorpay's servers send an asynchronous webhook (
payment.capturedorsubscription.activated) toPOST /api/v1/billing/webhooks/razorpay.
sequenceDiagram
autonumber
actor User
participant Frontend as Browser (Razorpay SDK)
participant Backend as NestJS API
participant DB as PostgreSQL
participant RZP as Razorpay Server
User->>Frontend: Completes UPI / Card Authentication
Frontend-->>RZP: Payment Authorized
par Parallel Execution Race
RZP-->>Frontend: Returns payment_id & signature
Frontend->>Backend: POST /razorpay/verify
Backend->>DB: Check subscription ownership & activate
and
RZP->>Backend: POST /webhooks/razorpay (subscription.activated)
Backend->>DB: Record webhook event (eventId)
Backend->>DB: Reconcile status & activate
endThe Race Condition Failure Modes
If the webhook arrives first and locks the subscription row, the frontend
/verifycall might throw a database lock conflict.If the user has an aggressive ad-blocker or loses Wi-Fi right as the payment succeeds, the frontend
/verifycall never happens.Razorpay retries webhooks on network failure. If your webhook handler isn't idempotent, you might trigger duplicate activation emails or miscount subscriptions.
Solution: Idempotent Webhook Storage with 23505 Handling
We maintain a dedicated webhook_events table in PostgreSQL with a unique constraint on (provider, provider_event_id):
// billing.service.ts
const eventId = event?.id || headers['x-razorpay-event-id'];
const eventType = event?.event || '';
// 1. In-memory / fast-path deduplication
const duplicate = await this.webhookEvents.findOne({
where: { provider: BillingProvider.RAZORPAY, providerEventId: eventId },
});
if (duplicate) {
return { received: true, duplicate: true };
}
// 2. Execute reconciliation
const providerSubscriptionId =
event?.payload?.subscription?.entity?.id ||
event?.payload?.payment?.entity?.subscription_id;
if (providerSubscriptionId && eventType.startsWith('subscription.')) {
await this.reconcileRazorpaySubscription(providerSubscriptionId);
}
// 3. Atomically persist event ID; catch concurrent duplicate insert race
try {
await this.webhookEvents.save(
this.webhookEvents.create({
provider: BillingProvider.RAZORPAY,
providerEventId: eventId,
eventType,
providerSubscriptionId: providerSubscriptionId || null,
}),
);
} catch (error: any) {
// PostgreSQL 23505 = unique_violation. Concurrent worker already handled this.
if (error?.code !== '23505') throw error;
}
return { received: true };5. Preventing Configuration Drift on Startup
One of the nastiest production bugs in payment systems is currency or amount drift:
Someone edits the price on the marketing page to ₹499/mo.
In the Razorpay Dashboard, someone created a plan for ₹49.90 because they entered
499instead of49900paise.Or someone configured the plan as USD instead of INR.
Users will sign up, get charged ₹49.90, and your database will grant them full access while you lose revenue.
We prevent this by introducing an assertive configuration guard on boot:
// razorpay.service.ts
async assertPlanConfiguration(interval: BillingInterval): Promise<void> {
const planId = this.planId(interval);
if (this.validatedPlanIds.has(planId)) return;
const plan = await this.request(`/plans/${encodeURIComponent(planId)}`);
const expectedPeriod = interval === BillingInterval.MONTH ? 'monthly' : 'yearly';
const expectedAmount = interval === BillingInterval.MONTH ? 49_900 : 499_900; // in paise
const valid =
plan?.entity === 'plan' &&
plan?.period === expectedPeriod &&
Number(plan?.interval) === 1 &&
plan?.item?.currency === 'INR' &&
Number(plan?.item?.amount ?? plan?.item?.unit_amount) === expectedAmount;
if (!valid) {
throw new ServiceUnavailableException(
`Razorpay ${interval.toLowerCase()} plan must be ${expectedPeriod} at ₹${expectedAmount / 100} INR`,
);
}
this.validatedPlanIds.add(planId);
}When a user initiates checkout, the server verifies the plan with Razorpay's API before issuing the subscription ID. If an admin pasted the wrong Plan ID or configured the wrong currency, the transaction fails immediately with an actionable error rather than billing the customer incorrectly.
6. Prorations, Subunit Math, and Refunds
Like Stripe, Razorpay deals strictly in currency subunits:
1 INR = 100 paise.
₹499.00 must be passed as
49900.₹4,999.00 must be passed as
499900.
Never Use Floating Point for Currency Math
In JavaScript:
0.1 + 0.2 === 0.3 // false (0.30000000000000004)When calculating prorated refunds for downgraded subscriptions:
// proration.util.ts
export function calculateProratedRefund(
totalPaidSubunits: number, // 49900
periodStart: Date,
periodEnd: Date,
cancelDate: Date,
): number {
const totalDurationMs = periodEnd.getTime() - periodStart.getTime();
const remainingMs = Math.max(0, periodEnd.getTime() - cancelDate.getTime());
if (totalDurationMs <= 0 || remainingMs <= 0) return 0;
// Perform division last, truncate to integer paise
const refundPaise = Math.floor((totalPaidSubunits * remainingMs) / totalDurationMs);
// Enforce minimum transaction threshold (Razorpay requires >= 100 paise)
return refundPaise >= 100 ? refundPaise : 0;
}Issuing the Refund via API
To refund a Razorpay payment, query the payments or invoices linked to the subscription, find the captured payment ID, and issue the partial refund:
// razorpay.service.ts
async refundPayment(
paymentId: string,
amountPaise?: number,
notes?: Record<string, string>,
): Promise<{ id: string; status: string } | null> {
const body: Record<string, any> = {};
if (typeof amountPaise === 'number' && amountPaise > 0) {
body.amount = amountPaise;
}
if (notes) {
body.notes = notes;
}
return this.request(`/payments/${encodeURIComponent(paymentId)}/refund`, {
method: 'POST',
body: JSON.stringify(body),
});
}7. Status Mapping & The "Halted" Trap
Razorpay subscription lifecycles do not map 1:1 to generic billing states. In particular, Razorpay has a status called halted.
A subscription moves to halted when recurring debit attempts fail repeatedly (e.g., customer had insufficient funds or expired card). A halted subscription is NOT active, but naive implementations check:
// ❌ WRONG
if (remote.status !== 'cancelled') {
user.isPro = true;
}This would grant free ongoing Pro access to users whose cards are declining!
Here is the authoritative status mapping:
private mapRazorpayStatus(status: string): SubscriptionStatus {
const s = String(status || '').toLowerCase();
// Authenticated = Mandate created, awaiting first cycle
if (s === 'active' || s === 'authenticated') {
return SubscriptionStatus.ACTIVE;
}
if (s === 'cancelled') {
return SubscriptionStatus.CANCELLED;
}
if (s === 'completed' || s === 'expired') {
return SubscriptionStatus.EXPIRED;
}
// Halted / Paused / Pending = Payment failure, revoke paid features immediately
if (s === 'paused' || s === 'pending' || s === 'halted') {
return SubscriptionStatus.SUSPENDED;
}
return SubscriptionStatus.APPROVAL_PENDING;
}8. Summary Checklist for Production
Before taking your Razorpay integration live, run through this checklist:
Dual-Mode Checkout: Do you support one-time Orders as a fallback for users whose cards reject RBI recurring mandates?
Flipped Argument Verification: Are you using
${orderId}|${paymentId}for orders and${paymentId}|${subscriptionId}for subscriptions?Timing-Safe Equality: Are you comparing HMAC buffers using
crypto.timingSafeEqual()instead of===?Fastify Raw Body: Is
{ rawBody: true }configured in your HTTP adapter so webhooks are verified against the pristine stream?Idempotent Webhooks: Does your webhook handler catch PostgreSQL
23505unique violations to handle concurrent delivery gracefully?Currency Subunits: Are all amounts computed as integers in paise (₹499 =
49900) with no floating-point arithmetic?Boot Plan Verification: Do you assert that remote Razorpay plans match local interval and currency configurations on initialization?
Status Mapping: Does your system suspend access when a subscription transitions to
haltedorpaused?
Getting payment plumbing right is unglamorous, but handling these edge cases upfront ensures your billing engine runs autonomously without silent revenue leaks or angry customers.
Comments (1)
Join the discussion by logging into your account.
Igor Ganapolsky
The 23505 handler runs after reconcileRazorpaySubscription. Two deliveries of the same event can both miss findOne, both reconcile, and only then one insert loses the race. Persist provider_event_id first, in the same transaction, and return on unique_violation before you touch the subscription row. The order path has a second hole. payment.captured and order.paid are different event ids for one payment, so a unique constraint on the webhook id still extends currentPeriodEnd twice. Key the entitlement write on payment_id.