概要

0:01:30

無料トライアルは、サブスクリプションアプリでもっとも効果的なコンバージョン施策のひとつです。Apple はこれを 導入オファー(introductory offer)と呼び、次の 3 種類があります。

  • 無料トライアル: 一定期間は無料で利用でき、その後は通常価格が課金されます。
  • Pay as you go: 一定回数の請求期間のあいだ、割引価格で支払います。
  • Pay up front: 一定期間分の割引額を一括で支払い、その後は通常価格に切り替わります。

このガイドでは、もっともよく使われ、コンバージョンへの影響が大きい無料トライアルを中心に扱います。 他の種類を検出・表示するコードのパターンも同じで、異なるのは paymentMode の値だけです。

トライアルは Apple ID とサブスクリプショングループごとに 1 回。 Apple は各ユーザーが 導入オファーを利用できる回数を、サブスクリプショングループごとに 1 回だけに制限しています。過去に契約して解約した ことがある場合、同じグループ内のどの商品でも新しいトライアルの対象にはなりません。

ステップ 1: App Store Connect で設定する

0:02:30

RevenueCat がトライアルを表示できるようにするには、まず App Store Connect で無料トライアルを設定します。

  1. App Store Connect にログインし、対象のアプリを開きます。
  2. サイドバーの Subscriptions へ移動します。
  3. サブスクリプショングループを選び、対象のサブスクリプション商品をクリックします。
  4. Introductory Offers までスクロールし、+ ボタンをクリックします。
  5. Start DateEnd Date を設定します(無期限で運用するなら End Date は空欄のままにします)。
  6. 無料トライアルにするため、PriceFree に設定します。
  7. Duration を選びます。
    • 3 日、1 週間、2 週間、1 か月、2 か月、3 か月、6 か月、1 年
  8. EligibilityNew Subscribers Only に設定します(標準的な選択肢です)。
  9. Save をクリックします。
反映までの遅延: App Store Connect での導入オファーの変更は、サンドボックス(sandbox)環境に 反映されるまで最大 1 時間かかることがあります。テストでトライアルが表示されない場合は、数分待ってから Offering を再取得してください。

ステップ 2: トライアルの対象かを確認する

0:02:30

Offering を取得したら、各 StoreProductintroductoryDiscount プロパティを調べます。 これが存在し、その paymentMode.freeTrial であれば、そのユーザーはその商品の トライアルの対象です。

swift
// Fetch offerings and inspect trial eligibility per package
func fetchOfferingsAndCheckTrials() async {
    do {
        let offerings = try await Purchases.shared.offerings()
        guard let current = offerings.current else { return }

        for package in current.availablePackages {
            let product = package.storeProduct

            if let intro = product.introductoryDiscount {
                switch intro.paymentMode {
                case .freeTrial:
                    // e.g. "7-day free trial"
                    print("\(product.productIdentifier): Free trial for \(intro.subscriptionPeriod)")
                case .payAsYouGo:
                    // Discounted price for N periods
                    print("\(product.productIdentifier): \(intro.localizedPriceString) for \(intro.numberOfPeriods) periods")
                case .payUpFront:
                    // One upfront reduced payment
                    print("\(product.productIdentifier): \(intro.localizedPriceString) upfront")
                default:
                    break
                }
            } else {
                print("\(product.productIdentifier): No introductory offer available")
            }
        }
    } catch {
        print("Failed to fetch offerings: \(error)")
    }
}

特定のユーザーが対象かどうかを(すでにトライアルを利用済みかどうかも踏まえて)確認するには、RevenueCat 専用の 対象確認 API を使います。

swift
// Check if a specific user is eligible for the intro offer
let productIds = ["com.yourapp.premium_monthly"]
let eligibility = await Purchases.shared.checkTrialOrIntroDiscountEligibility(productIdentifiers: productIds)

if eligibility["com.yourapp.premium_monthly"]?.status == .eligible {
    // Show "Start your 7-day free trial" messaging
} else {
    // Show regular pricing — user has already used the trial
}

ステップ 3: ペイウォール UI にトライアルを表示する

0:03:00

パッケージの introductoryDiscount を読み取り、トライアルの案内を条件付きで表示する価格カードの コンポーネントを作ります。このパターンはどの RevenueCat の Offering 構成でも動作し、App Store Connect で 設定したトライアル期間に自動的に合わせて表示されます。

swift
import SwiftUI
import RevenueCat

struct PackagePricingCard: View {
    let package: Package
    let onPurchase: (Package) -> Void

    private var product: StoreProduct { package.storeProduct }
    private var freeTrial: StoreProductDiscount? {
        guard let intro = product.introductoryDiscount,
              intro.paymentMode == .freeTrial else { return nil }
        return intro
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Trial badge
            if let trial = freeTrial {
                HStack(spacing: 4) {
                    Text("FREE")
                        .font(.caption2)
                        .fontWeight(.black)
                        .foregroundColor(.white)
                        .padding(.horizontal, 6)
                        .padding(.vertical, 2)
                        .background(Color.green)
                        .cornerRadius(4)
                    Text("\(trialPeriodLabel(trial)) free trial")
                        .font(.subheadline)
                        .fontWeight(.semibold)
                        .foregroundColor(.green)
                }
            }

            // Pricing
            HStack(alignment: .firstTextBaseline, spacing: 2) {
                Text(product.localizedPriceString)
                    .font(.title2)
                    .fontWeight(.bold)
                if let period = product.subscriptionPeriod {
                    Text("/ \(periodLabel(period))")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }

            // CTA
            Button(action: { onPurchase(package) }) {
                Text(freeTrial != nil ? "Start Free Trial" : "Subscribe Now")
                    .font(.headline)
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 14)
                    .background(Color.accentColor)
                    .cornerRadius(12)
            }

            // Legal disclaimer
            if let trial = freeTrial {
                Text("Free for \(trialPeriodLabel(trial)), then \(product.localizedPriceString)/\(periodLabel(product.subscriptionPeriod)). Cancel anytime.")
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .multilineTextAlignment(.center)
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .cornerRadius(16)
    }

    private func trialPeriodLabel(_ discount: StoreProductDiscount) -> String {
        let period = discount.subscriptionPeriod
        switch period.unit {
        case .day:   return period.value == 7 ? "7-day" : "\(period.value)-day"
        case .week:  return "\(period.value)-week"
        case .month: return "\(period.value)-month"
        case .year:  return "\(period.value)-year"
        default:     return "trial"
        }
    }

    private func periodLabel(_ period: SubscriptionPeriod?) -> String {
        guard let period = period else { return "period" }
        switch period.unit {
        case .day:   return "day"
        case .week:  return "week"
        case .month: return "month"
        case .year:  return "year"
        default:     return "period"
        }
    }
}

ステップ 4: 購入後にトライアルを検出する

0:02:00

ユーザーが「Start Free Trial」をタップして購入が成功したら、エンタイトルメントの periodType を 確認して、そのユーザーが有効なトライアル中かを検証します。これを使って、「トライアルはあと X 日で終了」の ような、トライアル専用の UI を表示できます。

swift
func purchasePackage(_ package: Package) async {
    do {
        let result = try await Purchases.shared.purchase(package: package)
        let customerInfo = result.customerInfo

        if let entitlement = customerInfo.entitlements["premium"] {
            if entitlement.periodType == .trial {
                // User is in the free trial period
                if let trialEnd = entitlement.expirationDate {
                    let formatter = RelativeDateTimeFormatter()
                    let timeRemaining = formatter.localizedString(for: trialEnd, relativeTo: Date())
                    showSuccessMessage("Trial started! Ends \(timeRemaining).")
                } else {
                    showSuccessMessage("Your free trial has started!")
                }
            } else if entitlement.isActive {
                // User purchased directly (no trial)
                showSuccessMessage("Welcome to Premium!")
            }
        }
    } catch {
        showErrorMessage("Purchase failed: \(error.localizedDescription)")
    }
}

トライアルの期限切れと移行

0:02:00

トライアルが終わると、Apple はユーザーを有料サブスクリプションに移行させる(解約していない場合)か、 エンタイトルメントを失効させます。RevenueCat はこの更新をサーバー間通知(server-to-server notification)で 受け取り、customerInfoUpdatedNotification を発火します。これは SubscriptionManager が自動的に受け取ります。

トライアル終了前に移行を積極的に促すには、willRenew を確認します。

swift
let customerInfo = try await Purchases.shared.getCustomerInfo()
if let premium = customerInfo.entitlements["premium"],
   premium.periodType == .trial {

    if premium.willRenew {
        // Trial will convert — show positive reinforcement
        print("Trial converts automatically in \(daysRemaining(until: premium.expirationDate)) days")
    } else {
        // User cancelled during trial — show win-back prompt
        showWinBackOffer()
    }
}

func daysRemaining(until date: Date?) -> Int {
    guard let date = date else { return 0 }
    return Calendar.current.dateComponents([.day], from: Date(), to: date).day ?? 0
}

サーバー側で RevenueCat の Webhook を購読すれば、TRIAL_STARTEDTRIAL_CONVERTEDTRIAL_CANCELLED のイベントを受け取り、サーバー側で フォローアップ(メール送信、分析など)を実行することもできます。


App Store ガイドライン

0:01:30
トライアルの条件を明確に開示してください。 Apple は、トライアル期間、トライアル後に課金される 価格、解約方法をわかりやすく表示することを求めています。この情報を埋もれさせると、アプリが却下される おそれがあります。ペイウォール UI とアプリのメタデータの両方に記載してください。
トライアル後の価格を目立つように表示してください。 トライアル後のサブスクリプション価格は、 ユーザーが購入を確定する前に見えている必要があります。トライアル期間だけを表示し、その後いくら課金されるかを 書かないのは避けてください。
解約手順を明確に案内してください。 設定 > [自分の名前] > サブスクリプション から 管理・解約できることをユーザーに案内します。この導線をアプリ内(たとえば設定やペイウォール)に含めることは、 ガイドラインの要件であると同時に、ユーザーの信頼につながります。

関連ガイド