WWDC Quick Look 💓 By SwiftGGTeam
Implement App Store Offers

Implement App Store Offers

元の動画を見る

ハイライト

App Store のサブスクリプションオファー体系には introductory offers(新規獲得)、promotional offers(活性化)、offer codes(マーケティング配布)の 3 形態があります。今年の更新は 2 方向。既存 API の情報補完と、離脱ユーザーを取り戻す全新 win-back offers の登場です。


主要内容

サブスクリプション製品の核心課題はユーザー離脱です。ユーザーが自動更新をキャンセルすると、開発者はほぼ再接触手段がありません——promotional offers は資格検証とプッシュを自前で行い、コンバージョン経路が長くコストも高い。Apple 今年の解は win-back offers。サブスクリプション期限切れかつ自動更新オフの離脱ユーザー向けの全新オファータイプです。

win-back offers が従来オファーと最大の違いは、Apple がユーザーセグメントとチャネル配布を代行すること。App Store Connect で資格条件(有料期間、離脱期間、2 回目オファー間隔)を設定すれば、Apple が条件合致ユーザーを自動マッチし、製品ページ、Today タブ、サブスクリプション設定ページなどにオファーカードを表示。ユーザーはカードをタップし決済確認まで一気通貫。App Store Connect で win-back offer の優先度も設定でき、Apple がパーソナライズ推薦に使用します。

API 面も更新あり。Transaction と RenewalInfo 構造体に offer フィールドが追加され、オファー ID、タイプ、支払モードを統合。StoreKit Views に subscriptionPromotionalOfferpreferredSubscriptionOffer の 2 つの View Modifier が追加され、SubscriptionStoreView で表示オファーを直接制御可能。macOS 15 で offer code redemption sheet がついにサポート。


詳細

Transaction と RenewalInfo の offer フィールド追加

Transaction 構造体は iOS 17.2 で offer メンバーを追加。オプショナル値で idtypepaymentMode の 3 フィールドを含む。ユーザーがオファー利用で購入した場合のみ設定される。RenewalInfo の同名フィールドは iOS 18.0 から利用可能。次の更新サイクルに適用されるオファーを表す(02:02)。

App Store Server API 側の対応フィールドは offerIdentifierofferType(既存)、新規 offerDiscountType(新フィールド、履歴取引もバックフィル)。offerDiscountType の値は StoreKit の paymentMode に対応し、free trial / pay-as-you-go / pay-up-front を区別(02:53)。

macOS 15 が offer code redemption sheet をサポート

// Present offer code redemption sheet on macOS - SwiftUI API

import SwiftUI
import StoreKit

struct MyView: View {

    @State var showOfferCodeRedemption: Bool = false

    var body: some View {
        Button("Redeem Code") {
            showOfferCodeRedemption = true
        }
        .offerCodeRedemption(isPresented: $showOfferCodeRedemption) { result in
            // Handle result
        }
    }
}
  • @State var showOfferCodeRedemption:ブール状態で引換シートの表示/非表示を制御
  • .offerCodeRedemption(isPresented:):macOS 15 から iOS と同 API。シートが引換フローを自動処理
  • result クロージャ:引換結果を取得して後続処理(04:25

preferredSubscriptionOffer:SubscriptionStoreView で表示オファーを選択

// Choose preferred offer in a SubscriptionStoreView

import SwiftUI
import StoreKit

struct MyView: View {
    let groupID: String

    var body: some View {
        SubscriptionStoreView(groupID: groupID)
            .preferredSubscriptionOffer { product, subscription, eligibleOffers in
                let freeTrialOffer = eligibleOffers
                    .filter { $0.paymentMode == .freeTrial }
                    .max { lhs, rhs in
                        lhs.period.value < rhs.period.value
                    }
                return freeTrialOffer ?? eligibleOffers.first
            }
    }
}
  • preferredSubscriptionOffer:クロージャ引数は product、subscription、eligibleOffers。StoreKit が現在ユーザーが資格を持つ全オファーを自動渡し
  • $0.paymentMode == .freeTrial:無料トライアル系オファーをフィルタ
  • .max { lhs.period.value < rhs.period.value }:最長の無料トライアルを検索
  • freeTrialOffer ?? eligibleOffers.first:無料トライアルがなければ最初の利用可能オファーにフォールバック(20:15

サブスクリプション資格と win-back offer 表示ロジックの確認

// Check subscription entitlement and offer eligibility

import StoreKit

func shouldShowMerchandising(
    for groupID: String,
    productIDs: [Product.ID]
) async throws -> MerchandisingVisibility {
    // Get subscription status
    let statuses = try await Product.SubscriptionInfo.status(for: groupID)

    // Check if the customer is already entitled to the subscription
    let entitlement = SubscriptionEntitlement(for: statuses)
    if entitlement.autoRenewalEnabled {
        return .hidden
    }

    // Check for offers to show in merchandising UI
    let products = try await Product.products(for: productIDs)

    let isEligibleForIntroOffer = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
    if isEligibleForIntroOffer {
        let subscriptions = products.map {
            ($0, $0.subscription?.introductoryOffer)
        }
        return .visible(subscriptions)
    }

    // Check for eligible win-back offers
    let purchasedStatus = statuses.first {
        $0.transaction.unsafePayloadValue.ownershipType == .purchased
    }
    let renewalInfo = try purchasedStatus?.renewalInfo.payloadValue
    let bestWinBackOfferID = renewalInfo?.eligibleWinBackOfferIDs.first

    // Return the product with the offer if there is one
    if let bestWinBackOfferID {
        let subscriptions: [(Product, Product.SubscriptionOffer?)] = products.map {
            let winBackOffer = $0.subscription?.winBackOffers.first {
                $0.id == bestWinBackOfferID
            }
            return ($0, winBackOffer)
        }
        return .visible(subscriptions)
    }

    // Only return the product if there is no offer
    return .visible(products.map { ($0, nil) })
}
  • Product.SubscriptionInfo.status(for:):サブスクリプショングループの現在状態を取得
  • SubscriptionEntitlement:カスタム構造体。状態リストから権利保有と自動更新 ON/OFF を判定
  • isEligibleForIntroOffer:まず introductory offer 資格を確認。新規ユーザーにはトライアル優先表示
  • $0.transaction.unsafePayloadValue.ownershipType == .purchased:「自己購入」記録のみ参照。Family Sharing を除外
  • renewalInfo?.eligibleWinBackOfferIDs.first:Apple 返却リストの先頭が最適 win-back offer
  • $0.subscription?.winBackOffers.first { $0.id == bestWinBackOfferID }:製品情報内で ID マッチし完全 offer オブジェクトを取得(23:05

win-back offer を購入に追加

// Add a win-back offer to a purchase

import StoreKit

func purchase(
    _ product: Product,
    with offer: Product.SubscriptionOffer?
) async throws {
    // Prepare the purchase options
    var purchaseOptions: Set<Product.PurchaseOption> = []

    // Add win-back offer to the purchase
    if let offer, offer.type == .winBack {
        purchaseOptions.insert(.winBackOffer(offer))
    }

    // Make the purchase
    try await product.purchase(options: purchaseOptions)
}
  • .winBackOffer(offer):iOS 18.0 新 PurchaseOption。win-back offer オブジェクトを購入フローに渡す
  • product.purchase(options:):App Store が支払シート表示前にユーザー資格を自動検証(25:26

Streamlined Purchasing と PurchaseIntent

App Store Connect で App Store プロモーションを有効化している場合(デフォルト ON)、ユーザーは app を開かず App Store 内で win-back offer 購入を完了可能。この動作を streamlined purchasing と呼ぶ。購入前にカスタムロジック(オンボーディング画面など)が必要なら App Store Connect で無効化し、app 内で PurchaseIntent API を使って App Store から開始された購入を受信(28:48)。PurchaseIntent は iOS 18.0 で offer フィールドを追加。win-back offer を含む場合のみ設定される。


重要ポイント

  • 何を作るか:離脱ユーザー向け win-back offers セグメント戦略を設定。「有料サブスクリプション期間 ≥ 3 か月 + 離脱 2–6 か月」を初回条件に、App Store Connect で無料トライアル型 win-back offer を作成。なぜ価値があるか:Apple が自動配布。資格検証とプッシュロジックを自前で書かず、promotional offers より広いカバレッジ。どう始めるか:App Store Connect のサブスクリプション価格ページで Win-Back Offers タブを開き、Create Offer をクリック、資格条件と価格を設定。

  • 何を作るか:SubscriptionStoreView に preferredSubscriptionOffer を組み込み、StoreKit Views が最適オファーを自動表示。なぜ価値があるか:introductory offer と win-back offer の両方に該当する場合、どちらを表示するか決める必要があり、この View Modifier で数行の選択ロジックを完結。どう始めるか:既存 SubscriptionStoreView に .preferredSubscriptionOffer 修飾子を追加、クロージャ内でビジネス優先度に応じた SubscriptionOffer を返す。

  • 何を作るか:Transaction と RenewalInfo のオファー確認を新 offer フィールドに移行。なぜ価値があるかoffer フィールドがオファー ID、タイプ、支払モードを統合。Apple 推奨の照会方式。旧分散フィールドは将来非推奨の可能性。どう始めるか:サーバー側 JWSTransaction 解析で offerIdentifier + offerType に加え offerDiscountType も読み取り。クライアント側は transaction.offer を第一チェック入口に。

  • 何を作るか:macOS 版 app に offer code redemption sheet サポートを追加。なぜ価値があるか:macOS 15 でついにこの能力が追加。以前 macOS ユーザーは手動でコード入力のみで体験が悪かった。どう始めるか.offerCodeRedemption(isPresented:) SwiftUI 修飾子を使用。コードは iOS と完全一致。


関連セッション

コメント

GitHub Issues · utterances