Juspay, PhonePe, Razorpay, idempotency keys, retry logic, graceful degradation, and admin recovery workflows.
India's UPI payment infrastructure has a ~2-3% transaction failure rate. These failures are real and frequent: bank timeouts, UPI PIN issues, network drops, VPA resolution failures. A single gateway failure can also cause platform-wide payment outage.
Juspay is a payment orchestration platform — it provides a unified API that internally routes to Razorpay, PhonePe, Paytm, and others. Benefits:
Q: How did you choose between Juspay and direct Razorpay/PhonePe integration?
A: Juspay as the primary makes sense for a production platform because it abstracts gateway management. But we maintained direct PhonePe and Razorpay integrations as fallbacks in case Juspay itself goes down (which has happened). The triple-redundancy cost is justified at ₹1.5Cr GMV — a 2-hour payment outage could lose ₹25,000+ in orders.
// Idempotency middleware
async function idempotencyGuard(req, res, next) {
const key = req.headers['idempotency-key'] || req.body.idempotencyKey;
if (!key) return next(); // optional for non-payment routes
const cacheKey = `idem:${req.user.tenantId}:${key}`;
// Check if we've seen this key before
const cached = await redis.get(cacheKey);
if (cached) {
// Return exact same response as first call
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
// Intercept response to cache it
const originalJson = res.json.bind(res);
res.json = (body) => {
// Cache for 24 hours (payment window)
redis.setex(cacheKey, 86400, JSON.stringify({
status: res.statusCode,
body,
}));
return originalJson(body);
};
next();
}
// Payment initiation with idempotency
router.post('/payments/initiate',
authenticate,
idempotencyGuard,
async (req, res) => {
const { orderId, amount, gateway } = req.body;
// Store payment attempt with idempotency key in DB
const payment = await Payment.create({
orderId,
amount,
gateway,
idempotencyKey: req.body.idempotencyKey,
status: 'PENDING',
tenantId: req.user.tenantId,
});
// Initiate with gateway
const result = await initiateWithGateway(gateway, payment);
res.json({ paymentId: payment._id, ...result });
}
);
async function handlePaymentWebhook(event, tenantId) {
const { paymentId, status, orderId } = parseGatewayEvent(event);
// Deduplicate: mark as processing (SETNX = atomic set if not exists)
const webhookKey = `webhook:${event.id}`;
const isNew = await redis.set(webhookKey, '1', 'NX', 'EX', 3600);
if (!isNew) return; // already processed — webhook deduplication
if (status === 'CAPTURED') {
// Update order from PENDING_PAYMENT → CONFIRMED
await transitionOrder(orderId, 'CONFIRMED', 'system', tenantId);
await Payment.findOneAndUpdate(
{ gatewayPaymentId: paymentId },
{ status: 'CAPTURED', capturedAt: new Date() }
);
} else if (status === 'FAILED') {
await transitionOrder(orderId, 'FAILED', 'system', tenantId);
// Trigger user notification
await sendPushNotification(orderId, 'Payment failed. Please retry.');
}
}
async function initiatePayment(orderId, amount, preferredGateway = 'juspay') {
const gateways = [preferredGateway, 'phonepe', 'razorpay'].filter(
(g, i, arr) => arr.indexOf(g) === i // deduplicate
);
let lastError;
for (const gateway of gateways) {
try {
const result = await gatewayClients[gateway].initiatePayment({
orderId, amount,
callbackUrl: `${BASE_URL}/webhooks/${gateway}`,
});
return { gateway, ...result };
} catch (err) {
lastError = err;
console.error(`Gateway ${gateway} failed:`, err.message);
// Log for monitoring — alerting on repeated failures
await logGatewayFailure(gateway, err);
// Short delay before trying next gateway
await sleep(500);
}
}
// All gateways failed → hold order in PENDING_PAYMENT, notify admin
await notifyAdminGatewayOutage(orderId, lastError);
throw new ServiceUnavailableError('Payment services temporarily unavailable');
}
Sometimes our webhook endpoint is temporarily unavailable (deployment restart). Gateways retry with their own schedule. We implement our own retry for internal events:
// Exponential backoff retry for failed webhook processing
const RETRY_DELAYS = [30, 300, 1800]; // 30s, 5min, 30min
async function processWebhookWithRetry(event, attempt = 0) {
try {
await handlePaymentWebhook(event);
} catch (err) {
if (attempt >= RETRY_DELAYS.length) {
// Final failure: move to dead-letter queue, alert admin
await deadLetterQueue.add({ event, error: err.message });
await alertAdmin('Webhook processing exhausted retries', event);
return;
}
// Schedule retry
const delay = RETRY_DELAYS[attempt];
setTimeout(() => processWebhookWithRetry(event, attempt + 1), delay * 1000);
}
}
// Admin endpoint: manually override payment status with full audit trail
router.post('/admin/payments/:id/override',
authenticate,
authorize('payment:admin-override'),
async (req, res) => {
const { status, reason, evidence } = req.body; // evidence = screenshot URL, etc.
const payment = await Payment.findOneAndUpdate(
{ _id: req.params.id, tenantId: req.user.tenantId },
{ status, adminNote: reason, overriddenAt: new Date(), overriddenBy: req.user.sub },
{ new: true }
);
// Audit log — critical for financial reconciliation
await AuditLog.create({
action: 'payment.admin.override',
userId: req.user.sub,
resourceId: payment._id,
diff: { before: { status: payment.status }, after: { status } },
metadata: { reason, evidence },
});
// If overriding to CAPTURED, transition order
if (status === 'CAPTURED') {
await transitionOrder(payment.orderId, 'CONFIRMED', req.user.sub);
}
res.json({ payment });
}
);
Q: What is a reconciliation cron job?
A: Payment gateways provide a settlement report daily (CSV/API) with all transactions and their final status. Our reconciliation cron (runs at 2am daily) fetches this report, compares it against our Payment collection, and flags mismatches — orders marked CONFIRMED in our DB but marked FAILED by the gateway (or vice versa). These discrepancies require manual review. This is how you detect silent payment failures that didn't trigger webhooks.