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 の標準機能
一次情報源はここです。「販売およびトレンド」→「サブスクリプション」で、イベント単位の内訳が見られます。
- 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回だけ。 検知のたびに出すと、解約を決めた人に毎回引き止め画面を見せることになります。表示済みフラグを
UserDefaultsに持たせて再表示しない - 引き止めない。 「よろしければ理由を教えてください(任意)」の一言と選択肢だけ。解約操作を邪魔するような 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に寄せるか、けっこう悩みながら決めているので、その設計の考え方をまとめたいなと。
質問やツッコミがあれば、コメントでお気軽にどうぞ。
