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:
- Accept multiple payment methods (Cards, UPI, Wallets, Net Banking, PayLater)
- Route transactions intelligently based on success rates
- Recover failed transactions automatically
- Support recurring payments and subscriptions
- Provide a seamless payment experience across web and mobile
Payment Architecture
High-Level Payment Flow
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)
- Visa, Mastercard, American Express, RuPay
- 3D Secure authentication (mandatory for high-value transactions)
- Tokenization for faster repeat payments
2. UPI
- Direct UPI Intent flows (fastest)
- Supported apps: Google Pay, PhonePe, Paytm
- QR code generation for offline merchants
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
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
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);
}
}
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
}
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
}
}
}
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);
}
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 |
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
}
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?
- Login to JusPay Dashboard β Transactions
- Search for order ID
- Check "Webhook Status" or "Notification Status"
- If webhook failed, JusPay shows: β Failed, Reason: [connection timeout, 500 error, etc]
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
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
}
βΉ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:
- Card failed? β Try UPI
- UPI failed? β Try wallet
- All failed? β Offer PayLater or COD
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
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();
}
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
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%");
}
Only customer-initiated retries. Automatic retries can lead to duplicate charges.
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. |