How I Built a Scalable Payment Gateway SDK in Flutter
Payments look simple on the surface.

How I Built a Scalable Payment Gateway SDK in Flutter
Payments look simple on the surface.
A button. A checkout screen. A success callback.
But once you start building real payment systems, things get complicated very quickly.
You deal with:
- multiple payment gateways
- retries
- transaction verification
- network failures
- platform-specific SDK behavior
- app lifecycle interruptions
- analytics
- merchant customization
- state consistency
After working on a Flutter payment SDK architecture supporting multiple providers like Razorpay and PayU, I realized payment engineering is less about UI and more about designing reliable systems.
This article covers:
- SDK architecture
- abstract payment layers
- gateway adapters
- transaction state management
- retry systems
- event handling
- dependency injection
- lessons learned building production-grade payment flows
The Problem With Direct Gateway Integrations
Most Flutter apps begin like this:
Razorpay razorpay = Razorpay();
razorpay.on(
Razorpay.EVENT_PAYMENT_SUCCESS,
handleSuccess,
);
razorpay.open(options);
This works fine initially.
But problems start appearing when:
- another payment gateway gets added
- merchants require custom flows
- retries need implementation
- analytics must be standardized
- payment verification changes
- platform-specific bugs appear
Now the application becomes tightly coupled to one provider.
The business logic depends directly on the payment SDK.
That architecture does not scale.
What I Wanted Instead
I wanted the consuming app to look like this:
final paymentManager = PaymentManager(
gateway: RazorpayGateway(),
);
await paymentManager.initialize();
await paymentManager.makePayment(
paymentRequest,
);
And later:
gateway: PayUGateway()
without changing the business logic.
The architecture needed:
- plug-and-play gateways
- unified response models
- event streams
- retry handling
- transaction lifecycle tracking
- clean extensibility
The Architecture
The final architecture looked something like this:
Flutter App
↓
Payment Manager
↓
Abstract Gateway Layer
↓
Razorpay / PayU Adapters
↓
Native SDKs
↓
Backend Verification
The key idea was abstraction.
The app should never directly communicate with provider SDKs.
Only the adapter layer should know implementation details.
Folder Structure
The SDK structure evolved into this:
lib/
├── core/
│ ├── models/
│ ├── enums/
│ ├── exceptions/
│ └── utils/
│
├── gateways/
│ ├── razorpay/
│ └── payu/
│
├── manager/
│ └── payment_manager.dart
│
├── widgets/
│ └── payment_loader.dart
│
└── payment_sdk.dart
This separation helped a lot later.
core/contains shared abstractionsgateways/contains provider-specific logicmanager/orchestrates payment flowwidgets/contains reusable SDK UI components
Building the Abstract Payment Layer
The first important decision was creating a common gateway contract.
abstract class PaymentGateway {
Future<void> initialize();
Future<PaymentResult> startPayment(
PaymentRequest request,
);
Future<void> verifyPayment(
String transactionId,
);
Stream<PaymentEvent> observeEvents();
void dispose();
}
This abstraction became the foundation of the entire SDK.
Every gateway implementation follows this same contract.
Fixing Initialization Logic
One important issue I discovered while building the SDK was initialization handling.
Initially, I was calling:
await gateway.initialize();
inside every payment request.
That caused multiple problems:
- duplicate event listeners
- memory leaks
- repeated SDK setup
- duplicate callbacks
- inconsistent payment events
This becomes especially dangerous in payment systems because duplicate success callbacks can trigger duplicate order confirmations.
So I redesigned initialization to happen only once.
Production-Grade Gateway Initialization
The improved implementation looked like this:
class RazorpayGateway
implements PaymentGateway {
final Razorpay _razorpay = Razorpay();
bool _isInitialized = false;
final StreamController<PaymentEvent>
_eventController =
StreamController.broadcast();
@override
Future<void> initialize() async {
if (_isInitialized) return;
_razorpay.on(
Razorpay.EVENT_PAYMENT_SUCCESS,
_handlePaymentSuccess,
);
_razorpay.on(
Razorpay.EVENT_PAYMENT_ERROR,
_handlePaymentError,
);
_razorpay.on(
Razorpay.EVENT_EXTERNAL_WALLET,
_handleExternalWallet,
);
_isInitialized = true;
}
@override
Future<PaymentResult> startPayment(
PaymentRequest request,
) async {
final options = {
'amount': request.amount * 100,
'order_id': request.orderId,
'name': 'Demo Merchant',
'timeout': 300,
};
try {
_razorpay.open(options);
return PaymentResult.pending();
} catch (e) {
return PaymentResult.failure(
error: e.toString(),
);
}
}
void _handlePaymentSuccess(
PaymentSuccessResponse response,
) {
_eventController.add(
PaymentEvent(
status: PaymentStatus.success,
message: response.paymentId,
),
);
}
void _handlePaymentError(
PaymentFailureResponse response,
) {
_eventController.add(
PaymentEvent(
status: PaymentStatus.failed,
message: response.message,
),
);
}
void _handleExternalWallet(
ExternalWalletResponse response,
) {
_eventController.add(
PaymentEvent(
status: PaymentStatus.pending,
message: response.walletName,
),
);
}
@override
Stream<PaymentEvent> observeEvents() {
return _eventController.stream;
}
@override
void dispose() {
_razorpay.clear();
_eventController.close();
_isInitialized = false;
}
}
This structure is much closer to how production payment SDKs are implemented.
Building the Payment Manager
Once initialization moved outside payment execution, the manager layer became much cleaner.
class PaymentManager {
final PaymentGateway gateway;
PaymentManager({
required this.gateway,
});
Future<void> initialize() async {
await gateway.initialize();
}
Future<PaymentResult> makePayment(
PaymentRequest request,
) async {
try {
return await gateway.startPayment(
request,
);
} catch (e) {
return PaymentResult.failure(
error: e.toString(),
);
}
}
void dispose() {
gateway.dispose();
}
}
This orchestration layer became extremely useful later for:
- retries
- analytics
- transaction reconciliation
- logging
- gateway switching
Standardizing Request Models
Every gateway had different request formats.
To avoid leaking provider-specific data into the app, I created unified models.
class PaymentRequest {
final double amount;
final String orderId;
final String customerId;
PaymentRequest({
required this.amount,
required this.orderId,
required this.customerId,
});
}
The consuming application only understands PaymentRequest.
It never cares whether the provider is Razorpay or PayU.
Standardizing Responses
Different gateways return completely different response payloads.
A common response model simplified everything.
class PaymentResult {
final bool success;
final String? transactionId;
final String? errorMessage;
PaymentResult({
required this.success,
this.transactionId,
this.errorMessage,
});
factory PaymentResult.success({
required String transactionId,
}) {
return PaymentResult(
success: true,
transactionId: transactionId,
);
}
factory PaymentResult.failure({
required String error,
}) {
return PaymentResult(
success: false,
errorMessage: error,
);
}
factory PaymentResult.pending() {
return PaymentResult(
success: false,
);
}
}
This abstraction significantly reduced business logic complexity.
Modeling Transactions as State Machines
Initially, I handled transactions using random boolean flags.
That quickly became difficult to maintain.
I eventually modeled payments as state machines.
enum TransactionState {
created,
initiated,
processing,
success,
failed,
cancelled,
retrying,
}
Then:
class Transaction {
final String id;
TransactionState state;
Transaction({
required this.id,
required this.state,
});
}
This improved:
- predictability
- debugging
- analytics
- recovery flows
- support tooling
Payments Fail More Than You Think
One thing I underestimated:
Real payment systems fail constantly.
Failures happen because of:
- network drops
- bank timeouts
- app backgrounding
- SDK interruptions
- delayed confirmations
So retries became necessary.
Implementing Retry Logic
A simple retry system looked like this:
Future<PaymentResult> retryPayment(
PaymentRequest request,
) async {
int retryCount = 0;
while (retryCount < 3) {
try {
return await startPayment(
request,
);
} catch (e) {
retryCount++;
await Future.delayed(
Duration(seconds: 2),
);
}
}
return PaymentResult.failure(
error: "Retry limit exceeded",
);
}
Eventually this evolved into:
- exponential backoff
- persisted retries
- idempotency handling
- backend reconciliation
The biggest lesson:
Never trust client state alone in payment systems.
Real SDK Usage
The final SDK usage became very clean.
final paymentSDK = PaymentManager(
gateway: RazorpayGateway(),
);
await paymentSDK.initialize();
final result =
await paymentSDK.makePayment(
PaymentRequest(
amount: 499,
orderId: "ORD_123",
customerId: "CUST_789",
),
);
if (result.success) {
print("Payment Successful");
} else {
print(result.errorMessage);
}
This simplicity was intentional.
Good SDKs reduce complexity for consumers.
Things That Broke in Production
Production payment systems behave very differently from demo apps.
Some real issues I encountered:
- duplicate success callbacks
- app killed during payment
- delayed bank confirmations
- backend verification timeout
- SDK version mismatches
- partial transaction failures
Most payment engineering effort eventually goes into edge cases.
Not UI.
Lessons Learned Building a Flutter SDK
1. SDK Consumers Hate Complex Setup
Developer experience matters a lot.
Even technically strong SDKs fail if setup feels painful.
Good SDKs should:
- minimize configuration
- provide good defaults
- expose predictable APIs
- have excellent documentation
2. Never Leak Provider-Specific Logic
Initially I exposed gateway-specific fields publicly.
That weakened the abstraction.
The app should never know provider internals.
3. Versioning Becomes Important Very Quickly
Breaking SDK consumers accidentally is painful.
Semantic versioning becomes essential.
4. Edge Cases Matter More Than Happy Paths
Payment systems are mostly edge cases.
The happy path is the easy part.
Final Thoughts
Building a payment SDK taught me that scalable Flutter engineering is mostly about system design.
The UI layer is usually the easiest part.
The difficult part is designing abstractions that survive:
- multiple gateways
- retries
- failures
- extensibility
- changing requirements
- real-world edge cases
And honestly, that’s what makes SDK engineering interesting.
메타데이터
- post_id
- ec2136fe41e1
- slug
- how-i-built-a-scalable-payment-gateway-sdk-in-flutter-ec2136fe41e1
- url
- https://medium.com/@anurag.kr.singh07/how-i-built-a-scalable-payment-gateway-sdk-in-flutter-ec2136fe41e1
- canonical_url
- https://medium.com/@anurag.kr.singh07/how-i-built-a-scalable-payment-gateway-sdk-in-flutter-ec2136fe41e1
- author_url
- https://medium.com/@anurag.kr.singh07
- status
- ok
- fetched_at
- 2026-06-09 15:37:30