iOS – Taking Payments with Stripe

September 3, 20268 min readUpdated 9/18/2026

Taking a card payment is the part of an app where a mistake is expensive in a way a layout bug is not. The good news is that the correct approach is also the least work — provided you resist the tempting alternative.

This lesson is Stripe's payment sheet, the two-step flow it forces, and how to keep the SDK from spreading into the rest of your app.

Do not build a card form

Stripe ships PaymentSheet: a native sheet rendered by the SDK in its own view, which collects the card and confirms the payment. The alternative is a card field you style yourself, and it is the wrong call for one reason that settles it.

With PaymentSheet the card number never touches your code. Your binary stays out of PCI scope, and you get Apple Pay, saved cards, 3D Secure and every payment method Stripe adds later for free. A hand-rolled field handles none of that and puts your code one careless refactor away from touching a card number.

Order first, then pay

Checkout is two steps, and it has to be:

    enum Step: Equatable {
        case collectingDetails
        case awaitingPayment(OrderCreateResponse)

Step one creates the order server-side. The server prices the cart from the database, saves it as PENDING_PAYMENT, opens a Stripe PaymentIntent and returns its client secret. Step two hands that secret to the sheet.

The order must exist first, because the PaymentIntent is what the sheet confirms. Modelling the two steps as an enum rather than a createdOrder != nil check matters: they are genuinely different screens with different actions, and the boolean version admits a third state where an order exists and the form is still editable.

What is not in the request

            items: items.map { item in
                OrderCreateRequest.Item(
                    productId: item.productID,
                    size: item.size,
                    crustId: item.crustID,
                    toppingIds: item.toppings.map(\.id),
                    quantity: item.quantity
                )
            }

Identifiers and quantities. No prices — not a subtotal, not a line total, not a grand total. The server decides what the cart costs, and a patched app sending total: 0.01 changes nothing because there is nowhere to put it.

This is the security boundary of the whole feature, and it is worth stating plainly: the device is never the authority on money. Everything the app computes is a preview shown before the customer commits.

Quarantine the SDK behind a protocol

enum PaymentOutcome: Equatable {
    case succeeded
    case cancelled
    case failed(message: String)
}

cancelled is its own case, not an error, and that distinction is the reason this enum exists. Stripe reports a dismissed sheet as a failure — so the naive mapping shows "Your payment failed" to somebody who simply changed their mind. It is easy to ship and hard to notice, because it only happens to customers who did not complete the purchase and therefore never complain.

@MainActor
protocol PaymentGateway {
    /// Opens the payment sheet and returns once the customer is done with it.
    func pay(_ request: PaymentRequest) async -> PaymentOutcome
    /// Collects a card against a SetupIntent WITHOUT charging it, for the profile screen.
    func saveCard(setupIntentClientSecret: String) async -> CardSetupOutcome
    /// False when no publishable key is configured — the UI then explains, rather than failing at
    /// the moment of tapping "Pay".
    var isReady: Bool { get }
}

Three things fall out of the abstraction, and all three are worth more than the indirection costs.

Checkout is testable. The view model's tests inject a gateway that returns .succeeded or .cancelled on demand. Driving the real sheet from a test is not merely hard — it is a system UI the test cannot touch.

Previews work. PaymentSheet needs a UIViewController to present from, which a preview does not have.

Stripe lives in one file. Nothing outside the payment folder imports the SDK, so replacing it is a single-file change rather than an archaeology exercise.

@MainActor
final class StubPaymentGateway: PaymentGateway {
    var isReady: Bool
    var nextPaymentOutcome: PaymentOutcome
    var nextCardSetupOutcome: CardSetupOutcome

    private(set) var payCallCount = 0
    private(set) var lastRequest: PaymentRequest?

Not a no-op — it returns a configured outcome, so a test can exercise the cancelled path and the failed path as easily as the happy one. isReady defaults to false, so a misconfigured build shows the explanatory copy rather than pretending it can take money.

Bridging a completion handler into async

Stripe's API is completion-based. withCheckedContinuation brings it into async:

        let result = await withCheckedContinuation { continuation in
            sheet.present(from: presenter) { result in
                continuation.resume(returning: result)
            }
        }

        switch result {
        case .completed:
            return .succeeded
        case .canceled:
            return .cancelled
        case let .failed(error):
            return .failed(message: error.localizedDescription)
        }

"Checked" is the variant that traps if the continuation is resumed twice or never. Both are leaks this code could otherwise introduce silently — a completion handler that is dropped hangs the caller forever, with no error and no crash, which is among the harder bugs to diagnose from a report.

Finding a view controller from SwiftUI

        guard var controller = scene?.keyWindow?.rootViewController else { return nil }
        while let presented = controller.presentedViewController {
            controller = presented
        }
        return controller

SwiftUI has no view controllers and PaymentSheet needs one. Walking the window hierarchy is the standard bridge, and the while loop is the part that is usually missing — presenting from the root while a sheet is already up throws "attempt to present on a view controller whose view is not in the window hierarchy".

The return URL

        configuration.returnURL = returnURL

This is what brings the customer back after a 3D Secure redirect into their bank's page. It has to match a URL scheme declared in Info.plist:

	<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleURLName</key>
			<string>com.lovemesomecoding.pizza.ios</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>pizzaios</string>
			</array>
		</dict>
	</array>

Get it wrong and the app is simply never reopened, leaving a paid order the customer never sees confirmed — a failure that only appears for cards that require authentication, which is to say not on your test card.

The device does not decide that a payment succeeded

Stripe accepting the card means Stripe accepted the card. Your order is still PENDING_PAYMENT until your backend hears about it, through a webhook or by asking.

    func pay() async -> UUIDString? {
        guard case let .awaitingPayment(created) = step,
              let clientSecret = created.clientSecret else { return nil }

On success the view model returns the order id, the cart is cleared, and the app navigates to a confirmation screen that polls your own server for the status. There is no "mark this order paid" endpoint, because anyone can call your API.

Polling rather than relying solely on the webhook is deliberate: a webhook does not reach a laptop unless stripe listen is running, and even in production it can arrive seconds later than the customer does.

Returning an optional rather than throwing

Three outcomes — paid, cancelled, failed — collapse into one decision for the caller. Cancellation in particular must not be an error: the order is still reserved and the customer can try again. A thrown error would force every call site to write a catch that distinguishes the two, which is exactly the mistake the enum was introduced to prevent.

Saving a card without charging it

The profile screen collects a card for later. That is a SetupIntent, not a PaymentIntent — same sheet, no charge. The server opens it, the sheet collects the details, and only the opaque pm_… token comes back to be saved.

Never the number, never the CVC, never the cardholder name. If a cardNumber property appears anywhere in an app doing this correctly, something has gone badly wrong.

Configuring the sheet

    private func baseConfiguration() -> PaymentSheet.Configuration {
        var configuration = PaymentSheet.Configuration()
        configuration.merchantDisplayName = merchantDisplayName
        configuration.returnURL = returnURL
        configuration.allowsDelayedPaymentMethods = false
        return configuration
    }

merchantDisplayName is what the customer sees at the top of the sheet and in the Apple Pay confirmation, so it should be the name they recognise from the App Store rather than your company's legal entity.

allowsDelayedPaymentMethods is the one to think about. Enabling it offers methods that confirm hours or days later — bank debits, some local schemes — which is right for a subscription and wrong for a pizza. If you enable it, your fulfilment has to wait for the webhook rather than for the sheet, and that is a backend change more than an app one.

Building with the SDK, and building without it

#if canImport(StripePaymentSheet)
import StripePaymentSheet
import StripePayments

The demo app guards the whole Stripe implementation, with a fallback that refuses loudly: isReady is false, every call fails with an explanation, and the checkout screen renders the "payment is not configured" branch it already has for a missing key.

This is not a way to make payment optional in production. It is what keeps the repository buildable for somebody who clones it without network access, or who removes the package to read the rest of the app — and on the machine this track was written on it earned its keep for a third reason, because that toolchain could not compile the SDK's asset catalogues at all.

Keys

The publishable key is public by design: it identifies the account and can only create intents, never charge one. It ships in the binary via a build setting. The secret key lives on your server and nowhere else — if one ever lands in a repository, the only correct response is to roll it in the dashboard rather than to delete the commit.

Testing this

The view model's tests cover all three outcomes without a network. The one worth writing first is the cancellation case, because it is the one that ships broken:

        let orderID = await model.pay()

        XCTAssertNil(orderID)
        XCTAssertNil(model.errorMessage, "A cancelled payment must not show a red message.")

Beyond that, Stripe's test cards cover the interesting paths — 4242… succeeds, 4000 0025 0000 3155 requires authentication, 4000 0000 0000 9995 declines for insufficient funds. The second is the one worth exercising on a real device, because it is the only way to find out whether your return URL is right.

What is still missing

Worth stating rather than leaving as an apparent oversight: the demo app can save cards and can take payments, and checkout does not yet offer the saved ones. It always collects a fresh card.

Wiring the two together is a server-side change more than an app one — it means creating the PaymentIntent with a customer and a stored payment method attached, and the app's part is a picker above the pay button. All three of the web frontends in the same project share the gap, which is usually a sign that it is the next thing to do rather than something everybody forgot.

Next

The payment sheet is accessible because Stripe made it so. Everything around it is your responsibility, and a drawn control carries no meaning at all. The next lesson is accessibility.