Resources / Field guide

Taking iOS payments outside Apple's IAP

Apple's in-app purchase takes a 15–30% cut. For many apps you can charge through Stripe instead: send the user to Safari for checkout, then deep-link them back into the app to unlock access. Here's the architecture, the App Store rules that govern it, and the Swift wiring to receive the return.

Why bother

Apple's commission on in-app purchases is 15–30%. On a subscription business that's the difference between healthy margins and thin ones. If your product is also sold on the web, charging through your existing Stripe setup keeps payments in one place, gives you full control of the billing experience, and keeps that commission. The mechanism is a clean round trip: the app hands off to Safari for the actual payment, and Safari hands back to the app when it's done.

Rules first — and they're a moving target. What's allowed depends on app category and country, and it changed materially in May 2025: after the Epic v. Apple ruling, the US storefront now permits external purchase links, buttons, and calls to action with no special entitlement and no Apple commission. Outside the US, the older anti-steering restrictions still apply in many regions. This is an engineering guide, not legal advice — always check the current App Store Review Guidelines (3.1.1 / 3.1.3) for the storefronts you ship to before relying on any of this.

The safe, globally-compatible UI rules

If you want one build that passes review everywhere — including regions with the stricter rules — design the in-app UI so it never sells anything; the website does the selling. The conservative line that keeps review smooth:

  • Don't show prices or plan names in the app, don't label a button "Subscribe / Buy / Purchase / Upgrade", and don't show a plan comparison in-app.
  • Do use neutral wording — "Get Full Access", "Manage Account", "Continue on the web" — open Safari to an external URL, and receive a deep link back.

App review specifically looks for in-app purchase flows. If the app shows no prices and no "buy" button, the purchase happens entirely on your site, which is exactly where you want it. (On the US storefront post-2025 you can be more direct, but the neutral approach is the safe default for a single global build.)

The flow

iOS app "Get Full Access" Safari your payment page Stripe Checkout card payment App unlocked deep link: myapp://payment-complete
Hand off to Safari for the payment; Stripe redirects to a deep link that reopens the app and confirms access.
  1. User taps "Get Full Access". The app opens Safari to your payment page.
  2. The website runs Stripe Checkout. The user pays on the web.
  3. On success, your site redirects to your app's custom URL scheme — e.g. myapp://payment-complete?token=….
  4. iOS reopens the app with that URL; the app confirms the payment with your backend and moves the user into the paid experience.

Step 1 — Register a URL scheme

If you already do password-reset or magic-link deep links you likely have one; reuse it. In Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.yourcompany.myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

Define one path per outcome so the app knows what just happened:

myapp://payment-complete?token={accountCreationToken}   # new user paid
myapp://subscription-updated                             # existing user upgraded
myapp://reset-password?token={token}                     # (likely already exists)

Step 2 — Handle the incoming deep link

SwiftUI's app lifecycle exposes onOpenURL:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    DeepLinkHandler.shared.handle(url)
                }
        }
    }
}

On UIKit, the same arrives in the scene delegate:

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    DeepLinkHandler.shared.handle(url)
}

Step 3 — Confirm, don't trust

Treat the deep link as a signal, not proof of payment — a URL is trivially forged. When payment-complete arrives, call your backend to verify the session/subscription (your server already knows the truth from Stripe, ideally confirmed by a Stripe webhook), and only then flip the user to paid:

func handle(_ url: URL) {
    guard url.scheme == "myapp" else { return }
    switch url.host {
    case "payment-complete":
        let token = url.queryItem("token")
        Task { await AccountService.confirmPayment(token: token) }   // verify server-side
    case "subscription-updated":
        Task { await AccountService.refreshEntitlements() }
    default:
        break
    }
}

Have the website show a clear "return to the app" affordance too — auto-redirects to a custom scheme can be blocked, so a tappable "Open MyApp" link is a reliable fallback.

When this fits

This pattern suits apps that already sell on the web and have a Stripe account — the app rides the billing system you maintain rather than duplicating it in StoreKit. If iOS is your only surface and you have no web presence, native in-app purchase may genuinely be less work. And wherever you ship, let the current App Store guidelines for that storefront decide how direct your in-app wording can be.

Building payments into an app?

Stripe, deep links, web-to-app round trips, subscription logic — done properly and tested end to end. I take this on directly. Tell me what you're building and let's talk.

Work with me