Why do Razorpay webhooks fail for most Indian SaaS teams?
Everyone believes webhook integration is just writing a basic HTTP callback handler. Here’s why that’s wrong.
Engineering teams often hook Razorpay directly into downstream business logic. They update user balances, issue GST invoices, or grant workspace access right inside the HTTP request handler.
Under normal operating conditions, this naive architecture works. But payment flows in India frequently hit network latency, banking gateway drop-offs, and micro-outages. When an endpoint times out, throws an unhandled exception during heavy database loads, or hits transient infrastructure failures, webhooks pile up fast. If downstream systems run synchronously, slow response times immediately trigger delivery failures. That includes WhatsApp confirmation alerts or accounting syncs like those described in our Razorpay UPI & WhatsApp n8n Automation blueprint.
According to Webhooks – Best Practices – Razorpay, Razorpay considers any HTTP response outside the 2xx status range as an event delivery failure and retries delivery under an exponential backoff policy for 24 hours.
Without a dedicated idempotent ingestion layer, these retries execute repetitive database operations. You’ll end up with duplicate subscription provisioning, double credit allotments, and mismatched ledgers. If your endpoint consistently fails to return a 2xx status, Razorpay will automatically deactivate it.
Is this starting to feel like a lot?
Let us handle the busy work. LoomVector builds and ships this exact kind of system for Indian SMBs, end to end.
Step-by-Step Implementation: Verification and Idempotent Pipeline
Step 1: Capture the Raw HTTP Request Payload
Signature verification fails immediately if your framework deserializes the JSON request payload before computing the signature. Middleware like express.json() or standard body parsers alter byte sequences, whitespace, and Unicode formatting.
According to Razorpay Webhook Documentation Review | Svix Blog, Razorpay computes webhook signatures using an HMAC SHA-256 algorithm with your configured webhook secret as the cryptographic key and the raw request body as the message.
To preserve the raw body in an Express application, configure your route using express.raw():
import express from 'express';
const app = express();
app.post(
'/api/v1/razorpay-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
// req.body is an unmodified Buffer
}
);
Step 2: Implement Cryptographic HMAC SHA-256 Signature Verification
Once you’ve isolated the raw buffer, extract the X-Razorpay-Signature header from the incoming request. Use Node.js’s native crypto module to generate an HMAC SHA-256 digest using your webhook secret. Perform a constant-time comparison using crypto.timingSafeEqual to eliminate timing attack vulnerabilities:
import crypto from 'crypto';
function verifyRazorpaySignature(rawBodyBuffer, receivedSignature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(rawBodyBuffer)
.digest('hex');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
const receivedBuffer = Buffer.from(receivedSignature, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
If the signature comparison returns false, terminate execution immediately and respond with an HTTP 400 Bad Request.
Step 3: Implement Idempotent Ingestion Using Event Identifiers
Razorpay guarantees at-least-once delivery. Network retries can send the exact same event multiple times.
According to Webhooks – Razorpay Curlec, incoming duplicate webhook deliveries can be identified using the ‘x-razorpay-event-id’ request header, whose value is unique per event, enabling idempotent handling.
To prevent duplicate execution, establish an idempotency_keys table in PostgreSQL:
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
status VARCHAR(50) NOT NULL,
received_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
In your webhook controller, extract the header and attempt an atomic insert before invoking downstream business logic:
Incoming Webhook Request → Extract 'x-razorpay-event-id' → Database INSERT (Unique Check)
├─ Already Exists? → Return HTTP 200 OK (Stop Processing)
└─ New Event? → Enqueue Background Worker → Return HTTP 200 OK
app.post('/api/v1/razorpay-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-razorpay-signature'];
const eventId = req.headers['x-razorpay-event-id'];
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
const isValid = verifyRazorpaySignature(req.body, signature, webhookSecret);
if (!isValid) {
return res.status(400).send('Invalid signature');
}
try {
// Attempt an idempotent database write
const isNewEvent = await db.insertEventId(eventId);
if (!isNewEvent) {
// Event has already been processed or is queued; acknowledge to halt retries
return res.status(200).send('Event already processed');
}
// Parse payload buffer after verification
const payload = JSON.parse(req.body.toString('utf8'));
// Push event to background queue (e.g., BullMQ / SQS)
await queue.add('process-payment', { eventId, data: payload });
// Instantly acknowledge Razorpay
return res.status(200).send('Accepted');
} catch (err) {
return res.status(500).send('Internal Server Error');
}
});
According to Webhooks – Best Practices – Razorpay, Razorpay considers any HTTP response outside the 2xx status range as an event delivery failure and retries delivery under an exponential backoff policy for 24 hours. Acknowledging immediately with a 200 OK ensures your endpoint won’t trigger redundant retries.
Engineering teams scaling transaction-heavy payment backends often struggle with distributed state consistency, queuing architectures, and automated invoicing. LoomVector designs and builds production-grade ingestion systems, resilient microservice workflows, and payment infrastructure tailored for growing Indian software companies.