logo

Provider-Agnostic Payment Service with Spring Boot: Stripe & Razorpay Integration

A deep-dive into resilient, provider-agnostic payment processing

Spring Boot · Stripe · Razorpay · Resilience4j · Webhooks

By Bharat Chaudhari July 16, 2026

Introduction

Payment systems represent one of the most critical components in modern software platforms.

Unlike many business workflows, payment processing directly impacts revenue, customer trust, compliance requirements, and operational risk.

Failures in payment infrastructure are rarely isolated from technical issues. They often result in:

  • Lost transactions
  • Duplicate charges
  • Reconciliation challenges
  • Customer support escalations
  • Reputational damage

Building reliable payment systems therefore requires more than integrating a payment provider SDK.

A production-grade payment platform must address:

  • Provider abstraction
  • Webhook reliability
  • Idempotency
  • Failure handling
  • Retry strategies
  • Operational visibility

In this article, we’ll examine how a provider-agnostic Payment Service enables integration with multiple payment gateways while maintaining reliability, extensibility, and operational resilience.

Why Payments Deserve Their Own Service

Many applications initially implement payment processing directly within business services.

Examples:

  • Order Service
  • Subscription Service
  • Booking Service

Over time, this creates duplicated integrations and inconsistent payment behavior.

A dedicated Payment Service centralizes:

  • Provider integrations
  • Refund workflows
  • Webhook handling
  • Payment status tracking
  • Reconciliation

This creates a single source of truth for financial transactions while simplifying future provider integrations.

What You Will Build

At the end of this guide, you will have a fully functional payment microservice that:

  • Supports Stripe and Razorpay through a single, unified API surface.
  • Initiates checkout sessions and processes refunds against either provider with a single provider name parameter.
  • Handles all webhook lifecycle events — including async payment success, failure, and expiry — so your local database state always mirrors the gateway.
  • Uses database-level idempotency to guarantee that duplicate webhook deliveries from providers are silently ignored rather than double-applied.
  • Wraps every outbound API call in a Resilience4j circuit breaker that fails fast and prevents thread starvation when a provider is unavailable.
  • Exposes Prometheus metrics and Micrometer traces through Spring Actuator for production observability.

Prerequisites

Before you start, make sure the following tools are installed, and the access prerequisites are met.

Required Tools and Versions

  • Java Development Kit (JDK) 21
  • Spring Boot 4.0.3
  • PostgreSQL 15
  • Apache Maven
  • ngrok (for local webhook testing — explained in Step 4)

Account Access

You will need developer accounts with the following providers. Both offer free sandbox environments:

  • Stripe — for API keys and a webhook signing secret.
  • Razorpay — for an API key, API secret, and webhook secret.

Getting Started

Clone the repository and build the project using Maven. This will download all dependencies and compile the application:

				
					git clone https://github.com/yourusername/payment-service.git 
cd payment-service 
mvn clean install 
				
			

Configuration

The service is entirely driven by environment variables, which are referenced from application.yml. This approach means you never commit secrets to source control and can deploy the same artifact to different environments simply by changing the variables in scope.

Set the following variables in your shell, CI/CD environment, or .env file before starting the application:

Database

  • DB_HOST — hostname of your PostgreSQL instance (e.g. localhost)
  • DB_PORT — port, typically 5432
  • DB_NAME — the database name
  • DB_USERNAME — the database user
  • DB_PASSWORD — the database password

Stripe

  • STRIPE_SECRET_KEY — your Stripe secret API key (starts with sk_test_ in sandbox)
  • STRIPE_WEBHOOK_SECRET — the webhook signing secret generated in the Stripe dashboard

Razorpay

  • RAZORPAY_API_KEY — your Razorpay key ID
  • RAZORPAY_API_SECRET — your Razorpay key secret
  • RAZORPAY_WEBHOOK_SECRET — the secret set on the Razorpay webhook configuration

Tip: Keep a .env.example file in your repository with placeholder values, so new contributors know exactly which variables to populate without ever seeing real credentials.

Core Architecture

Before diving into individual code snippets, it helps to understand the three architectural decisions that make this service robust enough for production. Each one solves a real class of production failure.

Why We Built a Provider-Agnostic Architecture

Payment providers change.

Organizations frequently:

  • Add providers
  • Replace providers
  • Expand into new regions

A tightly coupled implementation makes these changes more expensive.

The Payment Service uses a provider abstraction layer.

Business services interact with:

  • Payment Service

not:

  • Stripe
  • Razorpay
  • PayPal
  • Adyen

This separation allows providers to evolve independently without impacting business workflows.

Payment Flow

				
					Client  
  │  
  ▼
Payment Service  
  │  
  ▼  
Provider Factory  
 ┌───────────────┐  
 │               │  
 ▼               ▼  
Stripe       Razorpay  
  │               │  
  ▼               ▼  
Payment         Events  
  │  
  ▼  
Webhook Processing  
  │  
  ▼  
Transaction Status Update
				
			

1. The Provider Factory Pattern

The most important design choice in this codebase is that no controller, service, or business logic class ever imports Stripe’s SDK or Razorpay’s SDK directly. Instead, all providers implement a common PaymentProvider interface, and a PaymentProviderFactory resolves the correct implementation at runtime based on the provider’s name passed in the API request.

This is what the factory looks like:

				
					@Component 
@RequiredArgsConstructor 
public class PaymentProviderFactory { 
    private final List<PaymentProvider> paymentProviders; 
 
    public PaymentProvider getPaymentProvider(String providerName) { 
        PaymentGateway gateway = PaymentGateway.fromString(providerName); 
        return paymentProviders.stream() 
                .filter(provider -> provider.supports() == gateway) 
                .findFirst() 
                .orElseThrow(() -> 
                    new UnsupportedPaymentProviderException(providerName)); 
    } 
} 
				
			

Why Provider Abstraction Matters

The Provider Factory pattern creates a clean separation between:

  • Business Logic
  • Provider Implementation

Adding a new provider becomes:

  1. Implement the interface.
  2. Register provider.
  3. Deploy.

No changes are required for existing integrations.

This reduces risk while improving maintainability.

2. Async Idempotent Webhooks

Why Webhooks Are Challenging

Payment processing rarely completes synchronously.

Providers often communicate final transaction status through webhooks.

Examples:

  • Payment succeeded
  • Payment failed
  • Refund processed
  • Charge disputed

Webhook infrastructure introduces several challenges:

  • Duplicate deliveries
  • Out-of-order events
  • Network failures
  • Provider retries

A robust payment system must treat webhooks as unreliable inputs and design accordingly.

Idempotency: The Most Important Payment Concept

Payment providers routinely retry webhooks.

This behavior is intentional and necessary.

Without idempotency, retries could result in:

  • Duplicate orders
  • Duplicate refunds
  • Duplicate accounting entries

The Payment Service maintains an idempotency store that records processed webhook events.

When duplicate events arrive, they are safely ignored.

This guarantees that each payment event affects system state exactly once regardless of how many times providers retry delivery.

Why Webhook Processing Is Asynchronous

Webhook endpoints should respond quickly.

Providers expect rapid acknowledgement.

Long-running processing increases the likelihood of retries and duplicate events.

For this reason:

				
					Webhook Received
       ↓
Persist Event
       ↓
Return 200 OK
       ↓
Process Asynchronously
				
			

This improves throughput and reduces provider-side retries.

Here is how the service implements this two-layer defence: First, the webhook endpoint returns 200 OK immediately and offloads all work to a dedicated @Async thread pool. Second, every event is checked against a webhook_events database table before any business logic is executed.

				
					@Async("paymentAsyncExecutor") 
public void processCheckoutCompletedAsync(Event event) { 
    String eventId = event.getId(); 
 
    // 1. Idempotency check — bail out if already handled 
    if (webhookEventService.isAlreadyProcessed(eventId)) { 
        log.info("Stripe event already processed, skipping. eventId={}", eventId); 
        return; 
    } 
 
    // 2. Write a raw audit record 
    WebhookEventEntity webhookEvent = webhookEventService.recordEvent( 
            "stripe", eventId, event.getType(), event.toJson()); 
 
    // 3. Execute business logic 
    transactionService.saveTransaction( 
            request, paymentIntentId, sessionId, "stripe", "PAID"); 
 
    // 4. Mark the event as fully processed 
    webhookEventService.markProcessed(webhookEvent); 
}
				
			

Why this matters: Steps 2 and 4 act as a distributed lock. If the process crashes between recording the event and marking it processed, the event will be reprocessed on the next delivery — but once markProcessed runs, all future deliveries are silently skipped. The raw audit log in step 2 also gives you a forensic trail of every webhook ever received.

3. Full Webhook Lifecycle and Refund Synchronisation

Most tutorials only handle the happy path: a checkout completes, and you record a PAID status. Real payments are messier. Buy Now Pay Later instruments may take hours to settle. Sessions expire. Refunds can be partially processed and then updated again.

This service listens to the entire event lifecycle:

  • checkout.session.async_payment_succeeded — marks pending transactions as PAID once a delayed payment settles.
  • checkout.session.async_payment_failed — transitions records to FAILED so customers can be notified and retry.
  • checkout.session.expired — cleans up stale PENDING records automatically.
  • charge.refunded, charge.refund.updated, refund.processed — asynchronously patches RefundEntity records so the database always reflects the gateway’s current refund state, with no reliance on UI triggers.

This background synchronisation pattern is what separates a proof-of-concept integration from infrastructure you can genuinely rely on in production.

Circuit Breakers with Resilience4j

A payment gateway outage does not just mean failed transactions – it can mean thousands of HTTP threads blocking a timeout, exhausting your connection pool and taking down your entire service. Circuit breakers prevent this by detecting failure patterns and short-circuiting calls before they even reach the remote endpoint.

Every outbound call to Stripe or Razorpay in this service is wrapped in a Resilience4j circuit breaker. When the failure rate crosses a configurable threshold, the circuit opens, and all calls fail immediately with a controlled exception. After a configured wait, the circuit moves half-open and tests whether the provider has recovered.

Resilience Through Circuit Breakers

External providers are beyond our control.

Failures happen.

Without protection, provider outages can:

  • Exhaust threads
  • Increase latency
  • Cascade failures

Circuit breakers detect repeated failures and temporarily stop outbound calls.

Benefits:

  • Faster failure handling
  • Reduced resource consumption
  • Improved platform stability

This pattern is essential whenever critical workflows depend on external systems.

Running the Service

Start the application locally with:

				
					mvn spring-boot:run 
				
			

For webhook testing, you need a publicly accessible URL. Use ngrok to create a tunnel to your local port:

				
					ngrok http 8085
				
			

Copy the HTTPS URL ngrok provides and paste it into the Stripe or Razorpay dashboard as your webhook endpoint. Both providers will now deliver real events to your local machine.

Example: Initiating a Checkout Session
To start a Stripe checkout session, send a POST request to the checkout endpoint:

				
					curl -X POST http://localhost:8085/v1/payment/checkout/stripe \ 
  -H "Content-Type: application/json" \ 
  -d '{ 
    "name": "Premium Subscription", 
    "amount": 2000, 
    "quantity": 1, 
    "currency": "USD", 
    "email": "user@example.com" 
  }' 
				
			
A successful response returns a sessionUrl that you redirect your customer to:
				
					{ 
  "status": "SUCCESS", 
  "message": "Session created", 
  "sessionId": "cs_test_a1b2c3d4", 
  "sessionUrl": "https://checkout.stripe.com/pay/cs_test_a1b2c3d4", 
  "amount": null, 
  "key": null 
} 
				
			

To use Razorpay instead, simply change the stripe to razorpay in the URL path. The request and response shapes remain identical — which is exactly the point of the provider’s abstraction

Common Issues and Fixes

Webhook Signature Verification Fails

Symptom: You receive a 400 Bad Request or a signature validation exception when a provider delivers a webhook.

Fix: Verify that the signing secret in your environment variables exactly matches the one shown in the provider dashboard — a single misplaced character will cause every verification to fail. Also confirm that your endpoint is receiving the raw, unmodified request body. Spring MVC can inadvertently consume the InputStream before it reaches your validation code. This service avoids accepting the payload as a plain String (@RequestBody String), ensuring the raw bytes are available for HMAC verification.

Gateway Timeout Errors Under Load

Symptom: Webhooks are being routinely resent by the provider because your server takes too long to respond.

Fix: This happens when database writes or downstream calls are made synchronously inside the webhook handler. The endpoint must return 200 OK before doing any significant work. Confirm that the @Async("paymentAsyncExecutor") annotation is present and that the executor bean is correctly configured in application.yml. Tune the thread pool size (corePoolSize, maxPoolSize) for your hardware and expected webhook throughput.

Observability and Exception Handling

The service delegates global error formatting to the common-module by extending its GlobalExceptionHandler. This guarantees a consistent JSON error format across all endpoints, whether the failure is a missing parameter, an unsupported provider, or a downstream gateway timeout.

The service exposes two key observability surfaces out of the box, integrated with distributed tracing provided by the common-module.

Swagger / OpenAPI

Interactive API documentation is available at http://localhost:8080/swagger-ui.html. You can explore all endpoints, inspect requests and response schemas, and send test requests directly from the browser.

Prometheus Metrics

The /actuator/prometheus endpoint exposes custom metrics including checkout session latency histograms and webhook duplication counters. Connect this endpoint to Grafana for a live operational dashboard with no additional instrumentation required.

Reconciliation Considerations

No payment system should rely solely on real-time processing.

Periodic reconciliation helps identify:

  • Missed webhooks
  • Processing failures
  • Status mismatches

Many mature payment systems run scheduled reconciliation jobs to verify platform records against provider records.

This serves as an additional layer of protection against operational issues.

Production Considerations

PCI Awareness

Avoid storing sensitive card data unless absolutely necessary.

Leverage provider-hosted payment experiences where possible.

Audit Logging

Track:

  • Payment creation
  • Refund initiation
  • Webhook processing
  • Status transitions

Monitoring

Monitor:

  • Provider latency
  • Error rates
  • Refund volume
  • Webhook failures
  • Circuit breaker activity

Fraud Detection

As platforms mature, payment systems often integrate:

  • Velocity checks
  • Risk scoring
  • Device intelligence

These concerns should remain separate from provider integrations.

Common Mistakes

  • Direct Provider Integration Inside Business Services: Creates duplication and coupling.
  • Ignoring Idempotency: Leads to duplicate financial operations.
  • Synchronous Webhook Processing: Increases retrying and operational instability.
  • No Reconciliation Process: Creates hidden inconsistencies.
  • No Circuit Breakers: Allows provider failures to cascade into platform failures.

What to Build Next

This service gives you a solid production foundation. Here are three natural extensions worth considering:

  • Integration tests with Test Containers — spin up a real PostgreSQL container in your CI pipeline and test the full webhook flow end-to-end, including idempotency verification.
  • Rate limiting on checkout endpoints — prevent abuse by adding a token bucket or sliding window rate limiter (Spring’s built-in support or Bucket4j are good options) at the controller layer.
  • A third payment provider — because every provider implements the PaymentProvider interface, adding PayPal, Adyen, or any other gateway is a matter of writing a single new class. The factory, the controllers, and the database schema do not need to change.

Conclusion

Payment processing is fundamentally a reliability problem.

Successful payment systems must operate correctly despite retries, failures, provider outages, network issues, and duplicate events.

By separating payment responsibilities into a dedicated service, introducing provider abstraction, enforcing idempotency, processing webhooks asynchronously, and protecting external integrations with circuit breakers, the platform establishes a resilient foundation for financial transactions.

Most importantly, the architecture remains adaptable. New providers, payment methods, and regional requirements can be introduced without disrupting business workflows or rewriting core payment logic.

As transaction volume grows, these design decisions become increasingly valuable, transforming payment processing from a collection of integrations into a scalable platform of capability.

Source Code: https://github.com/inexture-solutions/springboot-microservices/tree/main/payment-service

FAQs

Why create a dedicated Payment Service?

Centralization improves consistency, maintainability, and provider management.

Why not integrate Stripe directly into business services?

Direct integration creates tight coupling and makes provider changes difficult.

Why is idempotency important?
It prevents duplicate financial operations caused by retries.

Why process webhooks asynchronously?

To improve throughput and reduce provider-side retries.

Can additional providers be added later?
Yes.

The provider abstraction layer is specifically designed to support future providers.

What happens if a provider goes down?
Circuit breakers and failure handling mechanisms prevent widespread platform impact.

Bharat Chaudhari is a Java Backend Engineer with 5+ years of experience building scalable microservices using Spring Boot. He specializes in distributed systems, event-driven architecture, Spring Security, Kafka, PostgreSQL, Redis, Docker, and Kubernetes, with a passion for building production-ready backend platforms.

Delivering Engineering Excellence Across Global Markets

  • India
  • USA
  • UAE
  • Europe
  • Singapore
  • Australia

Need Help Building Enterprise Spring Boot Microservices?

We specialize in designing secure, scalable, and event-driven microservices using Spring Boot, Kafka, PostgreSQL, Redis, OAuth2/JWT, Docker, Kubernetes, and cloud-native best practices for enterprise applications.