← Back to list

How I Built a Scalable Payment Gateway SDK in Flutter

Payments look simple on the surface.

Anurag Kr Singh · 2026-05-26 11:53 · 0 claps · 5.1 min read
#mobile-payments #flutter #scalable-systems #system-design-concepts
Open on Medium ↗
Wiki topics: FIN · Fintech & Banking 📱 · Mobile Development

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 abstractions
  • gateways/ contains provider-specific logic
  • manager/ orchestrates payment flow
  • widgets/ 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