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