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 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:
- Implement the interface.
- Register provider.
- 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 asPAIDonce a delayed payment settles.checkout.session.async_payment_failed— transitions records toFAILEDso customers can be notified and retry.checkout.session.expired— cleans up stalePENDINGrecords automatically.charge.refunded,charge.refund.updated,refund.processed— asynchronously patchesRefundEntityrecords 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"
}'
{
"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
PaymentProviderinterface, 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.
