Why Multiple Payment Gateways?

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.

Payment Gateway Strategy: Customer initiates payment │ ▼ ┌─────────────┐ │ Juspay │ ←── Primary: payment orchestration layer │ (smart │ Routes to best gateway based on: │ routing) │ - Success rate history └──────┬──────┘ - Bank availability │ - Card type / UPI handle Routes to: │ ┌──────┴──────┬──────────────┐ ▼ ▼ ▼ PhonePe Razorpay [future gateway] (UPI-first) (cards, netbanking) If Juspay is down → direct PhonePe or Razorpay fallback If PhonePe fails → retry via Razorpay

What Juspay is

Juspay is a payment orchestration platform — it provides a unified API that internally routes to Razorpay, PhonePe, Paytm, and others. Benefits:

Interview Q&A

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 Keys

The problem idempotency solves

Without idempotency (dangerous): Client ──── POST /payments/initiate ────▶ Server Server processes Network drops before response Client retries: Client ──── POST /payments/initiate ────▶ Server Server processes AGAIN → Customer charged twice! With idempotency (safe): Client ──── POST /payments/initiate ────▶ Server { idempotencyKey: "uuid-1" } Server processes, stores key+result Network drops before response Client retries: Client ──── POST /payments/initiate ────▶ Server { idempotencyKey: "uuid-1" } Key exists → return cached result, NO re-process → Customer charged once only
// 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 });
  }
);

Webhook Handling Flow

Payment webhook processing: Gateway (Juspay/Razorpay) │ │ POST /webhooks/payment { event: 'payment.captured', ... } ▼ [1] Verify HMAC signature ──── FAIL → 401, drop │ PASS ▼ [2] Deduplicate: has this webhookId been processed? Redis SET NX webhookId (60min TTL) ── Already exists → 200 (idempotent ack) │ New ▼ [3] Parse event type payment.captured → update order PENDING→CONFIRMED payment.failed → update order PENDING→FAILED, notify user │ ▼ [4] Atomic DB update (optimistic lock on order) │ ▼ [5] Broadcast WebSocket event to customer/merchant/rider │ ▼ [6] Respond 200 to gateway (must be fast — gateway retries on timeout)
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.');
  }
}

Graceful Degradation

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');
}

Retry Logic for Webhook Delivery Failures

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 Recovery Workflow

// 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 });
  }
);
Interview Q&A

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.