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_orpk_live_) — safe to include in your iOS app. It can only create tokens and confirm payments. - Secret key (starts with
sk_test_orsk_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.gitSet 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 formsStripeApplePay— 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:
- Your iOS app tells your server what the user wants to buy (item ID, quantity, etc.)
- Your server creates a
PaymentIntentwith the amount, currency, and metadata - Your server returns the
client_secretto the iOS app - 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:
| Feature | Stripe | In-App Purchase (StoreKit 2) | RevenueCat |
|---|---|---|---|
| Best for | Physical goods, services, tips | Digital goods, subscriptions | Digital subscriptions (wrapper) |
| Apple's commission | 0% | 15-30% | 15-30% (Apple) + RevenueCat fee |
| Processing fee | 2.9% + $0.30 | Included in Apple's cut | Included in Apple's cut |
| Server required | Yes (for PaymentIntent) | No | No (RevenueCat is the server) |
| Apple Pay support | Yes (via Stripe SDK) | Native | Via StoreKit |
| Subscription management | Stripe Billing (server-side) | App Store manages | Dashboard + SDK |
| Refund handling | Full API control | Apple handles | Webhook notifications |
| Platform restrictions | None (iOS, Android, Web) | Apple only | iOS, Android, Web |
| Setup complexity | Medium (needs backend) | Low (native SDK) | Low (SDK + dashboard) |
| Payout speed | 2-7 business days | 30-45 days | 30-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
- Go to your Apple Developer account and create a Merchant ID (e.g.,
merchant.com.yourapp) - In the Stripe Dashboard, go to Settings → Payment Methods → Apple Pay. Follow the instructions to upload the Apple Pay certificate signing request.
- 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 automaticallyStep 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.