In-App Purchases

The classic way to sell a Mac app outside the App Store asks a lot of your customer. They leave your app for a browser, pay, wait for an email, find the license key, switch back, and paste it in. Every one of those steps is a place to lose the sale, and the ones who make it through still write to you when the email lands in spam.

AmoreCheckout removes the detour. The customer clicks buy, pays in a sheet inside your app, and the app unlocks itself.

It's part of AmoreKit and bridges the two libraries you already have: AmoreStore fetches your products, AmoreLicensing activates the key. When payment completes, the key is fetched from the server and activate(licenseKey:) runs for you.

For a complete working reference, see the Pomodoro example project, which integrates Sparkle updates, licensing, and in-app checkout end-to-end.

Before You Start

In-app purchases build on the licensing system, so you'll need a product and a connected Stripe account first. The licensing guide covers both.

Install

Add the AmoreKit package to your project:

.package(url: "https://github.com/AmoreComputer/AmoreKit", from: "0.6")

Add AmoreCheckout as a dependency to your target:

.product(name: "AmoreCheckout", package: "AmoreKit")

Checkout runs in an embedded web view, so sandboxed apps need the outgoing-connections entitlement:

<key>com.apple.security.network.client</key>
<true/>

Payment can always finish in the external browser instead, which Apple Pay requires. The app activates the license either way.

Create the Checkout

Create one AmoreCheckout where you create your AmoreLicensing instance, and pass it down:

let licensing = try AmoreLicensing(publicKey: "your-public-key")
let checkout = AmoreCheckout(licensing: licensing)

You own the object. One instance serves every product, and its lifetime is yours.

Present the Sheet

Fetch your products with AmoreStore, then bind the one being bought. The amoreCheckout(item:checkout:) modifier presents checkout for whatever the binding holds:

import AmoreCheckout
import SwiftUI
import struct AmoreStore.Product

struct PaywallView: View {
    let checkout: AmoreCheckout
    let products: [Product]
    @State private var buying: Product?

    var body: some View {
        VStack {
            ForEach(products) { product in
                Button("Buy \(product.name)") { buying = product }
            }
        }
        .amoreCheckout(item: $buying, checkout: checkout)
    }
}

That's the whole integration. When payment completes, the license is activated on this device and licensing.status flips to .valid, so any UI you gate on it unlocks reactively.

Handle the Result

Pass onResult to receive the outcome of every attempt, including retries after a failure:

.amoreCheckout(item: $buying, checkout: checkout) { result in
    switch result {
    case .completed(let license, let licenseKey):
        print("Unlocked \(license.product.name) with \(licenseKey)")
    case .cancelled:
        break
    case .failed(.activationFailed(let licenseKey, _)):
        print("Activate manually with \(licenseKey)")
    case .failed(let error):
        print(error.localizedDescription)
    }
}

The sheet already shows the license key on success, and shows it with a copy button if activation fails after payment, so nobody is ever left having paid without a key. The result carries it for your own UI too, and the key is never persisted beyond the flow.

Recover Interrupted Purchases

If the app quits mid-checkout after the customer paid, resolve the purchase once at launch:

.task {
    await checkout.recoverPendingPurchase()
}

Starting checkout guards the same interrupted purchase, so a customer cannot be charged twice by buying again before recovery runs. A session that already completed activates its license, one still processing is polled to its result, and an open session for the same product reopens where it left off. If the session can't be checked at all, the flow fails rather than charging again, and retrying is safe.

Customize It

The sheet is a thin layer over AmoreCheckout, which is UI-independent. Everything below drives the same object and activates the license the same way.

Your Own Success Screen

Build one from the same CheckoutCompletion and call onDone to close the sheet:

.amoreCheckout(item: $buying, checkout: checkout, completedView: { completion in
    VStack {
        Text("Welcome to \(completion.product.name)!")
        Button("Start") { completion.onDone?() }
    }
})

Your Own Container

Embed AmoreCheckoutView anywhere and close it from onDone:

AmoreCheckoutView(
    checkout: checkout,
    product: product,
    onDone: { isPresented = false }
)

The view's lifetime drives the flow, so removing it from the hierarchy cancels a checkout in progress.

Your Own Paywall

AmoreCheckout is @Observable, so a paywall built from no supplied views at all can read state directly:

switch checkout.state {
case .idle, .preparing:
    ProgressView()
case .awaitingPayment(let url):
    Link("Pay", destination: url)
case .activating:
    Text("Activating…")
case .completed(let license, _):
    Text("Unlocked \(license.product.name)")
case .failed(let error):
    Text(error.localizedDescription)
}

Browser Only

To skip the embedded web view and hand the URL straight to the browser, observe checkoutURL and open it. The license still activates in the app when payment clears.

Next Steps