1. 共通の前提設定(Dashboard)
まずRevenueCat Dashboard側で行う共通作業です。
Products → Entitlements → Offerings の関係
Product(ストア側のID)
└── Entitlement(アクセス権の抽象化)
└── Offering(表示パッケージのまとまり)
└── Package(monthly/annual/lifetime等)
Dashboard手順
- Project作成 → iOS App / Android App をそれぞれ追加
- Products に各ストアのProduct IDを登録
- Entitlements を作成(例:
premium)→ Productsをアタッチ - Offerings を作成 → Packages に Products を紐付け
2. iOS設定
App Store Connect側
| 項目 | サブスク | 買い切り |
|---|---|---|
| 種別 | Auto-Renewable Subscription | Non-Consumable(消耗しない) |
| グループ | Subscription Group必須 | 不要 |
| 無料トライアル | 設定可能 | 不可 |
App Store Connect手順(サブスク)
App Store Connect→ アプリ →サブスクリプション→ グループ作成- サブスクリプション追加 → Product ID設定(例:
com.yourapp.premium.monthly) - 価格・期間・説明を設定してレビュー提出
RevenueCat Dashboard(iOS)
iOS App→Bundle IDを入力App Store Connect API Keyを登録(In-App Purchase Key、非共有キーを推奨)
iOSコード実装
// AppDelegate or @main
import RevenueCat
func application(...) -> Bool {
Purchases.logLevel = .debug
Purchases.configure(withAPIKey: "appl_XXXXXXXXXX")
// ログインユーザーがいる場合
// Purchases.configure(withAPIKey: "...", appUserID: userId)
return true
}
// 購入画面
func loadOfferings() async {
do {
let offerings = try await Purchases.shared.offerings()
guard let current = offerings.current else { return }
// current.availablePackages でパッケージ一覧取得
} catch {
print(error)
}
}
// 購入実行(サブスク・買い切り共通)
func purchase(package: Package) async {
do {
let result = try await Purchases.shared.purchase(package: package)
if result.customerInfo.entitlements["premium"]?.isActive == true {
// アクセス付与
}
} catch {
print(error)
}
}
// リストア(買い切りでは必須UIとしてAppleが要求)
func restorePurchases() async {
do {
let customerInfo = try await Purchases.shared.restorePurchases()
// entitlementsを確認
} catch { }
}
3. Android設定
Google Play Console側
| 項目 | サブスク | 買い切り |
|---|---|---|
| 種別 | Subscriptions | One-time products(INAPP) |
| Base Plan | 必須(monthly等) | 不要 |
| Offer | 無料トライアル等 | 不要 |
Google Play Console手順(サブスク)
収益化→サブスクリプション→ 作成- Product ID設定(例:
premium_monthly) - Base Plan追加 → 期間・価格設定
- Offer(任意)でトライアル設定
RevenueCat Dashboard(Android)
Google Play App→Package Nameを入力- Service Account JSONをアップロード(Google Play → API Access → サービスアカウント作成 → 財務管理者権限付与)
Androidコード実装
// Application.onCreate()
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
Purchases.debugLogsEnabled = true
Purchases.configure(
PurchasesConfiguration.Builder(context, "goog_XXXXXXXXXX").build()
)
// Offerings取得
Purchases.sharedInstance.getOfferingsWith(
onError = { error -> },
onSuccess = { offerings ->
val packages = offerings.current?.availablePackages ?: return@getOfferingsWith
// UI表示
}
)
// 購入実行(Activity内)
Purchases.sharedInstance.purchaseWith(
PurchaseParams.Builder(activity, packageToPurchase).build(),
onError = { error, userCancelled -> },
onSuccess = { storeTransaction, customerInfo ->
if (customerInfo.entitlements["premium"]?.isActive == true) {
// アクセス付与
}
}
)
4. サブスク vs 買い切り の実装上の違い
| 観点 | サブスク | 買い切り(Non-Consumable) |
|---|---|---|
| Entitlement有効期間 | 更新毎に延長 | 永続 |
| リストア | 自動(レシート確認) | 必須ボタンをUI提供 |
| Androidキャンセル | cancelledDateが入る | 基本なし |
| Webhook | INITIAL_PURCHASE, RENEWAL, CANCELLATION 等 | INITIAL_PURCHASE のみ |
5. テスト方法
iOS テスト
Xcode → Product → Scheme → Edit Scheme → StoreKit Configuration
→ .storekit ファイルを作成してローカルテスト
または Sandbox環境:
- App Store Connect →
ユーザーとアクセス→Sandboxテスターでアカウント作成 - 実機の設定 →
App Store→ Sandboxアカウントでサインイン - サブスクの更新間隔は短縮される(1ヶ月→5分等)
// Sandboxか確認
Purchases.shared.getCustomerInfo { info, error in
print(info?.originalAppUserId)
}
Android テスト
- License Tester:Google Play Console →
設定→ライセンステスターにGoogleアカウント追加 - Test Track:Internal Testingにアップロード必須(実際のPlay Billingはリリース済みアプリが必要)
RevenueCat Dashboard でのテスト確認
Customersタブ → App User IDで検索 → トランザクション履歴確認SandboxデータとProductionデータが切り替え表示可能
6. Webhook設定 → Firebase連携
RevenueCat Dashboard設定
Project Settings → Integrations → Webhooks → URL登録
https://asia-northeast1-YOUR_PROJECT.cloudfunctions.net/revenuecatWebhook
Cloud Functions 実装全体像
// functions/src/revenuecatWebhook.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import * as sgMail from "@sendgrid/mail";
admin.initializeApp();
const db = admin.firestore();
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export const revenuecatWebhook = functions
.region("asia-northeast1")
.https.onRequest(async (req, res) => {
// 1. 認証(RevenueCat Authorization Header)
const authHeader = req.headers.authorization;
if (authHeader !== process.env.REVENUECAT_WEBHOOK_SECRET) {
functions.logger.warn("Unauthorized webhook attempt");
res.status(401).send("Unauthorized");
return;
}
const event = req.body;
const { event: eventData } = event;
try {
// 2. Firestoreに格納
await savePurchaseHistory(eventData);
// 3. メール送信(特定イベントのみ)
await sendEmailIfNeeded(eventData);
// 4. ログ書き込み
logEvent(eventData);
res.status(200).send("OK");
} catch (error) {
functions.logger.error("Webhook processing error", { error, eventData });
res.status(500).send("Internal Server Error");
}
});
// ── Firestore格納 ──────────────────────────────
async function savePurchaseHistory(event: any) {
const userId = event.app_user_id;
const eventType = event.type; // INITIAL_PURCHASE, RENEWAL, CANCELLATION, etc.
const record = {
eventType,
productId: event.product_id,
platform: event.store, // APP_STORE / PLAY_STORE
price: event.price ?? null,
currency: event.currency ?? null,
purchaseDate: event.purchased_at_ms
? admin.firestore.Timestamp.fromMillis(event.purchased_at_ms)
: null,
expirationDate: event.expiration_at_ms
? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms)
: null,
cancelledDate: event.cancel_reason ?? null,
environment: event.environment, // SANDBOX / PRODUCTION
transactionId: event.transaction_id,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
rawEvent: event, // 生データも保存しておくと安心
};
// コレクション構造: users/{userId}/purchaseHistory/{transactionId}
await db
.collection("users")
.doc(userId)
.collection("purchaseHistory")
.doc(event.transaction_id ?? db.collection("_").doc().id)
.set(record, { merge: true }); // idempotent
// サマリーも users/{userId} に最新状態を反映
await db.collection("users").doc(userId).set(
{
subscriptionStatus: deriveStatus(event),
lastEventType: eventType,
lastEventAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);
}
// ── メール送信 ─────────────────────────────────
const EMAIL_TRIGGER_EVENTS = new Set([
"INITIAL_PURCHASE",
"RENEWAL",
"CANCELLATION",
"EXPIRATION",
"BILLING_ISSUE",
]);
async function sendEmailIfNeeded(event: any) {
if (!EMAIL_TRIGGER_EVENTS.has(event.type)) return;
const userId = event.app_user_id;
const userDoc = await db.collection("users").doc(userId).get();
const email = userDoc.data()?.email;
if (!email) return;
const subjects: Record<string, string> = {
INITIAL_PURCHASE: "ご購入ありがとうございます",
RENEWAL: "サブスクリプションが更新されました",
CANCELLATION: "サブスクリプションのキャンセルを受け付けました",
EXPIRATION: "サブスクリプションが終了しました",
BILLING_ISSUE: "【要確認】お支払いに問題が発生しました",
};
await sgMail.send({
to: email,
from: "noreply@yourapp.com",
subject: subjects[event.type] ?? "購入状況のお知らせ",
text: buildEmailBody(event),
});
}
// ── Cloud Functionsログ ────────────────────────
function logEvent(event: any) {
functions.logger.info("RevenueCat event received", {
eventType: event.type,
userId: event.app_user_id,
productId: event.product_id,
environment: event.environment,
store: event.store,
transactionId: event.transaction_id,
});
}
// ── ステータス導出ヘルパー ─────────────────────
function deriveStatus(event: any): string {
const map: Record<string, string> = {
INITIAL_PURCHASE: "active",
RENEWAL: "active",
UNCANCELLATION: "active",
CANCELLATION: "cancelled",
EXPIRATION: "expired",
BILLING_ISSUE: "billing_issue",
};
return map[event.type] ?? "unknown";
}
主要なWebhookイベント一覧
| イベント | 発生タイミング |
|---|---|
INITIAL_PURCHASE | 初回購入(サブスク・買い切り共通) |
RENEWAL | サブスク自動更新 |
CANCELLATION | ユーザーがキャンセル操作 |
EXPIRATION | サブスク期限切れ |
BILLING_ISSUE | 支払い失敗 |
UNCANCELLATION | キャンセル撤回 |
PRODUCT_CHANGE | プラン変更 |
TRANSFER | ユーザーID移転 |
7. セキュリティチェックリスト
// Webhook Secret の設定
// RevenueCat Dashboard → Project Settings → Webhooks
// → Authorization Header に任意の文字列を設定
// Firebase側:environment config or Secret Manager
// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
- ✅
transaction_idをドキュメントIDにして 冪等性を確保 - ✅
environment: SANDBOXのイベントは本番Firestoreに書かないようフィルタリング推奨 - ✅ RevenueCat SDKではなくサーバー側(Webhook)を信頼の根拠にする
特に詰まりやすい箇所(Google PlayのService Account権限、Sandboxテスターの設定、Webhookの冪等処理)があれば、そこだけ深掘りします!
