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 or React Native 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

This tutorial will show you how to dynamically fetch products and customize checkouts per customer with deep linking back to your app.

However, you can always directly link to any of your products with its unique checkout link.

Demo

Lemon Squeezy: Plans

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

LEMON_SQUEEZY_API_KEY={API_KEY}
LEMON_SQUEEZY_STORE_ID={STORE_ID}
LEMON_SQUEEZY_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: {
        redirectUrl: "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

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
}

React Native

Now that our server is set up, let’s build a React Native component to:

  • Fetch and display our store’s products
  • Display a buy button on each product to initate a checkout
  • Link to purchase a product with Lemon Squeezy Hosted Checkout

Imports and Interface

import React, { useEffect, useState } from 'react';
import {
  View,
  Text,
  FlatList,
  StyleSheet,
  TouchableOpacity,
  ActivityIndicator,
  Linking,
  Image
} from 'react-native';
 
interface Product {
  id: string;
  type: string;
  variantId: string;
  attributes: {
    name: string;
    price_formatted: string;
    description: string;
    large_thumb_url: string;
  };
}

Main Component

This component encompasses all the heavy lifting by fetching, displaying, and linking to checkout for each product.

const ProductsScreen = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetchProducts();
  }, []);
 
  const fetchProducts = async () => {
    try {
      const response = await fetch('your.api.domain.com/api/products');
      const { success, products } = await response.json();
      if (success) {
        setProducts(products.data);
      }
    } catch (err) {
      setError('Failed to fetch products');
    } finally {
      setLoading(false);
    }
  };
 
  const handleCheckout = async (variantId: string) => {
    try {
      const response = await fetch('your.api.domain.com/api/create-checkout', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          customerEmail: '[email protected]', //replace with your authenticated user email for personalized checkout
          variantId: variantId,
          returnUrl: 'your.app.domain', //optional redirect url
        }),
      });
      const { success, checkoutUrl } = await response.json();
      if (success && checkoutUrl) {
        await Linking.openURL(checkoutUrl);
      }
    } catch (err) {
      setError('Failed to create checkout');
    }
  };
 
  const renderProduct = ({ item }: { item: Product }) => (
    <View style={styles.productCard}>
      <Image 
        source={{ uri: item.attributes.large_thumb_url }}
        style={styles.productImage}
        resizeMode="cover"
      />
      <Text style={styles.productName}>{item.attributes.name}</Text>
      <Text style={styles.productPrice}>{item.attributes.price_formatted}</Text>
      <TouchableOpacity
        style={styles.buyButton}
        onPress={() => handleCheckout(item.variantId)}
      >
        <Text style={styles.buyButtonText}>Buy Now</Text>
      </TouchableOpacity>
    </View>
  );
 
  if (loading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }
 
  if (error) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>{error}</Text>
      </View>
    );
  }
 
  return (
    <FlatList
      data={products}
      renderItem={renderProduct}
      keyExtractor={(item) => item.id}
      contentContainerStyle={styles.container}
    />
  );
};
 
export default ProductsScreen;

Styling

const styles = StyleSheet.create({
  container: {
    padding: 16,
    marginTop: 16,
  },
  center: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  productCard: {
    backgroundColor: 'white',
    padding: 16,
    marginBottom: 16,
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 2,
  },
  productName: {
    fontSize: 18,
    fontWeight: 'bold',
  },
  productPrice: {
    fontSize: 16,
    color: '#666',
    marginTop: 8,
  },
  buyButton: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 6,
    marginTop: 12,
    alignItems: 'center',
  },
  buyButtonText: {
    color: 'white',
    fontWeight: '600',
  },
  error: {
    color: 'red',
    fontSize: 16,
  },
  webview: {
    flex: 1,
  },
  productImage: {
    width: '100%',
    height: 200,
    borderRadius: 8,
    marginBottom: 12,
  },
});

Congratulations! You’re now able to sell products in your iOS app with Lemon Squeezy.


Was this page helpful?