JusPay Payment Gateway

Senior architecture guide to JusPay payment integration, debugging, and production resilience

Table of Contents

Overview & Scale

JusPay is a payment orchestration platform that processes 300+ million transactions daily across 150+ countries, with $1 trillion annualized payment volume and 99.999% uptime SLA.

It's not just a single payment gatewayβ€”it's a platform that aggregates multiple payment processors (Stripe, HDFC, Axis, Razorpay, etc.), enabling merchants to:

Payment Architecture

High-Level Payment Flow

Customer Browser/App ↓ JusPay Frontend (HyperCheckout/SDK) ↓ (Encrypted Payment Data) JusPay Backend ↓ (Routes to best gateway) Payment Gateway (Stripe, HDFC, etc) ↓ (Calls Bank/Network) Bank/Card Network ↓ (Authorization Response) JusPay Backend ↓ (Webhook fired immediately) Your Backend (Webhook Handler) ↓ (Also poll status API) Order Fulfillment

Key Components

Component Responsibility Critical for
HyperCheckout Pre-built payment page UI. Customer enters payment details here Reducing PCI scope, improving UX
Express Checkout SDK Mobile SDK for building custom payment flows Mobile app integration, custom UX
Webhooks Real-time notifications when payment status changes Order fulfillment, inventory management
Order Status API Query current payment status (polling mechanism) Recovery from webhook failures, reconciliation
Mandate API Recurring payments, subscriptions, automatic charges SaaS, subscriptions, installments

Supported Payment Methods

1. Cards (Debit & Credit)

2. UPI

3. Wallets

Airtel Money, Cred Pay, FreeCharge, Itz Cash, JioMoney, MobiKwik, PayTM

4. Net Banking

Direct transfer from customer's bank account

5. PayLater

Buy now, pay later options (Flipkart, Amazon Pay)

6. COD (Cash on Delivery)

Offline payment collection

βœ… Key Insight: Payment Method Routing

JusPay intelligently routes each transaction to the best processor based on success rates, customer profile, and transaction amount. This hidden routing is one of their biggest advantagesβ€”they're constantly optimizing which gateway to use.

Benefits & Pros

1. Aggregation & Redundancy

If Stripe is down, JusPay automatically fails over to another processor. You don't need to maintain integrations with multiple gateways.

// You call JusPay once
juspay.createOrder({amount: 1000, currency: "INR"})

// JusPay internally decides:
// - Try Stripe first (95% success rate today)
// - If fails, try HDFC (92% success rate)
// - If fails, try Razorpay (91% success rate)

2. Intelligent Retry & Recovery

Automatic retry logic for failed transactions without your intervention. Significantly improves conversion rates.

3. High Uptime SLA

99.999% uptime guarantee means you're not responsible for JusPay outages. Their infrastructure handles scale.

4. PCI Compliance Simplified

Using HyperCheckout means payment data never touches your servers. Massive security win.

5. Multi-Currency Support

Handle international payments without currency conversion complexity.

6. Comprehensive Dashboard

Real-time transaction monitoring, settlement tracking, refund management, all in one place.

7. Mobile-First SDKs

Native iOS/Android SDKs with excellent developer experience.

Pitfalls & Cons

❌ Critical Pitfall #1: Processing Payment Twice

Both webhook AND customer redirect arrive almost simultaneously. If you process on both, customer charged twice.

// 🚨 DANGEROUS - Processes payment twice
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    processOrder(event.orderId);  // Process 1
}

@PostMapping("/return")
public void handleReturn(String orderId) {
    processOrder(orderId);  // Process 2 (customer redirects here too!)
}
// βœ… GOOD - Idempotent processing with request ID
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    // Check if already processed using idempotency key
    if (orderService.isAlreadyProcessed(event.requestId)) {
        return;
    }
    processOrder(event.orderId);
}

@PostMapping("/return")
public void handleReturn(String orderId) {
    // Never process here, just verify and redirect to success page
    PaymentStatus status = juspay.getOrderStatus(orderId);
    if (status.success) {
        redirect("/success?orderId=" + orderId);
    }
}
❌ Critical Pitfall #2: Ignoring Webhook Duplicates

Network glitches can cause JusPay to send the same webhook multiple times. You must handle duplicates.

// 🚨 BAD - Will process duplicate webhooks
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    order.setStatus("PAID");
    order.save();  // If webhook arrives twice, order saved twice
}

// βœ… GOOD - Idempotent operations
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    Order order = orderRepository.findById(event.orderId);

    // Use database uniqueness constraint on webhook ID
    if (webhookLog.exists(event.webhookId)) {
        return;  // Already processed
    }

    order.setStatus("PAID");
    order.save();

    webhookLog.save(event.webhookId);  // Record processed webhook
}
❌ Critical Pitfall #3: Only Relying on Webhooks

Webhooks are "eventually consistent"β€”they may arrive late or not at all. If you ONLY listen to webhooks, you'll miss payments.

// 🚨 BAD - Missing payments if webhook fails
// Your webhook handler crashes? Payment not processed!

// βœ… GOOD - Dual approach: webhook + polling
// 1. Process immediately on webhook (fast path)
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    processPayment(event.orderId);
}

// 2. Poll for missed webhooks (safety net)
@Scheduled(fixedDelay = 60000)  // Every 60 seconds
public void reconcileMissedPayments() {
    List pending = orderRepository.findByStatus("PENDING");

    for (Order order : pending) {
        PaymentStatus status = juspay.getOrderStatus(order.getId());
        if (status.success && !order.isPaid()) {
            processPayment(order.getId());  // Catch missed webhook
        }
    }
}
⚠️ Pitfall #4: Not Validating Webhook Authenticity

Anyone can send a fake webhook to your endpoint. JusPay sends Basic Auth credentialsβ€”you must validate them.

// βœ… GOOD - Validate webhook authenticity
@PostMapping("/webhook")
public void handleWebhook(
    HttpServletRequest request,
    PaymentEvent event
) {
    // Extract and verify Basic Auth header
    String authHeader = request.getHeader("Authorization");
    String credentials = extractCredentials(authHeader);  // Decode Base64

    if (!credentials.equals(webhookUsername + ":" + webhookPassword)) {
        throw new UnauthorizedException("Invalid webhook credentials");
    }

    // Now safe to process
    processPayment(event.orderId);
}
⚠️ Pitfall #5: Not Handling Failed Transactions Properly

JusPay will mark transactions as FAILED, CANCELLED, PENDING. You must handle all states, not just SUCCESS.

Status What it means Action
SUCCESS Payment confirmed Fulfill order immediately
PENDING Still processing (typical for UPI) Wait, don't fulfill yet. Check again in 5 min
FAILED Transaction declined by gateway Show error to customer, allow retry
CANCELLED Customer abandoned payment Show error, allow restart
⚠️ Pitfall #6: Slow Webhook Handler

If your webhook handler takes >30 seconds, JusPay times out. Keep it fast.

// 🚨 BAD - Webhook handler that's too slow
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    // Sending email (slow, 5+ seconds)
    emailService.sendOrderConfirmation(event.orderId);

    // Updating 10 different microservices (slow)
    inventoryService.reserve(event.orderId);
    shippingService.createLabel(event.orderId);
    analyticsService.recordSale(event.orderId);

    // This takes too long! Webhook times out
}

// βœ… GOOD - Async processing
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    // Quick: mark payment as received
    orderService.markAsPaid(event.orderId);

    // Async: everything else happens in background
    eventPublisher.publishPaymentSucceeded(event.orderId);
    // (Other services consume this event asynchronously)

    return;  // Return immediately to JusPay
}
⚠️ Pitfall #7: Not Handling Refunds Properly

Refunds are asyncβ€”JusPay doesn't confirm immediately. You need to track refund status.

Debugging Strategy

Payment issues are complex because they involve multiple parties. Here's how to systematically debug:

Step 1: Understand the Transaction Flow

// Find the order ID
String orderId = "ORDER_123";

// Query JusPay API for complete transaction status
PaymentDetails details = juspay.getOrderStatus(orderId);

// Key fields to check:
// - status: SUCCESS, PENDING, FAILED, CANCELLED
// - gateway: Which processor handled it (Stripe, HDFC, etc)
// - gatewayTransactionId: Reference in external system
// - errorCode: Why did it fail?
// - errorMessage: Human-readable error
// - timestamps: created_at, authorized_at, captured_at
// - amount: What was charged?
// - method: Which payment method was used?

System.out.println("Order " + orderId + " is " + details.status);
System.out.println("Gateway: " + details.gateway);
System.out.println("Error: " + details.errorMessage);
System.out.println("Gateway TX ID: " + details.gatewayTransactionId);

Step 2: Check Webhook Logs

Did JusPay actually send a webhook for this transaction?

Step 3: Check Your Webhook Handler Logs

Did your server receive the webhook?

// In your webhook handler, always log:
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    logger.info("Webhook received for order {}: status={}, gateway={}, amount={}",
        event.orderId,
        event.status,
        event.gateway,
        event.amount
    );

    try {
        processPayment(event.orderId);
        logger.info("Successfully processed order {}", event.orderId);
    } catch (Exception e) {
        logger.error("Failed to process order {}: {}", event.orderId, e.getMessage());
        throw e;  // Let JusPay retry
    }
}

Step 4: Common Error Codes & What They Mean

Error Code Meaning Action
DECLINED Card/account declined by bank Customer needs to try different card/method
INSUFFICIENT_FUNDS Customer doesn't have enough balance Customer needs to add funds
EXPIRED_CARD Card expiry date passed Customer needs updated card
INVALID_CVV CVV doesn't match Customer enters wrong CVV, retry
3D_SECURE_FAILED OTP verification failed Customer re-enter OTP
GATEWAY_TIMEOUT External gateway didn't respond in time JusPay auto-retries, or customer retries
DUPLICATE_TRANSACTION Same transaction sent twice Check idempotency key, don't retry

Step 5: Production Debugging Checklist

// When a payment issue is reported:

1. Get order ID from customer
2. Query JusPay: juspay.getOrderStatus(orderId)
   β”œβ”€ Check status (SUCCESS? PENDING? FAILED?)
   β”œβ”€ Check gateway (which processor?)
   └─ Check errorMessage

3. If SUCCESS but not processed:
   β”œβ”€ Check webhook logs (did we receive it?)
   β”œβ”€ Check your application logs (did handler run?)
   └─ Check database (is order marked as paid?)

4. If FAILED:
   β”œβ”€ Check errorCode (is it permanent or transient?)
   β”œβ”€ If transient (GATEWAY_TIMEOUT): Customer can retry
   β”œβ”€ If permanent (DECLINED): Customer needs different method
   └─ If fraud: Check with customer

5. If PENDING (typical for UPI):
   β”œβ”€ Wait 5-10 minutes (UPI can be slow)
   β”œβ”€ Query again: juspay.getOrderStatus(orderId)
   └─ Check if now SUCCESS

6. Always have:
   β”œβ”€ Trace ID (pass through all requests)
   β”œβ”€ Idempotency Key (prevents duplicates)
   └─ Full logs (timestamp, user, amount, method)

Webhook Architecture

Webhook Reliability

βœ… Fact: JusPay Webhooks Are Reliable

JusPay guarantees webhook delivery through automatic retries. If your server is down, they retry for 24-48 hours.

However, You Still Need To Implement Polling

Why? Because "reliable" doesn't mean "100% guaranteed". Network issues, firewall rules, or your server crashes can cause webhooks to be missed. You need a safety net.

// 1. Fast path: Handle webhook immediately
@PostMapping("/webhook")
public void handleWebhook(PaymentEvent event) {
    orderService.markAsPaid(event.orderId);
}

// 2. Safety net: Reconciliation job (runs every hour)
@Scheduled(cron = "0 0 * * * *")  // Every hour
public void reconcilePayments() {
    // Find all orders that are marked "PENDING_PAYMENT"
    List pending = orderRepository.findByStatus("PENDING_PAYMENT");

    for (Order order : pending) {
        // Check JusPay: has this been paid?
        PaymentStatus status = juspay.getOrderStatus(order.getId());

        if (status.isSuccess()) {
            logger.warn("Found missed webhook for order {}", order.getId());
            orderService.markAsPaid(order.getId());
        }
    }
}

Webhook Request Structure

POST /webhook HTTP/1.1
Host: your-api.example.com
Authorization: Basic base64(username:password)
Content-Type: application/json

{
    "event_name": "payment_succeeded",
    "order_id": "ORDER_123",
    "transaction_id": "TXN_456",
    "status": "SUCCESS",
    "gateway": "stripe",
    "gateway_transaction_id": "ch_1J3G3K2eZvKYlo2C",
    "amount": 50000,      // In paise (lowest currency unit)
    "currency": "INR",
    "payment_method": "card",
    "timestamp": "2026-04-23T15:30:00Z",
    "error_message": null,
    "error_code": null
}
⚠️ Important: Amount is in paise, not rupees!

β‚Ή500 = 50000 paise. Always divide by 100 to get actual amount.

Best Practices

1. Use Request IDs & Trace IDs

// Generate unique ID for every order
String requestId = UUID.randomUUID().toString();

// Pass it through entire flow
JuspayOrder order = juspay.createOrder(
    JuspayOrderRequest.builder()
        .orderId("ORDER_123")
        .requestId(requestId)  // Idempotency key
        .amount(50000)
        .currency("INR")
        .metadata(Map.of("traceId", requestId))
        .build()
);

// Log with trace ID everywhere
logger.info("[{}] Creating order for customer", requestId);
logger.info("[{}] Received webhook payment.success", requestId);

2. Implement Idempotency Properly

// Use unique constraint on request_id
@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = "requestId"))
public class Order {
    @Id
    private String orderId;

    @Column(unique = true)
    private String requestId;  // Prevent duplicate processing

    // ... other fields
}

// Check before processing
public void processPayment(String orderId, String requestId) {
    Order existing = orderRepository.findByRequestId(requestId);

    if (existing != null) {
        // Already processed this request
        return existing;
    }

    // Process new request
    Order order = new Order(orderId, requestId);
    orderRepository.save(order);
}

3. Always Have Fallback Payment Methods

If primary payment method fails, offer alternatives:

4. Monitor These Metrics

// Track these in your monitoring system:

- Payment success rate (%), broken down by:
  β”œβ”€ Payment method (cards, UPI, wallet, etc)
  β”œβ”€ Gateway (Stripe, HDFC, Razorpay, etc)
  └─ Error code (DECLINED, INSUFFICIENT_FUNDS, etc)

- Payment latency (how long until webhook received?)
  β”œβ”€ P50: 50th percentile
  β”œβ”€ P95: 95th percentile
  └─ P99: 99th percentile

- Webhook delivery success rate
  β”œβ”€ How many webhooks did JusPay send?
  β”œβ”€ How many did we receive?
  └─ How many did we process?

- Reconciliation gap
  β”œβ”€ Payments in JusPay but not in your system
  └─ Indicates missed webhooks

5. Secure Webhook Handling

// 1. Validate all webhooks
@PostMapping("/webhook")
public void handleWebhook(HttpServletRequest request, PaymentEvent event) {
    // Verify Authorization header
    if (!isValidWebhookAuth(request)) {
        throw new UnauthorizedException("Invalid webhook");
    }

    // 2. Validate webhook signature (if available)
    // Some gateways provide HMAC signatures

    // 3. Prevent replay attacks
    if (webhookLog.exists(event.webhookId)) {
        return;  // Already processed
    }

    // 4. Validate amounts match
    Order order = orderRepository.findById(event.orderId);
    if (order.getAmount() != event.amount) {
        logger.error("Amount mismatch for order {}", event.orderId);
        throw new IllegalStateException("Amount doesn't match");
    }

    // 5. Process safely
    processPayment(event.orderId);
    webhookLog.record(event.webhookId);
}

Guardrails

Guardrail #1: Always Return HTTP 200 Immediately

Return success to JusPay instantly, then process asynchronously:

@PostMapping("/webhook")
public ResponseEntity handleWebhook(PaymentEvent event) {
    // Queue for async processing
    eventQueue.add(event);

    // Return immediately
    return ResponseEntity.ok().build();
}
Guardrail #2: Implement Dual-Write Safety

Process on webhook AND validate on redirect, but don't double-process:

// Database prevents duplicate processing
@Column(unique = true, nullable = false)
private String webhookId;  // Unique per event
Guardrail #3: Set Up Payment Failure Alerts

Alert when payment success rate drops:

// If success rate < 90%, alert
double successRate = (successCount / totalCount) * 100;
if (successRate < 90) {
    alerts.critical("Payment success rate below 90%");
}
Guardrail #4: Never Retry Failed Payments Automatically

Only customer-initiated retries. Automatic retries can lead to duplicate charges.

Guardrail #5: Log Everything for Audit Trail

Every payment event must be logged with full context for debugging:

PaymentAuditLog log = PaymentAuditLog.builder()
    .orderId(orderId)
    .requestId(requestId)
    .action("WEBHOOK_RECEIVED")
    .status(event.status)
    .gateway(event.gateway)
    .amount(event.amount)
    .timestamp(Instant.now())
    .userId(order.getUserId())
    .ipAddress(request.getRemoteAddr())
    .userAgent(request.getHeader("User-Agent"))
    .build();
auditLogRepository.save(log);

Quick Reference: Common Issues & Fixes

Issue Root Cause Fix
Payment success in JusPay but order not marked as paid Webhook not received OR webhook processed but failed silently 1. Check webhook logs in JusPay dashboard 2. Implement polling reconciliation 3. Add better error handling in webhook
Customer charged twice Webhook processed + Customer clicked button again + Both triggered order fulfillment Use idempotent order processing with unique request IDs
Payment marked FAILED but money deducted from account Authorized but not captured. Or processing error. Or gateway issue. Check gateway transaction ID in JusPay. Contact JusPay support with TX ID. Can refund manually.
UPI payment showing PENDING for 30+ minutes Normal. UPI can be slow. Or customer didn't complete OTP. Wait. Show customer "payment pending" message. Check again after 5 min. After 24h, assume cancelled.
Webhook timeout errors in JusPay dashboard Your webhook handler takes too long (>30s) or your server is down Return HTTP 200 immediately. Process order asynchronously. Monitor server health.
Getting "Duplicate transaction" error Sending same request ID twice Don't retry with same request ID. Generate new one for retry.