In-App Purchases on iOS with Lemon Squeezy
In this tutorial, you’ll learn how to build an in-app link-to-checkout expirience using Swift and Lemon Squeezy.
Mobile Checkout Essentials
In this tutorial we’ll show you how to:
- Set up your server with
lemonsqueezy.js
- Display products for purchase in your iOS app
- Checkout and purchase products in your iOS app
This guide does not cover user authentication, database management, or native in-app purchases
Prerequisites
Before starting this tutorial, you’ll need:
- A Lemon Squeezy account
- Your API key
- Your Store ID
- Your Webhook Secret
- At least one product created in your store
Server and API routes
For this guide we are going to use a basic Express app, and the LemonSqueezy JavaScript SDK @lemonsqueezy/lemonsqueezy.js
.
To learn more about LemonSqueezy API cheeckout our docs.
Setup
Install the LemonSqueezy JavaScript SDK @lemonsqueezy/lemonsqueezy.js
npm install express @lemonsqueezy/lemonsqueezy.js
Set up the SDK in your Express app
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY
});
Make sure to have your environment variables set
LEMONS_QUEEZY_API_KEY={API_KEY}
LEMONS_QUEEZY_STORE_ID={STORE_ID}
LEMONS_QUEEZY_WEBHOOK_SECRET={WEBHOOK_SECRET}
Fetch Products
Create a route on your server to fetch products.
Note for simplicity in this guide we are using the first variant available for a product.
app.get("/api/products", async (req, res) => {
try {
const { data, error } = await listProducts({
filter: {
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
},
include: ["variants"],
});
if (error) {
throw error;
}
data.data = data.data.map((product) => ({
...product,
variantId: product.relationships.variants.data[0]?.id,
}));
res.json({
success: true,
products: data,
});
} catch (error) {
console.error("Error fetching products:", error);
res.status(500).json({
success: false,
error: "Failed to fetch products",
});
}
});
Create a checkout
Add a route to create a checkout for a user based on their email and a product. This route will create a checkout url
which we will later use in the app.
You can optionally pass a redirect link, which in this case should be a deeplink to your iOS app.
You can customize the checkout with prefilled data you might have about your user. For this example we’re using the user’s email passed from the app, which in most cases should be your authenticated user.
app.post('/api/create-checkout', async (req, res) => {
try {
const { customerEmail, variantId} = req.body;
const checkout = {
...(customerEmail && { checkoutData: { email: customerEmail } }),
productOptions: {
redirect_url: "your.app.domain" //optional redirect url
}
};
const { data, error } = await createCheckout(
process.env.LEMON_SQUEEZY_STORE_ID,
variantId,
checkout
);
if (error) {
throw error;
}
res.json({
success: true,
checkoutUrl: data.data.attributes.url
});
} catch (error) {
console.error('Checkout creation error:', error);
res.status(500).json({
success: false,
error: 'Failed to create checkout'
});
}
});
Webhooks
Create a webhook route to recieve events from Lemon Squeezy. You will want to configure the events you receive in your store settings. Read more about webhooks here.
When receiving webhooks for purchase events you will want to sync your customer data, most likely with your database, so you can reflect purchases for your user in your app.
app.post('/api/webhooks/lemonsqueezy', async (req, res) => {
try {
const payload = req.body;
const eventName = payload.meta.event_name;
// Handle different event types
switch (eventName) {
case 'order_created':
// Handle new order
break;
case 'subscription_created':
// Handle new subscription
break;
// Add other event types as needed
}
res.status(200).send('Webhook received');
} catch (error) {
console.error('Webhook error:', error);
res.status(400).send('Webhook error');
}
});
Swift Components
Now that our server is set up, let’s build some Swift components to:
- Fetch and display our store’s products
- Display a buy button on each product to initate a checkout
- Open webview to purchase a product with Lemon Squeezy Hosted Checkout
Fetch and display your products
struct ProductListView: View {
@State private var products: [Product] = []
@State private var isLoading = true
@State private var error: String?
var body: some View {
NavigationView {
Group {
if isLoading {
ProgressView()
} else if let error = error {
Text(error)
.foregroundColor(.red)
} else {
List(products) { product in
ProductEntry(product: product)
}
}
}
.navigationTitle("Products")
.task {
await loadProducts()
}
}
}
private func loadProducts() async {
do {
products = try await ProductService.shared.fetchProducts()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
Helper methods for executing API requests to fetch products and create checkout
class ProductService {
static let shared = ProductService()
private let baseURL = "your.api.domain.com"
func fetchProducts() async throws -> [Product] {
guard let url = URL(string: "\(baseURL)/products") else {
throw NetworkError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(ProductResponse.self, from: data)
return response.products.data
}
func createCheckout(productId: String, variantId: String) async throws -> URL {
guard let url = URL(string: "\(baseURL)/create-checkout") else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"customerEmail": "[email protected]", //replace with your authenticated user email for personalized checkout
"productId": productId,
"variantId": variantId
])
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(CheckoutResponse.self, from: data)
guard let checkoutUrl = URL(string: response.checkoutUrl) else {
throw NetworkError.invalidURL
}
return checkoutUrl
}
}
enum NetworkError: Error {
case invalidURL
case noData
case decodingError(Error)
case serverError(Int)
case networkError(Error)
var localizedDescription: String {
switch self {
case .invalidURL: return "Invalid URL"
case .noData: return "No data received"
case .decodingError(let error): return "Failed to decode: \(error.localizedDescription)"
case .serverError(let code): return "Server error: \(code)"
case .networkError(let error): return error.localizedDescription
}
}
}
Product listing component with a button to buy
struct ProductEntry: View {
let product: Product
@State private var isLoading = false
@State private var error: String?
@State private var showingCheckout = false
@State private var checkoutUrl: URL?
var body: some View {
HStack {
ProductInfo(product: product)
Spacer()
BuyButton(isLoading: isLoading, action: handleCheckout)
}
.alert("Error", isPresented: Binding(
get: { error != nil },
set: { if !$0 { error = nil } }
)) {
Text(error ?? "")
}
.sheet(isPresented: $showingCheckout) {
if let url = checkoutUrl {
SafariView(url: url)
}
}
}
private func handleCheckout() {
isLoading = true
Task {
do {
checkoutUrl = try await ProductService.shared.createCheckout(
productId: product.id,
variantId: product.variantId
)
showingCheckout = true
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}
struct ProductInfo: View {
let product: Product
var body: some View {
VStack(alignment: .leading) {
Text(product.attributes.name)
.font(.headline)
Text(product.attributes.price_formatted)
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
struct BuyButton: View {
let isLoading: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
if isLoading {
ProgressView()
} else {
Text("Buy")
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.blue)
.cornerRadius(8)
}
}
.disabled(isLoading)
}
}
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
Models
struct ProductResponse: Codable {
let success: Bool
let products: ProductsData
}
struct ProductsData: Codable {
let data: [Product]
}
struct Product: Codable, Identifiable {
let type: String
let id: String
let variantId: String
let attributes: ProductAttributes
}
struct ProductAttributes: Codable {
let name: String
let price_formatted: String
let description: String
let buy_now_url: String
}
struct CheckoutResponse: Codable {
let success: Bool
let checkoutUrl: String
}
Congratulations! You’re now able to sell products in your iOS app with Lemon Squeezy.