NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Stripe Payment Integration: Accept Payments in Your iOS App

A complete guide to integrating Stripe with SwiftUI — from server-side PaymentIntents to PaymentSheet, custom payment forms, Apple Pay, and handling edge cases in production.

Ahmed GaganAhmed Gagan
14 min read

TL;DR

Apple requires In-App Purchases for digital goods, but physical goods, services, and tips can use Stripe. Install the Stripe iOS SDK via SPM, create a PaymentIntent on your server, pass the client secret to your SwiftUI app, and present Stripe's PaymentSheet for a drop-in checkout experience. Add Apple Pay through Stripe for a one-tap flow. This guide covers every step with real code.

If your iOS app sells physical products, services, consultations, food delivery, ride-sharing, or accepts tips, you cannot use Apple's In-App Purchase system. Apple's own guidelines say so. Instead, you need a payment processor like Stripe. This guide walks you through the full SwiftUI Stripe integration — from installing the SDK to presenting a payment sheet, building custom forms, wiring up Apple Pay, and handling every production edge case. Every code snippet compiles. Every pattern is tested in real shipping apps.

When Should You Use Stripe Instead of In-App Purchases?

This is the first question every iOS developer asks, and getting it wrong can get your app rejected — or worse, get your developer account flagged. Here are the rules, straight from Apple's App Store Review Guidelines (Section 3.1):

  • Digital goods and content = In-App Purchase required. This includes subscriptions to digital content, premium app features, virtual currency, loot boxes, AI credits, unlockable levels, and any content consumed within the app. Apple takes a 15-30% commission, and there is no way around it for these categories.
  • Physical goods and real-world services = External payment allowed. If the user is paying for something delivered outside the app — a physical product shipped to their door, a ride, a meal, a haircut, tutoring, consulting — you can (and should) use Stripe, Square, or any other payment processor. Apple takes no commission on these transactions.
  • Reader apps (Guideline 3.1.3(a)). Apps that provide access to previously purchased content or subscriptions (think Netflix, Spotify, Kindle) can direct users to their website for purchases. They cannot offer in-app purchases for the same content, but they also do not need to use IAP at all.
  • Person-to-person payments and tips. Apps like Venmo, tipping in a livestream, or sending money to another user can use external payment systems. The key is that the payment goes to another person, not to the developer for app functionality.
  • Enterprise and B2B apps. Apps distributed through Apple Business Manager for enterprise use can use external payment for business services, especially when the purchase happens outside the app (e.g., SaaS subscriptions managed through a web dashboard).

The simple test: If the thing the user pays for exists entirely inside your app, use In-App Purchase. If it exists in the physical world or is consumed outside your app, Stripe is the right choice. When in doubt, check our StoreKit 2 vs RevenueCat breakdown for digital goods, and come back here for everything else.

How Do You Set Up Stripe in an iOS Project?

The Stripe iOS SDK is a mature, well-documented library that handles PCI compliance, card validation, 3D Secure, and Apple Pay for you. Here is the complete setup process.

Step 1: Create a Stripe Account and Get Your Keys

Sign up at dashboard.stripe.com. In the Developers section, you will find two key pairs:

  • Publishable key (starts with pk_test_ or pk_live_) — safe to include in your iOS app. It can only create tokens and confirm payments.
  • Secret key (starts with sk_test_ or sk_live_) — never put this in your app. It lives on your server only. Anyone with your secret key can issue refunds, create charges, and access customer data.

Start with the test keys. Stripe's test mode lets you simulate every payment scenario — successful charges, declined cards, 3D Secure challenges, and network errors — without moving real money.

Step 2: Add the Stripe iOS SDK via SPM

In Xcode, go to File → Add Package Dependencies and paste:

https://github.com/stripe/stripe-ios.git

Set the dependency rule to Up to Next Major Version with a minimum of 24.0.0. Add these libraries to your app target:

  • StripePaymentSheet — the drop-in checkout UI (recommended for most apps)
  • Stripe — the full SDK if you need custom payment forms
  • StripeApplePay — Apple Pay integration

If you prefer Package.swift:

// Package.swift
dependencies: [
    .package(
        url: "https://github.com/stripe/stripe-ios.git",
        from: "24.0.0"
    )
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "StripePaymentSheet", package: "stripe-ios"),
            .product(name: "StripeApplePay", package: "stripe-ios"),
        ]
    )
]

Step 3: Configure Stripe on App Launch

Set your publishable key as early as possible — the App struct's init() is the right place:

// App.swift
import SwiftUI
import StripePaymentSheet

@main
struct MyApp: App {
    init() {
        StripeAPI.defaultPublishableKey = "pk_test_YOUR_PUBLISHABLE_KEY"
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Important: Do not hardcode the key in production. Use a configuration file, build settings, or fetch it from your server on launch. The publishable key is safe for client-side use but you still want the flexibility to rotate it without shipping an update.

How Do You Create a Payment Intent from Your Server?

This is the core of Stripe's architecture: payments are always initiated server-side. Your iOS app never sees the secret key or creates charges directly. The flow is:

  1. Your iOS app tells your server what the user wants to buy (item ID, quantity, etc.)
  2. Your server creates a PaymentIntent with the amount, currency, and metadata
  3. Your server returns the client_secret to the iOS app
  4. The iOS app uses the client secret to confirm the payment through Stripe's SDK

This separation is critical for security and PCI compliance. Here is the server-side code in Node.js and Python.

Node.js (Express)

// server.js
const express = require('express');
const stripe = require('stripe')('sk_test_YOUR_SECRET_KEY');
const app = express();

app.use(express.json());

app.post('/create-payment-intent', async (req, res) => {
  try {
    const { amount, currency, metadata } = req.body;

    const paymentIntent = await stripe.paymentIntents.create({
      amount,            // Amount in cents (e.g., 1999 = $19.99)
      currency: currency || 'usd',
      metadata: metadata || {},
      automatic_payment_methods: { enabled: true },
    });

    res.json({
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id,
    });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Optional: Stripe webhook to confirm payment completion
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = 'whsec_YOUR_WEBHOOK_SECRET';

  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);

    if (event.type === 'payment_intent.succeeded') {
      const paymentIntent = event.data.object;
      console.log('Payment succeeded:', paymentIntent.id);
      // Fulfill the order: update database, send confirmation, etc.
    }

    res.json({ received: true });
  } catch (err) {
    res.status(400).send('Webhook Error: ' + err.message);
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Python (Flask)

# server.py
import stripe
from flask import Flask, request, jsonify

stripe.api_key = 'sk_test_YOUR_SECRET_KEY'
app = Flask(__name__)

@app.route('/create-payment-intent', methods=['POST'])
def create_payment_intent():
    try:
        data = request.get_json()
        intent = stripe.PaymentIntent.create(
            amount=data['amount'],        # Amount in cents
            currency=data.get('currency', 'usd'),
            metadata=data.get('metadata', {}),
            automatic_payment_methods={'enabled': True},
        )
        return jsonify({
            'clientSecret': intent.client_secret,
            'paymentIntentId': intent.id,
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 400

if __name__ == '__main__':
    app.run(port=3000)

Both endpoints do the same thing: accept an amount, create a PaymentIntent, and return the client secret. The automatic_payment_methods flag lets Stripe automatically enable the best payment methods for the customer's region — cards, Apple Pay, Google Pay, bank transfers, and more. In production, add authentication middleware to verify the user making the request and validate the amount against your product catalog to prevent price manipulation.

How Do You Build a Payment Sheet in SwiftUI?

Stripe's PaymentSheet is the fastest path to accepting payments. It is a pre-built, fully localized UI that handles card input, validation, error messages, and 3D Secure challenges. You present it with a single method call. Here is the complete SwiftUI integration:

// PaymentViewModel.swift
import Foundation
import StripePaymentSheet

@MainActor
@Observable
final class PaymentViewModel {
    var paymentSheet: PaymentSheet?
    var paymentResult: PaymentSheetResult?
    var isLoading = false
    var errorMessage: String?

    private let baseURL: String

    init(baseURL: String = "https://your-server.com") {
        self.baseURL = baseURL
    }

    func preparePayment(amountInCents: Int, currency: String = "usd") async {
        isLoading = true
        errorMessage = nil

        do {
            // 1. Request a PaymentIntent from your server
            let url = URL(string: "\(baseURL)/create-payment-intent")!
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONEncoder().encode([
                "amount": amountInCents,
            ])

            let (data, _) = try await URLSession.shared.data(for: request)
            let response = try JSONDecoder().decode(PaymentIntentResponse.self, from: data)

            // 2. Configure the PaymentSheet
            var config = PaymentSheet.Configuration()
            config.merchantDisplayName = "My Store"
            config.allowsDelayedPaymentMethods = true
            config.applePay = .init(
                merchantId: "merchant.com.yourapp",
                merchantCountryCode: "US"
            )

            // 3. Create the PaymentSheet with the client secret
            paymentSheet = PaymentSheet(
                paymentIntentClientSecret: response.clientSecret,
                configuration: config
            )
        } catch {
            errorMessage = "Failed to load payment: \(error.localizedDescription)"
        }

        isLoading = false
    }

    func handlePaymentResult(_ result: PaymentSheetResult) {
        paymentResult = result
        switch result {
        case .completed:
            // Payment succeeded — fulfill the order
            errorMessage = nil
        case .canceled:
            errorMessage = nil
        case .failed(let error):
            errorMessage = error.localizedDescription
        }
    }
}

struct PaymentIntentResponse: Codable {
    let clientSecret: String
    let paymentIntentId: String
}

Now the SwiftUI view that presents the sheet:

// CheckoutView.swift
import SwiftUI
import StripePaymentSheet

struct CheckoutView: View {
    @State private var viewModel = PaymentViewModel()

    let product: Product // Your product model

    var body: some View {
        VStack(spacing: 24) {
            // Product info
            VStack(spacing: 8) {
                Text(product.name)
                    .font(.title2.bold())
                Text(product.formattedPrice)
                    .font(.title.bold())
                    .foregroundStyle(.accent)
            }

            // Payment button
            if let paymentSheet = viewModel.paymentSheet {
                PaymentSheet.PaymentButton(
                    paymentSheet: paymentSheet,
                    onCompletion: viewModel.handlePaymentResult
                ) {
                    Text("Pay \(product.formattedPrice)")
                        .font(.headline)
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.accentColor)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            } else if viewModel.isLoading {
                ProgressView("Preparing checkout...")
            }

            // Error message
            if let error = viewModel.errorMessage {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.caption)
                    .multilineTextAlignment(.center)
            }

            // Success state
            if case .completed = viewModel.paymentResult {
                Label("Payment successful!", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
                    .font(.headline)
            }
        }
        .padding()
        .task {
            await viewModel.preparePayment(amountInCents: product.priceInCents)
        }
    }
}

That is the entire client-side integration. When the user taps the payment button, Stripe presents a native-feeling bottom sheet with card input, saved payment methods, and Apple Pay if configured. The sheet handles validation, error display, and 3D Secure challenges automatically. You just react to the result.

How Do You Build a Custom Payment Form?

If you need more control over the checkout UI — maybe your designer has a specific vision, or you want to embed payment fields directly in your existing flow — Stripe provides individual UI components you can arrange however you want.

Custom Card Form

Stripe's STPPaymentCardTextField is a UIKit component, so you need a UIViewRepresentablewrapper for SwiftUI:

// CardFieldView.swift
import SwiftUI
import Stripe

struct CardFieldView: UIViewRepresentable {
    @Binding var isValid: Bool

    func makeUIView(context: Context) -> STPPaymentCardTextField {
        let field = STPPaymentCardTextField()
        field.delegate = context.coordinator
        field.postalCodeEntryEnabled = true
        return field
    }

    func updateUIView(_ uiView: STPPaymentCardTextField, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(isValid: $isValid)
    }

    class Coordinator: NSObject, STPPaymentCardTextFieldDelegate {
        @Binding var isValid: Bool

        init(isValid: Binding<Bool>) {
            _isValid = isValid
        }

        func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField) {
            isValid = textField.isValid
        }
    }
}

// Usage in your checkout view:
struct CustomCheckoutView: View {
    @State private var cardIsValid = false
    @State private var isProcessing = false

    var body: some View {
        VStack(spacing: 20) {
            CardFieldView(isValid: $cardIsValid)
                .frame(height: 50)
                .padding(.horizontal)

            Button("Pay $19.99") {
                Task { await processPayment() }
            }
            .disabled(!cardIsValid || isProcessing)
        }
    }

    private func processPayment() async {
        isProcessing = true
        // Create PaymentIntent on server, then confirm with
        // STPAPIClient.shared.confirmPayment(...)
        isProcessing = false
    }
}

The custom card field gives you PCI compliance without handling raw card numbers. Stripe tokenizes the data on their servers — your app and your server never see the full card number. For most apps, I recommend starting with PaymentSheet and only switching to custom forms if you have a specific design requirement.

Stripe vs In-App Purchase vs RevenueCat: When to Use Each

This is the table I wish existed when I was figuring out payment options for my first iOS app. Each tool serves a different purpose, and picking the wrong one wastes weeks:

FeatureStripeIn-App Purchase (StoreKit 2)RevenueCat
Best forPhysical goods, services, tipsDigital goods, subscriptionsDigital subscriptions (wrapper)
Apple's commission0%15-30%15-30% (Apple) + RevenueCat fee
Processing fee2.9% + $0.30Included in Apple's cutIncluded in Apple's cut
Server requiredYes (for PaymentIntent)NoNo (RevenueCat is the server)
Apple Pay supportYes (via Stripe SDK)NativeVia StoreKit
Subscription managementStripe Billing (server-side)App Store managesDashboard + SDK
Refund handlingFull API controlApple handlesWebhook notifications
Platform restrictionsNone (iOS, Android, Web)Apple onlyiOS, Android, Web
Setup complexityMedium (needs backend)Low (native SDK)Low (SDK + dashboard)
Payout speed2-7 business days30-45 days30-45 days (via Apple)

The decision tree is simple: Selling digital content consumed inside your app? Use RevenueCat with StoreKit 2. Selling physical goods, real-world services, or accepting tips? Use Stripe. Doing both? You will need both — and that is perfectly fine. Many successful apps use RevenueCat for premium subscriptions and Stripe for marketplace transactions or physical product sales. See our monetization guide for a deeper breakdown of which model fits your app.

How Do You Handle Payment Errors and Edge Cases?

Payments are one of the most error-prone flows in any app. Users have expired cards, insufficient funds, flagged accounts, and flaky network connections. If your error handling is weak, you lose sales. Here is a production-grade error handler:

// PaymentErrorHandler.swift
import Foundation
import StripePaymentSheet

enum PaymentError: LocalizedError {
    case networkError
    case cardDeclined(String)
    case authenticationRequired
    case processingError
    case serverError(String)
    case unknown(String)

    var errorDescription: String? {
        switch self {
        case .networkError:
            return "Please check your internet connection and try again."
        case .cardDeclined(let reason):
            return "Your card was declined: \(reason)"
        case .authenticationRequired:
            return "Additional authentication is required. Please try again."
        case .processingError:
            return "There was an issue processing your payment. Please try again."
        case .serverError(let message):
            return "Server error: \(message)"
        case .unknown(let message):
            return message
        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .cardDeclined:
            return "Try a different card or contact your bank."
        case .networkError:
            return "Check your Wi-Fi or cellular connection."
        case .authenticationRequired:
            return "Your bank requires additional verification."
        default:
            return "If the problem persists, contact support."
        }
    }
}

func mapPaymentSheetResult(_ result: PaymentSheetResult) -> Result<Void, PaymentError> {
    switch result {
    case .completed:
        return .success(())
    case .canceled:
        return .success(()) // User chose to cancel — not an error
    case .failed(let error):
        let nsError = error as NSError
        if nsError.domain == "NSURLErrorDomain" {
            return .failure(.networkError)
        }
        // Stripe errors contain a decline code in userInfo
        if let stripeError = error as? StripeError,
           case .apiError(let apiError) = stripeError {
            let code = apiError.declineCode ?? apiError.code ?? "unknown"
            return .failure(.cardDeclined(declineMessage(for: code)))
        }
        return .failure(.unknown(error.localizedDescription))
    }
}

func declineMessage(for code: String) -> String {
    switch code {
    case "insufficient_funds":
        return "Insufficient funds. Please try a different card."
    case "lost_card", "stolen_card":
        return "This card has been reported lost or stolen."
    case "expired_card":
        return "Your card has expired. Please update your card details."
    case "incorrect_cvc":
        return "The CVC code is incorrect. Please check and try again."
    case "processing_error":
        return "A processing error occurred. Please try again."
    case "authentication_required":
        return "Your bank requires additional verification."
    default:
        return "Please try a different payment method."
    }
}

Idempotency

Network requests can fail after the payment is processed but before your app receives the confirmation. Without idempotency, the user might be charged twice. Stripe handles this with idempotency keys:

// On your server — Node.js example
app.post('/create-payment-intent', async (req, res) => {
  const { amount, currency, idempotencyKey } = req.body;

  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount,
      currency: currency || 'usd',
      automatic_payment_methods: { enabled: true },
    },
    {
      idempotencyKey: idempotencyKey, // Pass from the client
    }
  );

  res.json({ clientSecret: paymentIntent.client_secret });
});
// On your iOS app — generate the key before the request
import Foundation

func createIdempotencyKey(for productId: String, userId: String) -> String {
    return "\(userId)_\(productId)_\(Int(Date().timeIntervalSince1970))"
}

If the same idempotency key is sent twice, Stripe returns the original response instead of creating a duplicate charge. Always generate the key before the first request and reuse it for retries. This is a small detail that prevents serious production issues.

3D Secure (SCA)

In the EU and many other regions, Strong Customer Authentication (SCA) requires a second factor for online payments. Stripe's PaymentSheet handles 3D Secure automatically — it presents the bank's authentication challenge within the payment flow and returns the result. If you are using a custom payment form, you need to handle the requiresAction status and present the 3D Secure challenge yourself using STPPaymentHandler. For most apps, PaymentSheet is the safer choice because it handles SCA without any extra code.

How Do You Add Apple Pay with Stripe?

Apple Pay through Stripe gives your users one-tap checkout using Face ID or Touch ID. No card numbers to type, no forms to fill. Conversion rates for Apple Pay are significantly higher than manual card entry. Here is how to set it up end to end.

Step 1: Configure Your Merchant ID

  1. Go to your Apple Developer account and create a Merchant ID (e.g., merchant.com.yourapp)
  2. In the Stripe Dashboard, go to Settings → Payment Methods → Apple Pay. Follow the instructions to upload the Apple Pay certificate signing request.
  3. In Xcode, add the Apple Pay capability to your target and select your Merchant ID.

Step 2: Apple Pay with PaymentSheet (Easiest)

If you are already using PaymentSheet, Apple Pay is a single configuration line. Look at theconfig.applePay section in the PaymentViewModel above — that is all it takes:

var config = PaymentSheet.Configuration()
config.merchantDisplayName = "My Store"
config.applePay = .init(
    merchantId: "merchant.com.yourapp",
    merchantCountryCode: "US"
)

// Apple Pay now appears as a payment option in the sheet automatically

Step 3: Standalone Apple Pay Button

If you want a dedicated Apple Pay button outside the payment sheet — for example, on a product detail page for quick checkout — here is the SwiftUI implementation:

// ApplePayCheckoutView.swift
import SwiftUI
import PassKit
import StripeApplePay

struct ApplePayCheckoutView: View {
    @State private var isProcessing = false
    @State private var paymentStatus: PaymentStatus = .idle

    let amountInCents: Int
    let productName: String

    var body: some View {
        VStack(spacing: 20) {
            if StripeAPI.deviceSupportsApplePay() {
                PaymentButton(action: handleApplePay)
                    .frame(height: 50)
                    .padding(.horizontal)
                    .disabled(isProcessing)
            } else {
                Text("Apple Pay is not available on this device.")
                    .foregroundStyle(.secondary)
            }

            if case .success = paymentStatus {
                Label("Payment complete!", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
            }
        }
    }

    private func handleApplePay() {
        isProcessing = true

        let request = StripeAPI.paymentRequest(
            withMerchantIdentifier: "merchant.com.yourapp",
            country: "US",
            currency: "USD"
        )
        request.paymentSummaryItems = [
            PKPaymentSummaryItem(
                label: productName,
                amount: NSDecimalNumber(
                    value: Double(amountInCents) / 100.0
                )
            ),
            PKPaymentSummaryItem(
                label: "My Store",
                amount: NSDecimalNumber(
                    value: Double(amountInCents) / 100.0
                ),
                type: .final
            ),
        ]

        // Present Apple Pay sheet and handle result...
    }
}

enum PaymentStatus {
    case idle
    case processing
    case success
    case failed(String)
}

// Helper: Apple Pay button as a SwiftUI view
struct PaymentButton: UIViewRepresentable {
    let action: () -> Void

    func makeUIView(context: Context) -> PKPaymentButton {
        let button = PKPaymentButton(
            paymentButtonType: .buy,
            paymentButtonStyle: .automatic
        )
        button.addTarget(
            context.coordinator,
            action: #selector(Coordinator.didTap),
            for: .touchUpInside
        )
        return button
    }

    func updateUIView(_ uiView: PKPaymentButton, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(action: action)
    }

    class Coordinator: NSObject {
        let action: () -> Void
        init(action: @escaping () -> Void) { self.action = action }
        @objc func didTap() { action() }
    }
}

Pro tip: Always check StripeAPI.deviceSupportsApplePay() before showing the Apple Pay button. Simulator does not support Apple Pay, and some devices may not have it configured. Show a fallback card entry option when Apple Pay is unavailable. Also note that the last item in paymentSummaryItems is what appears as the total on the Apple Pay sheet — its label should be your business name.

Stop Rebuilding Payment Infrastructure from Scratch

Everything in this guide — the networking layer, payment view models, error handling patterns, Apple Pay integration — is infrastructure work. Important work, but work that looks the same across every app. If your app sells digital content or subscriptions, The Swift Kit ships with a complete RevenueCat integration, three paywall templates, entitlement checking, restore purchases, and a production-ready subscription management layer — all pre-wired into a clean MVVM architecture.

For Stripe payments, the patterns in this guide are exactly what you need. Pair them with The Swift Kit's existing Supabase backend for user authentication and database storage, and you have a complete commerce stack. Check out the full feature list or see the pricing plans to get started. Spend your time building what makes your app unique, not payment plumbing.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit