Payment Gateway Integration in iOS: A Practical Guide

Lessons from integrating HyperPay, STC Pay, Tabby, and Tamara into a delivery platform, and the architecture that made it manageable.

The Payment Landscape in the Middle East

Building a delivery app for the Saudi Arabian market means supporting a diverse range of payment methods. Unlike markets where Apple Pay and credit cards dominate, our users expect options like STC Pay (a mobile wallet tied to phone numbers), Tabby and Tamara (buy-now-pay-later services), alongside traditional card payments through HyperPay.

Each payment provider has its own SDK, its own flow, and its own set of quirks. The challenge isn't just integrating one gateway - it's building an architecture that can support multiple gateways without turning your checkout into spaghetti code.

Designing the Payment Architecture

The key insight was to create an abstraction layer that treats all payment methods uniformly from the checkout flow's perspective, while allowing each provider to handle its specific implementation details internally.

// Unified payment protocol - the checkout doesn't need to know
// which provider is being used
protocol PaymentGateway {
    var name: String { get }
    var supportedMethods: [PaymentMethod] { get }

    func initiatePayment(
        amount: Decimal,
        currency: Currency,
        orderId: String
    ) async throws -> PaymentSession

    func processPayment(
        session: PaymentSession,
        from viewController: UIViewController
    ) async throws -> PaymentResult

    func verifyPayment(transactionId: String) async throws -> PaymentStatus
}

// Unified result type
enum PaymentResult {
    case success(transactionId: String)
    case pending(checkoutId: String)
    case cancelled
    case failed(PaymentError)
}

enum PaymentError: Error {
    case insufficientFunds
    case cardDeclined
    case networkError
    case providerError(code: String, message: String)
    case userCancelled
}

The Payment Manager

A central PaymentManager acts as a coordinator. It selects the appropriate gateway based on the user's chosen payment method and handles the flow uniformly.

final class PaymentManager {
    private var gateways: [PaymentMethod: PaymentGateway] = [:]

    func register(_ gateway: PaymentGateway) {
        for method in gateway.supportedMethods {
            gateways[method] = gateway
        }
    }

    func processPayment(
        method: PaymentMethod,
        amount: Decimal,
        currency: Currency,
        orderId: String,
        from viewController: UIViewController
    ) async throws -> PaymentResult {
        guard let gateway = gateways[method] else {
            throw PaymentError.providerError(
                code: "UNSUPPORTED",
                message: "Payment method not available"
            )
        }

        let session = try await gateway.initiatePayment(
            amount: amount, currency: currency, orderId: orderId
        )
        return try await gateway.processPayment(
            session: session, from: viewController
        )
    }
}

Provider-Specific Implementations

HyperPay (Card Payments)

HyperPay handles traditional card payments and is our primary gateway. Their SDK presents a card input form and handles 3D Secure verification. The tricky part is their checkout flow requires a WebView-based redirect for 3DS, which means handling the callback URL correctly.

final class HyperPayGateway: PaymentGateway {
    let name = "HyperPay"
    let supportedMethods: [PaymentMethod] = [.visa, .mastercard, .mada]

    func processPayment(
        session: PaymentSession,
        from viewController: UIViewController
    ) async throws -> PaymentResult {
        return try await withCheckedThrowingContinuation { continuation in
            let checkoutSettings = OPPCheckoutSettings()
            checkoutSettings.paymentBrands = ["VISA", "MASTER", "MADA"]
            checkoutSettings.shopperResultURL = "com.app.payments://result"

            let checkout = OPPCheckoutProvider(
                paymentProvider: provider,
                checkoutID: session.checkoutId,
                settings: checkoutSettings
            )

            checkout?.presentCheckout(
                forSubmittingTransactionCompletionHandler: { transaction, error in
                    if let error {
                        continuation.resume(throwing: error)
                    } else if let transaction {
                        continuation.resume(returning: .success(
                            transactionId: transaction.resourcePath ?? ""
                        ))
                    }
                },
                cancelHandler: {
                    continuation.resume(returning: .cancelled)
                }
            )
        }
    }
}

STC Pay

STC Pay uses a phone number-based OTP flow. The user enters their phone number, receives an OTP, and confirms the payment. This flow needs careful UX consideration since it involves navigating between your app and potentially the STC Pay app.

Buy-Now-Pay-Later (Tabby & Tamara)

BNPL providers like Tabby and Tamara have become extremely popular in the region. Their integration is typically WebView-based - you redirect the user to their checkout page, they approve the installment plan, and you receive a callback.

The key challenge is handling the redirect flow reliably. Users might background the app, the WebView might lose state, or the network might drop mid-transaction. Robust state management is essential.

Error Handling and Recovery

Payment failures are inevitable. Network issues, insufficient funds, expired cards - the question is how gracefully you handle them. We built a structured error handling system that provides clear, actionable messages to users.

extension PaymentError {
    var userMessage: String {
        switch self {
        case .insufficientFunds:
            return "Insufficient funds. Please try a different payment method."
        case .cardDeclined:
            return "Your card was declined. Please check your card details or contact your bank."
        case .networkError:
            return "Connection issue. Please check your internet and try again."
        case .providerError(_, let message):
            return message
        case .userCancelled:
            return "Payment was cancelled."
        }
    }

    var isRetryable: Bool {
        switch self {
        case .networkError: return true
        case .providerError: return true
        default: return false
        }
    }
}

Security Considerations

Handling payments means handling sensitive data. Here are the non-negotiable security practices we follow:

  • Never store card details locally. Always use the provider's tokenization. The raw card number should never touch your servers or device storage.
  • Certificate pinning for all payment-related API calls to prevent man-in-the-middle attacks.
  • Jailbreak detection before initiating any payment flow. Compromised devices pose a security risk.
  • Server-side verification for every transaction. Never trust the client-side callback alone - always verify the payment status with the provider's server.
  • PCI DSS compliance - ensure your architecture keeps you out of PCI scope by using the provider's hosted UI for card input.

Testing Payments

Every provider offers a sandbox environment, but the real challenge is simulating edge cases: timeouts, partial payments, 3DS failures, and network drops mid-transaction. We built a mock payment gateway that conforms to our PaymentGateway protocol and can simulate any scenario.

The best payment integration is one the user barely notices. It should feel effortless, but behind the scenes, it handles every edge case gracefully.

Open Source: SwiftPaymentKit

I've extracted the core patterns from this architecture into an open-source library called SwiftPaymentKit. It provides the PaymentGateway protocol, an actor-based PaymentManager with automatic routing and retry logic, and support for all the payment methods discussed here. Add it via SPM and build your own gateway implementations on top of a battle-tested foundation.

Key Takeaways

  1. Abstract early. Build a unified payment protocol before integrating your first gateway. Adding subsequent providers becomes much easier.
  2. Handle every edge case. Users will background the app, lose network, and do unexpected things during payment. Plan for all of it.
  3. Never trust the client. Always verify payment status server-side before confirming an order.
  4. Provide clear error messages. When a payment fails, the user needs to know why and what they can do about it.
  5. Test with real devices. Payment SDKs often behave differently in simulators. Always test the complete flow on physical devices.
Back to all posts