{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"building-production-subscriptions-with-razorpay-webhooks-signatures-and-edge-cases-odzkz","url":"https://zyvop.com/building-production-subscriptions-with-razorpay-webhooks-signatures-and-edge-cases-odzkz","title":"Building Production Subscriptions with Razorpay: Webhooks, Signatures, and Edge Cases","subtitle":"A practical engineering guide to handling RBI e-mandates, flipped HMAC signatures, Fastify raw body buffers, and idempotent webhooks in Node.js.","tldr":"Integrating Razorpay into a modern TypeScript backend is full of undocumented landmines: flipped HMAC arguments between orders and subscriptions, Fastify body parser mutations that break webhook signatures, and strict RBI recurring mandate failures. Here is how we engineered a resilient, dual-mode billing engine in NestJS and Fastify.","keywords":["backend","fastify","Payments","Architecture","razorpay","Tutorial","Building ZyVOP in Public"],"entities":["Sanju Singh","backend","fastify","Payments","Architecture","razorpay","Tutorial","Building ZyVOP in Public","ZyVOP"],"keyTakeaways":["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 23505 unique violations to handle concurrent delivery gracefully?","Currency Subunits: Are all amounts computed as integers in paise (₹499 = 49900) with no floating-point arithmetic?"],"headings":["1. The Two Indian Payment Realities: Subscriptions vs. Orders","The Dual-Mode Architecture","2. The Flipped Signature Verification Trap","The Order Signature Payload","The Subscription Signature Payload","Constant-Time Verification","3. Fastify &amp; NestJS: Preserving Raw Buffers for Webhooks","Why Naive NestJS/Fastify Implementations Fail","The Fix: Fastify Raw Body Preservation","4. The Client-Server Handshake &amp; Race Conditions","The Race Condition Failure Modes","Solution: Idempotent Webhook Storage with 23505 Handling","5. Preventing Configuration Drift on Startup","6. Prorations, Subunit Math, and Refunds","Never Use Floating Point for Currency Math","Issuing the Refund via API","7. Status Mapping &amp; The \"Halted\" Trap","8. Summary Checklist for Production"],"outboundLinks":[],"contentText":"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\"] --&gt; Check{\"Has recurring plan&lt;br/&gt;configured &amp; supported?\"} Check --&gt;|Yes: Full Subscription| SubFlow[\"POST /v1/subscriptions&lt;br/&gt;(plan_id: plan_MONTHLY)&lt;br/&gt;Status: APPROVAL_PENDING\"] Check --&gt;|No / Unsupported Card| OrderFlow[\"POST /v1/orders&lt;br/&gt;(amount: 49900 paise, INR)&lt;br/&gt;Receipt: rcpt_usr_...\"] SubFlow --&gt; SDK[\"Razorpay Checkout SDK&lt;br/&gt;(subscription_id)\"] OrderFlow --&gt; SDK2[\"Razorpay Checkout SDK&lt;br/&gt;(order_id)\"] SDK --&gt; VerifySub[\"POST /api/v1/billing/razorpay/verify&lt;br/&gt;(paymentId, subscriptionId, signature)\"] SDK2 --&gt; VerifyOrder[\"POST /api/v1/billing/razorpay/verify&lt;br/&gt;(paymentId, orderId, signature)\"] VerifySub --&gt; ActiveSub[\"Activate Ongoing Subscription\"] VerifyOrder --&gt; ActiveOrder[\"Activate Fixed-Term Access&lt;br/&gt;(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 &amp; 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 --&gt; JSON.parse() --&gt; 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&lt;NestFastifyApplication&gt;( 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&lt;string, any&gt;, @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 &amp; 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 calls POST /api/v1/billing/razorpay/verify. Server-side: Razorpay's servers send an asynchronous webhook (payment.captured or subscription.activated) to POST /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-&gt;&gt;Frontend: Completes UPI / Card Authentication Frontend--&gt;&gt;RZP: Payment Authorized par Parallel Execution Race RZP--&gt;&gt;Frontend: Returns payment_id &amp; signature Frontend-&gt;&gt;Backend: POST /razorpay/verify Backend-&gt;&gt;DB: Check subscription ownership &amp; activate and RZP-&gt;&gt;Backend: POST /webhooks/razorpay (subscription.activated) Backend-&gt;&gt;DB: Record webhook event (eventId) Backend-&gt;&gt;DB: Reconcile status &amp; activate endThe Race Condition Failure Modes If the webhook arrives first and locks the subscription row, the frontend /verify call 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 /verify call 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 &amp;&amp; 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 499 instead of 49900 paise. 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&lt;void&gt; { 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' &amp;&amp; plan?.period === expectedPeriod &amp;&amp; Number(plan?.interval) === 1 &amp;&amp; plan?.item?.currency === 'INR' &amp;&amp; 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 &lt;= 0 || remainingMs &lt;= 0) return 0; // Perform division last, truncate to integer paise const refundPaise = Math.floor((totalPaidSubunits * remainingMs) / totalDurationMs); // Enforce minimum transaction threshold (Razorpay requires &gt;= 100 paise) return refundPaise &gt;= 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&lt;string, string&gt;, ): Promise&lt;{ id: string; status: string } | null&gt; { const body: Record&lt;string, any&gt; = {}; if (typeof amountPaise === 'number' &amp;&amp; amountPaise &gt; 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 &amp; 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 23505 unique 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 halted or paused? 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.","contentHash":"sha256:fa44b499547f2c877288dfabe8e3480d3130eacd8e5fd036a27024fda1e4b342","authorName":"Sanju Singh","authorUrl":"https://zyvop.com/author/sanjay687","authorSameAs":[],"category":"Tutorial","tags":["backend","fastify","Payments","Architecture","razorpay"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":10,"wordCount":2140,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-05T11:30:00.415Z","updatedAt":"2026-10-05T11:23:22.902Z","canonicalUrl":"https://zyvop.com/building-production-subscriptions-with-razorpay-webhooks-signatures-and-edge-cases-odzkz"}