0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ITパスポート300問アプリのサブスク継続率を、サーバーなしでどこまで測れるか考えた話

0
Posted at

ITパスポート300問アプリのサブスク継続率を、サーバーなしでどこまで測れるか考えた話

前回(StoreKit 2でサブスクを入れた時の小さな気づき)の続きです。

個人開発の iOS アプリ「情シスの教科書」(ITパスポート学習アプリ、約300問収録)にサブスクを入れてしばらく経ち、次に気になり始めたのが 「入ってくれた人が、ちゃんと続けてくれているのか」 でした。

ただ、このアプリはサーバーを持たない構成(Core Data + CloudKit + StoreKit 2 のみ)なので、自前でイベントログを集める場所がありません。その制約の中で継続率をどこまで追えるのか、調べたり試したりしたことをまとめます。

先に結論だけ書くと、

  • 全体の集計(継続率・解約数)は App Store Connect が一次情報源。アプリ側で頑張らない
  • 個々のユーザーについては、StoreKit 2 の willAutoRenew で「解約予約したか」までは分かる
  • レポートの自動取得は App Store Connect API を手元の Mac から叩けば足りる。サーバーは要らなかった

という着地でした。


前提: 「継続率」と呼んでいたものが3つあった

調べ始めてすぐ気づいたのですが、自分が漠然と「継続率」と呼んでいたものには、実際には別物が3つ混ざっていました。

呼び方 意味 どこで分かるか
更新率 1期目→2期目に自動更新された割合 App Store Connect
トライアル転換率 無料トライアル→有料に移行した割合 App Store Connect
解約予約 自動更新をOFFにした(期限までは使える)状態 StoreKit 2 でも分かる

特に3つ目が曲者で、ユーザーが「解約」した瞬間にはまだサブスクは生きています。期限が来て初めて expired になるので、解約の意思表示と実際の失効の間にはタイムラグがある んですね。頭では分かっていたつもりでしたが、どの数字がどの段階を指しているのか、整理してから見ないと混乱します(しました)。


方法①: まずは App Store Connect の標準機能

一次情報源はここです。「販売およびトレンド」→「サブスクリプション」で、イベント単位の内訳が見られます。

image.png

  • Subscribe(新規)
  • Renew(更新)
  • Cancel(自動更新OFF = 解約予約)
  • Billing Retry(決済リトライ)

「継続率」タブではコホート別(何月に入った人が何期目まで残っているか)の表も出ます。

母数が少ないうちは「%」を見ても仕方なかった

正直に書くと、うちのアプリの規模だとコホート表はまだスカスカです。1人の解約で継続率が数十%動くような母数なので、割合を眺めて一喜一憂しても意味がない。

なので今は %ではなくイベントの生件数 を見るようにしています。「今週 Cancel が1件あった」「Billing Retry が出ている」という事実ベースの把握です。割合の分析は母数が育ってからでいいや、と割り切りました。

あと細かい点ですが、App Analytics 側の数字と販売およびトレンド側の数字は集計基準が違うので、ぴったり一致しません。最初「どっちが正しいんだ」と悩みましたが、定点観測はどちらか片方に決めてしまうのが精神衛生上良いです。


方法②: StoreKit 2 で「目の前のユーザー」のことは分かる

全体の集計はオンデバイスでは無理ですが、いま使ってくれているそのユーザーが解約予約をしたかどうか は、サーバーなしで分かります。Product.SubscriptionInfo.Status の renewalInfo.willAutoRenew です。

import StoreKit

enum RenewalIntent {
    case active         // 継続中(自動更新ON)
    case cancelPending  // 解約予約中(期限までは利用可)
    case notSubscribed
}

func checkRenewalIntent(productID: String) async -> RenewalIntent {
    guard let product = try? await Product.products(for: [productID]).first,
          let subscription = product.subscription,
          let statuses = try? await subscription.status else {
        return .notSubscribed
    }

    for status in statuses {
        guard status.state == .subscribed,
              case .verified(let renewalInfo) = status.renewalInfo else {
            continue
        }
        return renewalInfo.willAutoRenew ? .active : .cancelPending
    }
    return .notSubscribed
}

state == .subscribed かつ willAutoRenew == false が「解約予約中」です。前回書いた3状態(unknown / premium / free)の内側に、もう1段階あるイメージですね。

すでに失効している場合は renewalInfo.expirationReason で理由も取れます。

if case .verified(let renewalInfo) = status.renewalInfo,
   let reason = renewalInfo.expirationReason {
    switch reason {
    case .autoRenewDisabled:
        // ユーザー自身が解約した
    case .billingError:
        // 決済エラーで失効した
    default:
        break
    }
}

「自分の意思で解約した」のか「決済エラーで落ちた」のかは全然違う話なので、ここが区別できるのはありがたいです。

やってみた工夫: 解約予約を検知したら、一度だけ理由を聞く

せっかく解約予約が分かるので、検知したタイミングで簡単なアンケートを出すようにしました。回答の送り先はサーバーがないので Google フォームです(以前フィードバック導線として仕込んだ仕組みの流用)。

実装で気をつけたのは2点。

  1. 表示は1回だけ。 検知のたびに出すと、解約を決めた人に毎回引き止め画面を見せることになります。表示済みフラグを UserDefaults に持たせて再表示しない
  2. 引き止めない。 「よろしければ理由を教えてください(任意)」の一言と選択肢だけ。解約操作を邪魔するような UI は Apple のガイドライン的にも心証的にも良くないはず

まだ回答数が語れるほど溜まっていないのですが、「価格」「学習が終わった」「使わなくなった」のどれが多いかが分かるだけでも、次に何を直すかの判断材料になりそうだと期待しています。


方法③: レポートの自動取得は App Store Connect API + ローカルの Mac で足りた

毎日 App Store Connect にログインして眺めるのは続かないので、レポート取得だけ自動化しました。App Store Connect API の salesReports エンドポイントで、サブスクイベントのレポート(TSV)が取れます。

必要なものは3つ。

  • API キー(App Store Connect の「ユーザとアクセス」→「統合」で発行、.p8 ファイル)
  • Issuer ID と Key ID
  • Vendor Number(「支払いと財務報告」に表示)

Python だとこんな感じです。

import jwt, time, requests, gzip

KEY_ID = "XXXXXXXXXX"
ISSUER_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
VENDOR = "XXXXXXXX"
PRIVATE_KEY = open("AuthKey_XXXXXXXXXX.p8").read()

token = jwt.encode(
    {"iss": ISSUER_ID, "exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1"},
    PRIVATE_KEY, algorithm="ES256", headers={"kid": KEY_ID},
)

r = requests.get(
    "https://api.appstoreconnect.apple.com/v1/salesReports",
    headers={"Authorization": f"Bearer {token}"},
    params={
        "filter[frequency]": "DAILY",
        "filter[reportDate]": "2026-07-10",
        "filter[reportType]": "SUBSCRIPTION_EVENT",
        "filter[reportSubType]": "DETAILED",
        "filter[version]": "1_4",
        "filter[vendorNumber]": VENDOR,
    },
)
tsv = gzip.decompress(r.content).decode("utf-8")
print(tsv.splitlines()[0])  # まずヘッダを眺める

レスポンスは gzip 圧縮された TSV で、イベント種別・日付・プロダクトIDなどが1行ずつ入っています(filter[version] はたまに上がるので、エラーが出たら公式ドキュメントを確認してください)。

これを launchd で毎朝実行して、結果をローカルに積んでいるだけです。「サーバーレスで分析基盤」みたいな大げさな話ではなく、手元の Mac に TSV が溜まっていくだけなのですが、個人開発の規模ならこれで十分でした。


検討したけど、やめたこと

App Store Server Notifications V2。 解約や更新をリアルタイムで受け取れる本命の仕組みですが、通知を受けるHTTPSエンドポイントが必要です。ゼロサーバー方針に反するので今回は見送りました。Cloudflare Workers あたりで受ければ実質無料でいけそうなので、母数が増えて「日次レポートでは粗い」と感じたら再検討するかもしれません。

RevenueCat などの課金SaaS。 ダッシュボードで継続率が全部見えるのは魅力ですが、今の規模だと明らかにオーバーキルで、課金情報を外部に出す判断も今は不要かなと。「標準機能で困ってから」の順番でいいと思っています。


まとめ

  • 「継続率」には更新率・トライアル転換率・解約予約の3つが混ざりがち。まず言葉を分ける
  • 全体の集計は App Store Connect が一次情報源。母数が少ないうちは%ではなくイベント件数を見る
  • オンデバイスでも willAutoRenew で「そのユーザーの解約予約」は検知できる
  • 解約予約の検知 → 理由アンケート(1回だけ・引き止めない)はサーバーなしで作れる
  • レポートの定期取得は App Store Connect API + ローカルスクリプトで十分。サーバーは要らない

派手なダッシュボードはできませんでしたが、「何が起きているか分からない」状態からは抜けられたので、個人開発としてはこれで良いスタートかなと思っています。

次回は、いま準備している「情シスの教科書」の大型アップデートの話を書く予定です。無料と有料の線引きを一度ちゃんと引き直していて、何を無料に残して何をProに寄せるか、けっこう悩みながら決めているので、その設計の考え方をまとめたいなと。

質問やツッコミがあれば、コメントでお気軽にどうぞ。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?