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
- Custom Checkout UI for the full customization reference
- AmoreCheckout API documentation
- Pomodoro, the open-source demo app with the whole flow
- Licensing guide if you haven't set up products and Stripe yet