> For the complete documentation index, see [llms.txt](https://developer.paddle.com/llms.txt).

# Add a hosted checkout to your mobile app

Get a step-by-step overview of how to add a Paddle-hosted external purchase flow for your iOS app, letting you go direct to customers while remaining compliant.

---

With recent developments in legislation around the App Store, you can link users in the United States to an external checkout for purchases in iOS apps.

You can use [hosted checkouts](https://developer.paddle.com/concepts/sell/hosted-checkout-mobile-apps.md) to let users securely make purchases outside your app — no hosting required. Customers tap a button in your app to open a checkout that's fully hosted by Paddle, then they're redirected to your app when they complete their purchase.

{% callout type="default" %}
Access to Hosted checkouts on live accounts is limited to approved mobile app companies. It's available on all [sandbox accounts](https://developer.paddle.com/sdks/sandbox.md) for evaluation and testing. To request approval, contact [sellers@paddle.com](mailto:sellers@paddle.com).
{% /callout %}

## What are we building?

In this tutorial, we'll use [hosted checkouts](https://developer.paddle.com/concepts/sell/hosted-checkout-mobile-apps.md) in Paddle to build an external purchase flow for in-app purchases in iOS apps.

We'll walk through handling fulfillment using the [RevenueCat x Paddle integration](https://www.paddle.com/revenuecat-integration-beta) or [webhooks](https://developer.paddle.com/webhooks/overview.md).

## What's not covered

This tutorial doesn't cover:

- **Handling authentication**  
  We assume you already have a way to identify your users, like [Firebase](https://firebase.google.com/) or [Sign in with Apple](https://developer.apple.com/sign-in-with-apple/).
- **Native in-app purchases**  
  We'll launch Paddle Checkout in Safari then redirect users back to your app. Like the App Store, Paddle Checkout supports [Apple Pay](https://developer.paddle.com/concepts/payment-methods/apple-pay.md) with no additional setup, plus [other popular payment options](https://developer.paddle.com/concepts/payment-methods/overview.md).
- **Subscription lifecycle management**  
  You can use Paddle to handle all parts of the subscription lifecycle, including updating payment methods and canceling subscriptions using the prebuilt [customer portal](https://developer.paddle.com/concepts/customer-portal.md). We cover that elsewhere in our docs.

## Before you begin

### Sign up for Paddle

You'll need a Paddle account to get started. You can sign up for two kinds of account:

- [Sandbox](https://developer.paddle.com/sdks/sandbox.md) — for testing and evaluation
- Live — for selling to customers

For this tutorial, we recommend signing up for a sandbox account. You can transition to a live account later when you've built your integration and you're ready to start selling. If you sign up for a live account, you'll need to:

- [**Complete account verification**](https://developer.paddle.com/build/onboarding/set-up-checklist#verification.md)  
  We'll ask you for some information to make sure that we can work together.
- [**Request hosted checkout access**](mailto:sellers@paddle.com)  
  You should contact support to check you're eligible to use hosted checkouts.

### Prep your iOS development environment

As part of our tutorial, we're going to update our app to include a link to a hosted checkout for purchases. You'll need:

- Some knowledge of iOS development, access to your iOS project, and Xcode on macOS.
- A [correctly configured URL scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) so you can redirect users back to your app.

You don't need to make changes to your iOS app to create a hosted checkout in Paddle, so you can come back to this later if you're working with a developer.

## Overview

Add a hosted checkout to your app to link out for in-app purchases in five steps:

1. [**Map your product catalog**](#create-catalog)  
   Create products and prices in Paddle that match your in-app purchase options.
2. [**Create a hosted checkout**](#create-hosted-checkout)  
   Create a hosted checkout in the Paddle dashboard, including where to redirect customers to after purchase.
3. [**Add a checkout button to your app**](#add-button)  
   Create a button that opens the hosted checkout URL when tapped.
4. [**Handle fulfillment and provisioning**](#handle-fulfillment)  
   Use RevenueCat or process webhooks to fulfill purchases after a customer completes a checkout.
5. [**Take a test payment**](#test-implementation)  
   Make a test purchase to make sure your purchase flow works correctly.

## Map your product catalog {% step=true %}

Before we add a hosted checkout to our app, we need to set up our product catalog in Paddle to match the in-app purchases we offer.

### Model your pricing

A [complete product](https://developer.paddle.com/build/products/create-products-prices.md) in Paddle is made up of two parts:

- A product entity that describes the item, like its name, description, and an image.
- At least one related price entity that describes how much and how often a product is billed.

You can create as many prices for a product as you want to describe all the ways they're billed.

In this example, we'll create a single product and single price for a one-time item called `Lifetime Access`.

### Create products and prices

You can [create products and prices](https://developer.paddle.com/build/products/create-products-prices.md) using the Paddle dashboard or the API.

{% instruction-steps %}

1. Go to **Paddle > Catalog > Products**.
2. Click {% mock-button icon="carbon:add" %}New product
3. Enter details for your new product, then click Save when you're done.
4. Under the **Prices** section on the page for your product, click New price
5. Enter details for your new price. Set the type to **One-time** to create a one-time price.
6. Click Save when you're done.
7. Click the  button next to a price in the list, then choose Copy price ID from the menu. Keep this for later.
{% /instruction-steps %}

{% /dashboard-instructions %}

## Create a hosted checkout {% step=true %}

Next, create a hosted checkout. A [hosted checkout](https://developer.paddle.com/concepts/sell/hosted-checkout-mobile-apps.md) is a link that users can use to make a purchase. It's unique to your account.

You can create multiple hosted checkouts if you have different apps or want to create links that redirect to different places in your app.

When creating a hosted checkout, you can set default prices. If you don't pass prices or a transaction to the checkout directly, the default prices are used instead.

{% callout type="note" %}
[Add a custom subdomain](https://developer.paddle.com/build/checkout/custom-subdomains.md) to personalize your hosted checkout link with your company or app name, like `aeroedit.paddle.io`. This is optional, but recommended for best customer experience and conversion rates.
{% /callout %}

{% instruction-steps %}

1. Go to **Paddle > Checkout > Hosted checkout**.
2. Click {% mock-button icon="carbon:add" %}New hosted checkout
3. Enter a name and a description. This is typically your app name and any details for your reference. They're not shown to customers.
4. Enter a redirect URL. This should be a custom URL scheme or [universal link](https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content/) that bounces users back to your app when their purchase is completed, for example `myapp://example-redirect`.
5. Optionally, paste the price ID you copied previously to the list of **Default prices** if you want this to be opened on every launch of the hosted checkout. You can add multiple price IDs if you have them to hand.
6. Click Save when you're done.
7. Click the  button next to the hosted checkout you just created, then choose Copy URL from the menu. Keep this for the next step.

{% /instruction-steps %}

{% /dashboard-instructions %}

## Add a checkout button to your app {% step=true %}

Now, update your iOS app to add a button that:

1. Checks to see if in-app purchases are allowed on the device.
2. Checks to see if a user already purchased the item.
3. Constructs a URL using your hosted checkout launch URL, and a `price_id` query parameter with the price ID you copied previously as the value.

Here's an example using SwiftUI:

```swift {% title="PurchaseView.swift" highlightLines="5,6" collapse=true %}
import SwiftUI
import StoreKit // required for checking device payment capabilities using SKPaymentQueue

struct PurchaseView: View {
    let checkoutBaseURL = "https://pay.paddle.io/checkout/hsc_01jt8s46kx4nv91002z7vy4ecj_1as3scas9cascascasasx23dsa3asd2a" // replace with your checkout launch URL
    let priceId = "pri_01h1vjg3sqjj1y9tvazkdqe5vt" // replace with a price ID or dynamically set it
    
    var body: some View {
        VStack {
            // Check if the device can make payments
            if SKPaymentQueue.canMakePayments() {
                // Create a purchase button with styling
                Button("Buy now") {
                    openCheckout()
                }
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(10)
            } else {
                // Fallback text when purchases aren't available
                Text("Purchases not available on this device.")
                    .foregroundColor(.secondary)
            }
        }
        .padding()
    }
    
    // Function to construct and open the checkout URL
    func openCheckout() {
        // Create URL with price_id parameter
        let checkoutURL = "\(checkoutBaseURL)?price_id=\(priceId)"
        if let url = URL(string: checkoutURL) {
            // Open URL in the default browser
            UIApplication.shared.open(url)
        }
    }
}
```

{% callout type="info" %}
If you've set a default price for the hosted checkout, you don't need to pass a `price_id` in the URL unless you want to open a checkout for a different price.
{% /callout %}

### Prefill information {% badge="Recommended" %}

To make for a more seamless user experience, you can use [URL parameters](https://developer.paddle.com/paddlejs/hosted-checkout-url-parameters.md) to pass additional information to the hosted checkout.

In this updated example, we pass customer details and a unique identifier for the customer in RevenueCat.

```swift {% title="PurchaseView.swift" collapse=true highlightLines="8,9,10,11,12,13,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54" %}
import SwiftUI
import StoreKit // required for checking device payment capabilities using SKPaymentQueue

struct PurchaseView: View {
    let checkoutBaseURL = "https://pay.paddle.io/checkout/hsc_01jt8s46kx4nv91002z7vy4ecj_1as3scas9cascascasasx23dsa3asd2a" // replace with your checkout launch URL
    let priceId = "pri_01h1vjg3sqjj1y9tvazkdqe5vt" // replace with a price ID or dynamically set it
    
    // Additional information
    // In a real app, this would come from your user authentication platform
    let appUserId = "85886aac-eef6-41df-8133-743cbb1daa4b"
    let userEmail = "sam@example.com"
    let countryCode = "US"
    let postalCode = "10021"
    
    var body: some View {
        VStack {
            // Check if the device can make payments
            if SKPaymentQueue.canMakePayments() {
                // Create a purchase button with styling
                Button("Buy now") {
                    openCheckout()
                }
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(10)
            } else {
                // Fallback text when purchases aren't available
                Text("Purchases not available")
                    .foregroundColor(.secondary)
            }
        }
        .padding()
    }
    
    // Function to construct and open the checkout URL with customer data
    func openCheckout() {
        // Create URL components for building a URL with query parameters
        var urlComponents = URLComponents(string: checkoutBaseURL)!
        
        // Add the price ID and customer information as query parameters
        urlComponents.queryItems = [
            URLQueryItem(name: "price_id", value: priceId),
            URLQueryItem(name: "app_user_id", value: appUserId),
            URLQueryItem(name: "user_email", value: userEmail),
            URLQueryItem(name: "country_code", value: countryCode),
            URLQueryItem(name: "postal_code", value: postalCode)
        ]
        
        // Open the checkout URL if it's valid
        if let url = urlComponents.url {
            UIApplication.shared.open(url)
        }
    }
}
```

## Handle fulfillment and provisioning {% step=true %}

When a customer completes a purchase, they'll be redirected back to your app. At this point, you need to handle fulfillment and unlock the features they bought.

{% accordion %}
{% accordion-item title="Using RevenueCat" %}

If you use the [RevenueCat x Paddle integration](https://www.paddle.com/revenuecat-integration-beta) to handle entitlements, you're all set.

Here's how it works:

1. Paddle automatically sends data to RevenueCat about the completed checkout.
2. RevenueCat grants the user an entitlement based on [your product configuration](https://www.revenuecat.com/docs/offerings/products-overview).
3. Use the RevenueCat SDK to [check entitlement status](https://www.revenuecat.com/docs/customers/customer-info) in your iOS app.

{% /accordion-item %}
{% accordion-item title="Using webhooks" %}

You can use webhooks to build your own fulfillment workflow. In this example, we'll grant users access when they've purchased our `Lifetime Access` product.

#### Build a webhook handler {% id="handler-handle-fulfillment" %}

When a customer creates or completes a transaction, Paddle can send a webhook to an endpoint you set up. You can store details of the transaction in your database and associate it with the user's account.

Add a new endpoint to the existing server-side code as set up in [Set up the endpoint](https://developer.paddle.com/build/mobile-apps/link-out-mobile-app-custom-workflow#setup-endpoint-create-transaction.md).

```typescript {% title="server.js" collapse=true %}
app.post("/paddle/webhooks", express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    // You can verify the webhook signature here
    // We don't cover this in the tutorial but it's best practice to do so
    // https://developer.paddle.com/webhooks/signature-verification

    const payload = JSON.parse(req.body.toString());
    const { data, event_type } = payload;
    const occurredAt = payload.occurred_at;

    // Listen for vital events from Paddle
    switch (event_type) {
      // 1. Record transactions in the database

      // Handle a new transaction
      // You can create a Transaction database to store records and associate them to a user
      case 'transaction.created':
        // Find the user associated with this transaction
        const userForTransaction = await User.findOne({ where: { paddleCustomerId: data.customer_id } });

        if (userForTransaction) {
          await Transaction.create({
            transactionId: data.id,
            userId: userForTransaction.id,
            subscriptionId: data.subscription_id,
            status: data.status,
            amount: data.amount,
            currencyCode: data.currency_code,
            occurredAt: occurredAt
          });
        }
        break;

      // Handle a completed transaction
      // If you have a Transaction database, you can update the transaction record
      case 'transaction.completed':

        // Find the transaction by its ID
        const completedTransaction = await Transaction.findOne({
          where: { transactionId: data.id }
        });

        if (completedTransaction) {
          await completedTransaction.update({
            status: data.status,
            subscriptionId: data.subscription_id,
            invoiceId: data.invoice_id,
            invoiceNumber: data.invoice_number,
            billedAt: data.billed_at,
            updatedAt: data.updated_at
          });
        }
        break;
    }

    res.json({ received: true });
  } catch (error) {
    console.error('Error processing webhook:', error);
    return res.status(500).json({ error: error.message });
  }
});
```

#### Unlock user access {% id="provision-access-handle-fulfillment" %}

When you receive the [`transaction.completed`](https://developer.paddle.com/webhooks/transactions/transaction-completed.md) webhook, you can use the details to handle order fulfillment and provisioning.

The example below updates a user's access permissions in your database. After this, your iOS app can check for the `lifetimeAccess` permission to unlock premium features.

```typescript {% title="server.js" collapse=true highlightLines="53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85" %}
app.post("/paddle/webhooks", express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    // You can verify the webhook signature here
    // We don't cover this in the tutorial but it's best practice to do so
    // https://developer.paddle.com/webhooks/signature-verification

    const payload = JSON.parse(req.body.toString());
    const { data, event_type } = payload;
    const occurredAt = payload.occurred_at;

    // Listen for vital events from Paddle
    switch (event_type) {
      // 1. Record transactions in the database

      // Handle a new transaction
      // You can create a Transaction database to store records and associate them to a user
      case 'transaction.created':
        // Find the user associated with this transaction
        const userForTransaction = await User.findOne({ where: { paddleCustomerId: data.customer_id } });

        if (userForTransaction) {
          await Transaction.create({
            transactionId: data.id,
            userId: userForTransaction.id,
            subscriptionId: data.subscription_id,
            status: data.status,
            amount: data.amount,
            currencyCode: data.currency_code,
            occurredAt: occurredAt
          });
        }
        break;

      // Handle a completed transaction
      // If you have a Transaction database, you can update the transaction record
      case 'transaction.completed':

        // Find the transaction by its ID
        const completedTransaction = await Transaction.findOne({
          where: { transactionId: data.id }
        });

        if (completedTransaction) {
          await completedTransaction.update({
            status: data.status,
            subscriptionId: data.subscription_id,
            invoiceId: data.invoice_id,
            invoiceNumber: data.invoice_number,
            billedAt: data.billed_at,
            updatedAt: data.updated_at
          });

          // 2. Provision access to your app
          // Fetch the user associated with this transaction
          const user = await User.findOne({ where: { id: completedTransaction.userId } });

          if (user) {
            // Fetch the items from the transaction
            const purchasedItems = data.items || [];

            // Add what access the user has based on the items they purchased
            // For this example, we're using access permissions and storing them in the user model on an accessPermissions field
            // We also map the Paddle product IDs to the access permissions
            // In a real app, you could use a database table for this mapping

            // Get existing permissions to see if any first
            const accessPermissions = user.accessPermissions ? JSON.parse(user.accessPermissions) : {};

            // Map product IDs to access permissions
            // We add an additional product as an example of how you can handle multiple
            const productToPermission = {
              'pro_01h1vjes1y163xfj1rh1tkfb65': 'lifetimeAccess',
              'pro_01gsz97mq9pa4fkyy0wqenepkz': 'temporaryAccess'
            }

            purchasedItems.forEach(({ price }) => {
              const permissionKey = productToPermission[price.product_id];
              if (permissionKey) accessPermissions[permissionKey] = true;
            });

            // Update the user with their new access permissions
            await user.update({
              accessPermissions: JSON.stringify(accessPermissions),
            });
          }
        }
        break;
    }

    res.json({ received: true });
  } catch (error) {
    console.error('Error processing webhook:', error);
    return res.status(500).json({ error: error.message });
  }
});
```

#### Create a notification destination

To start receiving webhooks, [create a notification destination](https://developer.paddle.com/webhooks/notification-destinations.md). This is where you can tell Paddle which events you want to receive and where to deliver them to.

{% instruction-steps %}

1. Go to **Paddle > Developer tools > Notifications**.
2. Click {% mock-button icon="carbon:add" %}New destination.
3. Set **Notification type** to **URL** and enter the URL for your webhook handler.
4. Choose the `transaction.completed` event. You can always edit events later.
5. Click Save destination.
{% /instruction-steps %}

{% /dashboard-instructions %}

{% /accordion-item %}
{% /accordion %}

## Test the complete flow {% step=true %}

We're now ready to test the complete purchase flow end-to-end! If you're using a sandbox account, you can take a test payment using [our test card details](https://developer.paddle.com/concepts/payment-methods/credit-debit-card.md):

{% definition-list %}
{% definition term="Email address" %}
An email address you own
{% /definition %}
{% definition term="Country" %}
Any valid country supported by Paddle
{% /definition %}
{% definition term="ZIP code (if required)" %}
Any valid ZIP or postal code
{% /definition %}
{% definition term="Card number" %}
`4242 4242 4242 4242`
{% /definition %}
{% definition term="Name on card" %}
Any name
{% /definition %}
{% definition term="Expiration date" %}
Any valid date in the future.
{% /definition %}
{% definition term="Security code" %}
`100`
{% /definition %}
{% /definition-list %}

{% callout type="note" %}
Before you go live, extend your Apple Pay integration by [verifying your domain for Apple Pay](https://developer.paddle.com/concepts/payment-methods/apple-pay#enable-payment-method.md). This lets you launch the Apple Pay modal directly from your checkout.
{% /callout %}

## Next steps

That's it. Now you've built a purchase workflow that links out to Paddle Checkout, you might like to hook into other features of the Paddle platform.

### Learn more about Paddle

When you use Paddle, we take care of payments, tax, subscriptions, and metrics with one unified platform. Customers can self-serve with the portal, and Paddle handles any order inquiries for you.

{% card-group cols=3 %}
{% card title="Payment methods" url="/concepts/payment-methods/overview" %}
Accept global payment methods out of the box, including credit/debit cards, Apple Pay, and more.
{% /card %}
{% card title="Metrics and reports" url="/concepts/profitwell-metrics" %}
Get revenue metrics, customer analytics, and detailed sales reports in your dashboard.
{% /card %}
{% card title="Customer portal" url="/concepts/customer-portal" %}
Allow customers to manage invoices, subscriptions, and account details without your intervention.
{% /card %}
{% /card-group %}

### Build a web checkout

Our tutorial uses a hosted checkout to build a payment workflow. You can also Paddle.js to build pricing pages and signup flows on the web, then redirect people to your app.

{% card-group cols=3 %}
{% card title="Get started with Paddle" url="/build/onboarding/overview" %}
End-to-end onboarding, API keys, and going live with Paddle Billing.
{% /card %}
{% card title="Localize prices" url="/build/products/offer-localized-pricing" %}
Show local currencies and pricing by country, out of the box.
{% /card %}
{% card title="Checkout events" url="/paddlejs/events/overview" %}
Trigger business logic from Paddle.js events in your web checkout.
{% /card %}
{% /card-group %}

### Build advanced subscription functionality

Paddle Billing is designed for subscriptions as well as one-time items. You can use Paddle to build workflows to pause and resume subscriptions, flexibly change billing dates, and offer trials.

{% card-group cols=3 %}
{% card title="Pause or resume a subscription" url="/build/subscriptions/pause-subscriptions" %}
Let customers temporarily suspend or reactivate their subscriptions with your app.
{% /card %}
{% card title="Work with trials" url="/build/subscriptions/update-trials" %}
Learn how to offer and manage free trials to attract more signups.
{% /card %}
{% card title="Paddle Retain" url="/concepts/retain/overview" %}
Recover failed payments and reduce churn with Paddle Retain.
{% /card %}
{% /card-group %}