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:

  1. Set up your server with lemonsqueezy.js
  2. Display products for purchase in your iOS app
  3. Checkout and purchase products in your iOS app

Prerequisites

Before starting this tutorial, you’ll need:

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.

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 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.

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.


Was this page helpful?