モバイルアプリのサブスクリプションが難しいのは、課金のAPIが難しいからではありません。同じ「Proプラン契約中」という1つの事実が、Apple・Google・あなたのサーバー・あなたのアプリの4か所に、別々の形式で、時間差で存在するからです。iOSではレシート、AndroidではPurchase Token、サーバーではDBの行、アプリではメモリ上のフラグ。どれかが遅れ、どれかが欠け、どれかが嘘をつきます。
RevenueCat は、この4つを Entitlement(アクセス権)という1つの語彙に正規化する層です。この記事は、RevenueCat公式ドキュメントの記述に忠実でありながら、「どの場面で・なぜ・どう使うか」を実コードで示す実装ガイドです。公式が正しく書いていても分散していて掴みにくい部分——アクセス権の真実源をどこに置くか、Webhookをどう安全かつ冪等に受けるか、CANCELLATION と EXPIRATION のどちらで剥奪するか——を、1本の設計として繋ぎます。
筆者の実務背景と、この記事の正直な線引き 私は自分の Expo アプリ2本(Palmia / memofu)の課金レイヤーに RevenueCat を採用しています。ただし両者ともストア公開はこれからで、「本番のストア課金を大規模に運用した実績」として語れる段階ではありません。 一方で、サブスクリプション課金そのものの本番運用は Web 側で通しています——マルチチャネル課金のサブスク学習プラットフォーム(Stripe WebhookをイベントID一意制約+
event.created順序保証+PII墨消しで冪等化、料金解決を純粋関数化、433テスト)と、サーバーレス決済基盤の信頼性レイヤー(本番二重課金0件)です。 本記事の RevenueCat 仕様はすべて公式ドキュメントを実際に参照して裏取りし、MRR・チャーン・コンバージョン改善率といった未確認の数値は一切扱いません。仕様は動きます。最終確認は必ず公式ドキュメントでお願いします(本記事の参照日は2026年8月6日)。
1. そもそも RevenueCat は何を肩代わりしているのか
「課金SDK」と一括りにすると判断を誤ります。RevenueCat が引き受けるのは購入処理そのものではなく、購入の"意味"を横断的に正規化する仕事です。実際の決済は最後まで Apple / Google が行います。
| 必要な仕事 | 自前実装 | RevenueCat |
|---|---|---|
| ストアの購入UIを出す | StoreKit / Play Billing を直接叩く | SDKがラップ(購入は結局ストアが行う) |
| レシート/購入トークンのサーバー検証 | Apple・Google 双方のAPIを実装 | 肩代わり |
| ストアからのサーバー通知受信 | App Store Server Notifications と Google RTDN を別々に実装 | 肩代わり(統一Webhookに正規化) |
| iOS/Android を跨いだ購読状態の統合 | 自前でユーザー単位に名寄せ | Entitlement として統合 |
| 復元・アカウント移管・エイリアス | 自前実装(事故が最も多い領域) | 肩代わり(挙動は設定可能) |
| 返金・猶予期間・ファミリー共有 | ストアごとの仕様差を吸収 | 肩代わり |
| 価格・ペイウォールのリモート変更 | 自前の設定配信基盤が必要 | Offering として標準機能 |
| A/Bテスト・分析 | 自前 or 別SaaS | 標準機能 |
| アクセス権のビジネスルール | あなたの責任 | あなたの責任(ここは委譲できない) |
最後の行が重要です。RevenueCat は「誰が何を買ったか」を正規化しますが、「Proを買った人にどこまで許すか」はあなたのドメインです。この境界を曖昧にした実装が、後から最も直しにくくなります。
使わない方がいい場面も正直に書く
- 買い切り(非消耗型)1商品だけ:Entitlement の恩恵よりSDKの重さが勝ちます。StoreKit 2 直叩きで十分です。
- iOSのみ・サーバーを持たない:StoreKit 2 のオンデバイス検証で完結する構成なら、外部依存を1つ増やす理由が薄い。
- すでに自社課金基盤があり、Web決済が主戦場:モバイルの取り分だけのために全体を寄せるのは本末転倒です。Web は Stripe、モバイルは RevenueCat と併存させ、Entitlement を自DBで統合する設計(§7)の方が素直です。
逆に、「iOS × Android × Web の3面で同じ"Pro"を売る」瞬間に、RevenueCat の費用対効果は跳ね上がります。
2. 中核メンタルモデル:Product → Entitlement / Offering → Package
ここを曖昧にしたまま実装すると、後から必ず「どこに真実があるのか」が分からなくなります。公式は購入の意味をこう定義しています——「User purchases a Product → Unlocks an Entitlement → You check the entitlement to grant access.」
Project(プロジェクト=ダッシュボードの最上位。v2 API の project_id はここ)
├─ App(iOS / Android / Web … プラットフォームごと)
├─ Product … ストアの実SKU(com.example.pro.monthly)
│ │ RevenueCat と Apple/Google を繋ぐ結合キー
│ └─(多対多)─ Entitlement … 「pro」=アクセス権。★アプリが見るのはこれだけ★
└─ Offering … 「いま出すペイウォールの中身」=リモート設定
└─ Package … 同じ商用単位の iOS/Android/Web 版を1つに束ねた箱
($rc_monthly / $rc_annual …)
Entitlement:アプリ側が知っていい唯一の語彙
公式の定義は 「RevenueCat Entitlements represent a level of access, features, or content that a user is 'entitled' to.」 です。
粒度の指針も公式が明示しています——「Most apps only have one entitlement, unlocking all premium features.」。ティアが本当に分かれている(公式の例:ナビアプリの pro +地図リージョンごとの権利)ときだけ増やす。悩んだら1つで始めるのが正解です。
設計上の要点:アプリのコードに商品ID(com.example.pro.monthly)を一切登場させない。 見ていいのは entitlements.active["pro"] だけ。こうしておくと、料金改定・年額プラン追加・地域別SKUの追加がアプリのリリースなしでできます。逆に商品IDで分岐を書いた瞬間、価格変更のたびにストア審査が必要な体になります。
公式が名指しする3つの事故
-
商品を Entitlement に紐づけ忘れる
「Failing to add your products to an entitlement, could lead to your users making purchases that don't unlock access to the promised content.」
年額プランを追加してストア審査も通したのに、
proへのアタッチを忘れる。ユーザーは課金され、機能は開かない——最悪の事故です。商品追加のチェックリストに「Entitlement へのアタッチ」を必ず入れてください。 -
消耗型を Entitlement に紐づける サブスクは期間中だけ権利が有効ですが、非消耗型・消耗型を Entitlement に紐づけるとその権利は永久に有効になります。ゲームの「ライフ10個」のような消耗型を Entitlement に付けてはいけません(残高管理は別の仕組みで行う)。
-
運用中に商品をデタッチする Entitlement への商品の付け外しは、過去に購入した全顧客に遡って効きます。運用中のデタッチは「昨日まで使えていた人のアクセスが消える」操作だと理解して触ってください。
Offering:ストア審査を通さずに価格と訴求を変える箱
Offering は「いまそのユーザーに見せる商品の組み合わせ」です。使うこと自体は任意ですが、Paywalls / Experiments / Targeting を使うなら Offering が必須です。
- Package は「同じ商用単位の各ストア版」を束ねたもの。
$rc_monthlyを1つ取れば、iOSでは iOS のSKU、Androidでは Android のSKUが解決されます。アプリ側のコードがPlatform.OSで分岐しなくて済むのはこの構造のおかげです。 - Offering の Identifier は作成後に変更できません。
default/holiday_2026のように、あとで意味が変わらない名前を付けてください。 currentをハードコードで置き換えない。実験(§12)は「currentOffering を読む」ことを前提に動くため、識別子を直書きすると実験が静かに機能しなくなります。
3. セットアップ:APIキーの境界を最初に決める
RevenueCat のキーは公開鍵(クライアント用)と秘密鍵(サーバー用)で役割が完全に分かれています。ここを混同すると、アプリのバイナリから抽出した鍵で他人の購読データを操作できる、という致命的な事故になります。
| キー | 置く場所 | できること |
|---|---|---|
| Public SDK key(アプリごと・プラットフォーム別) | アプリのバイナリ | 自分の購読状態の取得・購入 |
Secret key(sk_ 始まり・プロジェクト単位) | サーバーの環境変数のみ | 全顧客の参照・権利の付与/剥奪・返金 |
OAuth access token(atk_ 始まり) | サーバー(開発者単位・プロジェクト横断) | 同上(レート制限は開発者単位で共有) |
| Test Store key | ローカル開発のみ | 疑似購入 |
公式の最も強い警告のひとつがここです——「You must NEVER submit an app to the App Store or Google Play that is configured with a Test Store API key.」
秘密鍵は「サーバーからしか読まれない」ことを型で担保しておくと安全です。
// lib/revenuecat/env.server.ts — サーバー専用。クライアントから import された時点で落とす。
import "server-only";
import { z } from "zod";
/**
* 秘密情報の境界を1ファイルに閉じ込める(SRP)。
* 起動時に一度だけ検証し、未設定のままデプロイされる事故を実行時ではなく初期化時に落とす。
*/
const ServerEnvSchema = z.object({
/** REST API v2 のシークレットキー(`sk_` 始まり)。絶対にクライアントへ渡さない。 */
REVENUECAT_SECRET_KEY: z.string().startsWith("sk_"),
/** Webhook の Authorization ヘッダに設定した共有シークレット。 */
REVENUECAT_WEBHOOK_SECRET: z.string().min(16),
/** HMAC 署名検証用の署名シークレット(作成/ローテート時に一度しか表示されない)。 */
REVENUECAT_WEBHOOK_SIGNING_SECRET: z.string().min(16),
/** v2 API のパス要素。ダッシュボードの Project settings に表示される。 */
REVENUECAT_PROJECT_ID: z.string().min(1),
});
export const serverEnv = ServerEnvSchema.parse(process.env);
SDK の初期化(公式サンプルに準拠)
// Swift(iOS)— アプリ起動直後に一度だけ
import RevenueCat
Purchases.logLevel = .debug
Purchases.configure(withAPIKey: <public_apple_api_key>, appUserID: <app_user_id>)
// Kotlin(Android)— Application.onCreate で一度だけ
class MainApplication: Application() {
override fun onCreate() {
super.onCreate()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(PurchasesConfiguration.Builder(this, <public_google_api_key>).build())
}
}
// React Native / Expo
Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG);
Purchases.configure({ apiKey: <public_apple_api_key> }); // iOS
Purchases.configure({ apiKey: <public_google_api_key> }); // Android
logLevel を .debug にすると、購入・レシート・キャッシュの流れがすべてログに出ます。課金の不具合調査で最初に見るのはここです(本番ビルドでは絞る)。
Expo を使う人が最初にハマる罠:Expo Go では RevenueCat は動きません。 Expo Go はモックAPI(Preview API Mode)に置き換えるため、エラーも出ないまま購入が起きないという最悪の失敗の仕方をします。実SDKを動かすには EAS の development build が必要です。インストール後はネイティブビルドが必須で、ホットリロードでは
Invariant Violationになります。Android の注意:
getOfferings()をApplication.onCreateで呼んではいけません(プッシュ通知などのイベントで余計なネットワークリクエストが走る)。SDKが自前でキャッシュを暖めます。
型安全なラッパを1枚挟む(この記事の推奨)
SDKをアプリ全体から直接叩くと、"pro" という文字列が20か所に散ります。Entitlement ID を単一の真実源にして、SDKへの依存を1ファイルに閉じ込めます。
// lib/billing/entitlements.ts — アプリ全体で唯一 Entitlement ID を知っている場所(DRY)
export const ENTITLEMENTS = {
/** 全プレミアム機能。公式の推奨どおり、まずは1つだけで始める。 */
pro: "pro",
} as const;
export type EntitlementId = (typeof ENTITLEMENTS)[keyof typeof ENTITLEMENTS];
// lib/billing/client.ts — RevenueCat SDK への依存をここだけに閉じる(ETC / SRP)
import Purchases, {
PURCHASES_ERROR_CODE,
type CustomerInfo,
type PurchasesPackage,
} from "react-native-purchases";
import { type EntitlementId } from "./entitlements";
/** 購入の結果を、UIがそのまま分岐できる語彙に畳む。例外でフロー制御しない。 */
export type PurchaseOutcome =
| { readonly status: "granted"; readonly customerInfo: CustomerInfo }
| { readonly status: "pending" } // 承認待ち(ファミリー共有の承認要求・保留中トランザクション)
| { readonly status: "cancelled" }
| { readonly status: "failed"; readonly code: string; readonly message: string };
/**
* 端末が持つキャッシュ済み情報での判定。UIの出し分け専用(権威ではない)。
* 注意: 一度も購入していないユーザーの CustomerInfo は空。
* ダッシュボードで定義した Entitlement は、トランザクションが同期されるまで現れない。
*/
export function hasEntitlement(info: CustomerInfo, id: EntitlementId): boolean {
return info.entitlements.active[id] !== undefined;
}
export async function purchase(
pkg: PurchasesPackage,
required: EntitlementId,
): Promise<PurchaseOutcome> {
try {
const { customerInfo } = await Purchases.purchasePackage({ aPackage: pkg });
return hasEntitlement(customerInfo, required)
? { status: "granted", customerInfo }
: { status: "pending" };
} catch (error: unknown) {
// SDKのエラーは unknown で受け、コードを取り出すまで型を信用しない(境界での narrowing)
const code =
typeof error === "object" && error !== null && "code" in error
? String((error as { code: unknown }).code)
: "unknown";
if (code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
return { status: "cancelled" };
}
return {
status: "failed",
code,
message: error instanceof Error ? error.message : "purchase failed",
};
}
}
なぜ pending があるのか:購入APIは成功したのに Entitlement が有効にならないケースが実在します(ファミリー共有の購入承認待ち、Google Play の保留中トランザクションなど)。ここを「成功=解放」と書くと、承認前にプレミアム機能が開いてしまいます。「購入した」と「権利が有効になった」は別の事実として扱ってください。
SDK間でAPI名が揃っていません。 これがコピペ事故の最大の原因です。Entitlement の参照ひとつとっても、Swift/Kotlin は
entitlements[id]?.isActive、Flutter はentitlements.all[id]、JS/TS はentitlements.active[id]の存在確認、と形が違います。他プラットフォームのサンプルをそのまま持ち込まないでください。
4. 購入フロー:Offering を取り、Package を売り、Entitlement を確認する
公式サンプルの購入コードは3プラットフォームとも「購入 → その場で Entitlement を見る」という同じ形です。
// Swift
Purchases.shared.purchase(package: package) { (transaction, customerInfo, error, userCancelled) in
if customerInfo.entitlements["your_entitlement_id"]?.isActive == true {
// Unlock that great "pro" content
}
}
// Kotlin
Purchases.sharedInstance.purchaseWith(
PurchaseParams.Builder(this, aPackage).build(),
onError = { error, userCancelled -> /* No purchase */ },
onSuccess = { storeTransaction, customerInfo ->
if (customerInfo.entitlements["my_entitlement_identifier"]?.isActive == true) {
// Unlock that great "pro" content
}
}
)
// React Native / TypeScript
try {
const purchaseResult = await Purchases.purchasePackage({ aPackage: packageToBuy });
if (typeof purchaseResult.customerInfo.entitlements.active['my_entitlement_identifier'] !== "undefined") {
// Unlock that great "pro" content
}
} catch (error: any) {
if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
// Purchase cancelled
} else {
// Error making purchase
}
}
トランザクションの完了(iOSの finish、Androidの acknowledge / consume)は RevenueCat が自動で行います。ここを自前でやると二重完了や未確認トランザクションによる自動返金を招くので、既存実装からの移行時(§14)以外では触らないでください。
Offering の取得側は、current を素直に使うのが原則です。
// 現在の Offering を取り、Package を UI に流す
const offerings = await Purchases.getOfferings();
const current = offerings.current; // 条件に合致しなければダッシュボードの Default Offering
const monthly = current?.monthly; // $rc_monthly に対応(無ければ undefined)
const all = current?.availablePackages ?? []; // 表示順はダッシュボード側で制御できる
current を使うということは、「どのプランをいくらで売るか」の決定権をアプリのバイナリから剥がして、ダッシュボードに置くということです。これが Offering を使う最大の実利です。
5. App User ID:ここだけは後から直せない
課金実装で最も高くつく設計ミスは、暗号でもWebhookでもなく「ユーザーIDの決め方」です。RevenueCat の App User ID は、公式いわく 「a source of truth for the subscription status of the customer across different devices and platforms」。ここを間違えると、購読が別人のものになるか、二度と復元できなくなります。
匿名IDと logIn
SDKは初期化時に匿名ID($RCAnonymousID:...)を自動生成します。あとからログインすると、その匿名IDは新しいIDのエイリアスとして束ねられます。
// React Native — 自前の認証が確定したタイミングで一度だけ
const { customerInfo, created } = await Purchases.logIn(userId);
// created === true なら RevenueCat 側で新規に作られた ID
// Swift
Purchases.shared.logIn(<my_app_user_id>) { (customerInfo, created, error) in
// customerInfo updated for my_app_user_id
}
公式がブロックするID・使ってはいけないID
RevenueCat は次の値を App User ID として弾きます:'no_user', 'null', 'none', 'nil', '(null)', 'NaN', NULL文字, 空文字, 'unidentified', 'undefined', 'unknown', 'anonymous', 'guest', '-1', '0', '[]', '{}', '[object Object]'、および / を含む文字列。
弾かれないが使ってはいけないのは メールアドレス・電話番号などのPII です。理由は3つ:(1) Webhookペイロードや外部連携先に伝播する、(2) ユーザーがメールを変えると購読が迷子になる、(3) 削除要求(GDPR/APPI)への対応コストが跳ね上がる。不変で意味を持たない識別子——Supabase の auth.users.id のようなUUID——を使ってください。
// lib/billing/identity.ts
import Purchases from "react-native-purchases";
/**
* RevenueCat の App User ID は「不変・非PII・アプリ側の権威ID」で固定する。
* Supabase の user.id(UUID)はこの3条件を満たす。
*/
export async function syncIdentity(supabaseUserId: string | null): Promise<void> {
if (supabaseUserId === null) {
// 公式の指針:カスタムIDのみを使う設計なら logOut は呼ばず、次の logIn で切り替える。
// logOut は新しい匿名IDを生成し、キャッシュもクリアするため、
// 購読の帰属が匿名側に寄る事故の温床になる。
return;
}
await Purchases.logIn(supabaseUserId);
}
サーバー側でユーザーを探すときの注意:1人の顧客は複数のIDを持ち得ます。Webhookペイロードの
app_user_id(最後に見たID)・original_app_user_id(最初のID)・aliases(過去すべて)は別物で、公式は「original_app_user_idとaliasesの両方を検索せよ」と明示しています。自前UUIDを早期にlogInしていれば大半は一致しますが、匿名購入→後からログインという経路が存在する限り、照合ロジックは3つ全部を見るべきです。
Google Play 固有の罠:
obfuscatedExternalAccountIdを App User ID として使う設定は、RevenueCat SDK 利用時には罠です。SDKがこのフィールドにハッシュ化した App User ID を入れるため、意図しない上書きが起きます。既定の「匿名 App User ID を使う」のままにしてください。
復元(restore)と syncPurchases の違い
| API | いつ呼ぶ | 注意 |
|---|---|---|
restorePurchases() | ユーザーが「復元」ボタンを押したときだけ | OSのサインインダイアログが出るため、自動実行してはいけない |
syncPurchases() | 既存課金からの移行時など、プログラム的に同期したいとき | 公式が 「匿名ユーザーを移管・エイリアス化してしまうリスクがある」 と明記 |
消耗型・非更新型の購入はストアのレシートに残らないため、カスタムApp User IDの仕組みなしには復元できません。「ログイン必須にするか」という製品判断は、実は課金の復元可能性の判断でもあります。さらに Google Play Billing Library 8 世代(purchases-android 9.0.0+ / react-native-purchases 9.0.0+ など)では、消費済みの一回限りの購入はクエリできなくなったため復元できません。
購入が既に他のユーザーに紐づいていた場合の挙動は、プロジェクト設定の Restore Behavior(転送挙動) が決めます。「1つのストアアカウントを家族で共有している」「機種変更で別アカウントに入った」といった現実に直結する設定なので、実装前にプロダクトの方針として決めてください(後から変えると過去の帰属が変わります)。
6. ストア接続:実際に出荷を止めるのはコードではなくここ
RevenueCat の実装で最も多い「動かない」は、SDKのコードではなくストア側の資格情報が原因です。しかもどれも静かに失敗します。
Apple
- In-App Purchase Key(
.p8)が最重要。Purchases v5.x 以降、これが無いとトランザクションが記録されません。歴史的には必須ではなかったため、アップグレードしたプロジェクトは「設定が揃って見えるのに記録されない」状態になり得ます。iOS v5 への移行では、コードだけ直してダッシュボードを触り忘れるのが典型的な失敗です。 - In-App Purchase Key と App Store Connect API Key は別物です(同じ Integrations タブの別セクション)。ただし Issuer ID は共通で、App Store Connect API キーが1つ以上ないと Issuer ID が表示されません。
.p8はどちらも1回しかダウンロードできず、失効させた In-App Purchase Key は復活できません。 発行時に安全な場所へ保管してください。- App Store Server Notifications は必須ではありません(RevenueCat は無くても動く)。ただし返金コントロールや価格変更の自動検知には必要で、イベント到達の速さにも効きます(§8)。
- Apple は環境ごとに通知URLを1つしか設定できません。RevenueCat に向け、自社サーバーへは RevenueCat の転送機能で届けるのが公式の推奨で、逆向き(自社→RevenueCat)は明確に非推奨です。
- 既存アプリに In-App Purchase Key を後から追加すると、過去データが書き換わります(推定していた国・通貨・価格がAppleの実値に置換される)。チャートが変わっても不具合ではありません。
Google Play
- サービスアカウント資格情報の検証に最大36時間かかります。 それまで購入は「Invalid Play Store credentials」で失敗し続けます。RTDN の設定はこの36時間を待ってから行ってください。ここを知らないと「実装が間違っている」と誤診して丸一日溶かします。
- 2024年5月3日以降に作られた Google Cloud 組織は、
iam.disableServiceAccountCreation/iam.disableServiceAccountKeyCreation/ Domain Restricted Sharing の3つの組織ポリシーが既定でONになっており、この手順を素で塞ぎます。管理者権限が要る作業なので、着手前に確認してください。 - RTDN の手順は Google 公式ドキュメントと順序が逆です。RevenueCat では Pub/Sub トピックを自分で作らず、RevenueCat が生成したトピックIDを Play Console に貼り付けます。
Web を混ぜる場合(RevenueCat Billing / Stripe)
Web決済を同じ Entitlement に統合できます。ただし名称が動いており、**「RevenueCat Billing(旧 Web Billing)」**が現在の呼び名です。日本で売る場合の実務的な制約:
- RevenueCat Billing は JPY 対応(最低価格 ¥99)。ただし決済手段はカード / Apple Pay / Google Pay のみ。
- コンビニ決済(konbini)は非対応です。銀行振込・口座振替を含む「非同期のオフセッション決済」全般が対象外。日本のEC感覚で「コンビニ払いも」と考えているなら、ここは設計の前提から外してください。
7. アクセス権の真実源をどこに置くか(この記事の核心)
ここが、公式ドキュメントを読んだだけでは組み立てにくい部分です。まずクライアント側の性質を、公式の記述で正確に押さえます。
- SDKは CustomerInfo をキャッシュする。「The SDK caches the user's subscription information to reduce your app's reliance on the network.」
- キャッシュは5分より古ければ更新されるが、それは
getCustomerInfo()の呼び出し・購入・復元のときだけ。 - 決定的な一文:「CustomerInfo updates are not pushed to your app from the RevenueCat backend, updates can only happen from an outbound network request to RevenueCat.」
- SDKの外から購読状態が必要なとき(=あなたのバックエンド)は、REST API を使うよう公式が案内している。
つまり——クライアントの CustomerInfo は「速くて、たいてい正しい、少し古いかもしれないキャッシュ」。UIの出し分けには理想的で、サーバー資源の認可には不適格です。
三層モデル
[1] クライアント(CustomerInfo)
用途: UIの出し分け(ボタンを出す/隠す、ペイウォールを出す)
性質: 5分キャッシュ・オフラインでも動く・改ざんされ得る
↓(信じない)
[2] あなたのDB(user_entitlements テーブル)
用途: サーバー機能の認可の真実源。API/RLS/ジョブはここだけを見る
更新: RevenueCat Webhook を起点に同期(§8・§9)
↓(ズレたら / 取りこぼしたら)
[3] RevenueCat REST API v2
用途: 正規状態の取得・照合・復旧・サポート対応(§10)
「なぜ [2] が要るのか。毎回 [3] を叩けばいいのでは?」——外部APIをリクエストパスに置くと、RevenueCat の障害があなたのAPIの障害になり、レイテンシとレート制限(Customer Information は 480 req/min)が効いてきます。自DBに寄せておけば、認可判定はローカルの1クエリで済み、外部依存はデータ更新経路にだけ残ります。これは Stripe の本番実装で私が採った設計と同一の思想です(真実源はイベント、DBはその射影)。
8. Webhook を安全に受ける
8.1 公式が保証していること・していないこと
| 項目 | 公式の記述 |
|---|---|
| 認証(基本) | ダッシュボードで Authorization ヘッダを設定でき、毎リクエストに付与される。受信側で毎回検証することが推奨 |
| 認証(強) | HMAC 署名。ただし既定では無効で、連携ごとに「HMAC webhook signing」を明示的に有効化すると X-RevenueCat-Webhook-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex> が付く |
| 成功条件 | HTTP 200 のみ。「Any other status code will be considered a failure」——202 や 204 も失敗扱い |
| タイムアウト | 60秒。超えると切断され、リトライ予算を消費する |
| リトライ | 最大5回、間隔 5 / 10 / 20 / 40 / 80分=初回から合計およそ2時間35分で打ち切り |
| 重複 | at-least-once。再送は同じ event.id と同じ event_timestamp_ms を使う |
| 順序 | 公式に記述なし(=順不同で届く前提で設計する) |
| 到達の速さ | 多くは5〜60秒。CANCELLATION は2時間程度かかることがあり、EXPIRATION はストア側通知(§6)未設定だと1時間ほど遅れ得る |
| 前方互換 | api_version を上げずに新フィールド・新イベント型を追加し得る。削除は行わない。パーサは未知の型・未知のフィールドを許容しなければならない |
「5回・約2時間35分で打ち切り」は設計上とても重要です。 デプロイ失敗でエンドポイントが3時間落ちれば、その間のイベントは永久に失われます(ダッシュボードから手動再送はできます)。だから §10 の照合経路が必要になります。
8.2 受信ペイロード(公式サンプルの実物)
{
"event": {
"type": "INITIAL_PURCHASE",
"id": "12345678-1234-1234-1234-123456789012",
"app_id": "1234567890",
"event_timestamp_ms": 1658726378679,
"app_user_id": "1234567890",
"original_app_user_id": "$RCAnonymousID:87c6049c58069238dce29853916d624c",
"aliases": ["$RCAnonymousID:8069238d6049ce87cc529853916d624c"],
"product_id": "com.subscription.weekly",
"entitlement_ids": ["pro"],
"period_type": "NORMAL",
"purchased_at_ms": 1658726374000,
"expiration_at_ms": 1659331174000,
"store": "APP_STORE",
"environment": "PRODUCTION",
"is_family_share": false,
"country_code": "US",
"currency": "USD",
"price": 4.99,
"tax_percentage": 0.0,
"commission_percentage": 0.3,
"subscriber_attributes": {
"$email": { "updated_at_ms": 1662955084635, "value": "firstlast@gmail.com" }
}
},
"api_version": "1.0"
}
読み解くときの注意点を4つ。
subscriber_attributesに$emailが入り得ます。 つまり Webhookペイロードは PII を含み得る。生ペイロードをそのままログやエラートラッカー(Sentry等)に流すと、あなたはメールアドレスを意図せず第三者に送信することになります。保存・記録の前に墨消しするのが既定です。- フィールドの「存在」と「null」は別物。公式の表で "Always" は「キーは必ずあるが値はnullかもしれない」、"Sometimes" は「キーごと無いかもしれない」を意味します。
store/currency/price/cancel_reasonなどは後者——optional であって nullable ではないので、スキーマもそう書きます。 - 非推奨フィールドがまだ流れてきます:
entitlement_id(単数)→entitlement_idsを使う、takehome_percentage→tax_percentage+commission_percentageを使う。 - Google Play の
product_idは複合形式:2023年2月以降に RevenueCat 側で設定した商品は<subscription_id>:<base_plan_id>の形で届きます。素の subscription id で引くとバックエンドの商品照合が静かに外れます。
8.3 実装:HMAC 検証・冪等化・順序保証(Next.js 16 Route Handler)
先に前提を1つ。HMAC 署名は既定では飛んできません——ダッシュボードの当該 Webhook 連携で「HMAC webhook signing」を有効化して初めて X-RevenueCat-Webhook-Signature が付きます。署名シークレットは作成/ローテート時に一度しか表示されず、ローテートは旧シークレットを即時無効化します(重複期間は公式に用意されていないので、切り替えは瞬断を伴う前提で計画してください)。有効化していない場合は、下のコードから署名検証を外し、Authorization ヘッダのみで運用します(その分、防御は弱くなります)。
HMAC は生のリクエストボディに対して計算されます。 パース済みオブジェクトを再シリアライズすると検証は必ず失敗するので、request.text() で生文字列を取り、検証してからパースするのが唯一の正しい順序です。
// app/api/revenuecat/webhook/route.ts
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { z } from "zod";
import { serverEnv } from "@/lib/revenuecat/env.server";
import { applyMutation } from "@/lib/billing/apply-mutation";
import { decide } from "@/lib/billing/decide";
/**
* RevenueCat Webhook 受信口。
* 設計方針:
* - 受け取るものには寛容、使うものには厳格(未知フィールド・未知イベント型で 4xx を返さない)
* - 成功は HTTP 200 のみ。202/204 は「失敗」と見なされリトライ予算を焼く
* - 冪等性は event.id、順序保証は event_timestamp_ms(どちらもDBの制約で担保)
*/
/** 依存するフィールドだけを検証する。増えるフィールドで壊れないのが目的(passthrough)。 */
const EventSchema = z
.object({
id: z.string().min(1),
type: z.string().min(1),
event_timestamp_ms: z.number().int().nonnegative(),
// "Sometimes" フィールドは optional。null 許容とは意味が違う。
app_user_id: z.string().min(1).optional(),
original_app_user_id: z.string().min(1).optional(),
aliases: z.array(z.string()).optional(),
entitlement_ids: z.array(z.string()).nullish(),
expiration_at_ms: z.number().int().nullish(),
cancel_reason: z.string().nullish(),
environment: z.string().optional(),
store: z.string().optional(),
transferred_from: z.array(z.string()).optional(),
transferred_to: z.array(z.string()).optional(),
})
.passthrough();
const PayloadSchema = z.object({ api_version: z.string(), event: EventSchema });
/** 壊れた JSON で例外を投げず、「不正な入力」として Zod に判定させるための薄い包み。 */
function safeJsonParse(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
/** 人間に回すためのアラート。issue の形だけを送り、生ペイロードは絶対に載せない(§8.2)。 */
function reportMalformedWebhook(error: z.ZodError): void {
console.error("[revenuecat] malformed webhook", {
issues: error.issues.map((i) => ({ path: i.path, code: i.code })),
});
}
/** 長さの違いを漏らさないため、両者をハッシュしてから固定長で比較する。 */
function safeEqual(a: string, b: string): boolean {
const digest = (v: string): Buffer => createHash("sha256").update(v, "utf8").digest();
return timingSafeEqual(digest(a), digest(b));
}
/** `t=<unix>,v1=<hex>` を解析し、HMAC-SHA256("<t>.<rawBody>") と突き合わせる。 */
function hasValidSignature(rawBody: string, header: string | null): boolean {
if (header === null) return false;
const parts = new Map(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()] as const;
}),
);
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (timestamp === undefined || signature === undefined) return false;
// 署名は「誰が送ったか」しか保証しない。盗聴した正規リクエストをそのまま投げ直す
// リプレイを閉じるには時刻の窓が要る。公式の検証手順とリファレンス実装も許容差 300 秒。
// ただしこの判定は受信側の時計を信頼する。NTP が壊れて 300 秒ずれると正規の配信が
// 全て 401 になり、§8.1 のリトライを使い切って永久に失われる——時刻同期は前提条件。
const skewSec = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skewSec) || skewSec > 300) return false;
const expected = createHmac("sha256", serverEnv.REVENUECAT_WEBHOOK_SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
return safeEqual(expected, signature);
}
export async function POST(request: Request): Promise<Response> {
// ① 生ボディを最初に確保する。JSON.parse より前でなければ HMAC は成立しない。
const rawBody = await request.text();
const authorized =
safeEqual(request.headers.get("authorization") ?? "", serverEnv.REVENUECAT_WEBHOOK_SECRET) &&
hasValidSignature(rawBody, request.headers.get("x-revenuecat-webhook-signature"));
if (!authorized) {
// 攻撃者に「どちらが不一致か」を教えない。リトライさせても意味がないので 401 のまま。
return new Response("unauthorized", { status: 401 });
}
const parsed = PayloadSchema.safeParse(safeJsonParse(rawBody));
if (!parsed.success) {
// 壊れたペイロードを 5xx で返すと約2時間半リトライを浴び続ける。
// 自分では直せない入力は 200 で受理し、アラートで人間に回す。
reportMalformedWebhook(parsed.error);
return new Response(null, { status: 200 });
}
const { event } = parsed.data;
const mutation = decide(event); // 純粋関数(§9)。DBもネットワークも触らない
await applyMutation(event, mutation);
// ★ 200 以外を返してはいけない。202/204 は「失敗」と見なされリトライされる。
return new Response(null, { status: 200 });
}
DBスキーマは、冪等性と順序保証をアプリではなく制約で表現します。
-- 受信済みイベント:主キーが再送を構造的に排除する
-- (再送は同じ event.id で来るため、これだけで重複処理はゼロになる)
create table revenuecat_events (
id text primary key, -- event.id
type text not null,
app_user_id text,
event_timestamp_ms bigint not null,
received_at timestamptz not null default now()
);
-- アクセス権の射影:認可はこの1テーブルだけを見る
create table user_entitlements (
user_id text not null,
entitlement_id text not null,
expires_at timestamptz, -- null = 無期限(買い切り)
last_event_timestamp_ms bigint not null, -- 順序逆転の防波堤
store text,
updated_at timestamptz not null default now(),
primary key (user_id, entitlement_id)
);
-- 反映:古いイベントが後から届いても、WHERE 句が UPDATE ごと無効化する。
-- アプリ側に「この更新は適用していいか」の分岐を書かなくて済む=取りこぼしが起きない。
insert into user_entitlements
(user_id, entitlement_id, expires_at, last_event_timestamp_ms, store)
values ($1, $2, $3, $4, $5)
on conflict (user_id, entitlement_id) do update
set expires_at = excluded.expires_at,
last_event_timestamp_ms = excluded.last_event_timestamp_ms,
store = excluded.store,
updated_at = now()
where user_entitlements.last_event_timestamp_ms < excluded.last_event_timestamp_ms;
-- 認可の判定は、常にこの形(イベント種別ではなく「期限」で判断する)
select 1
from user_entitlements
where user_id = $1
and entitlement_id = $2
and (expires_at is null or expires_at > now());
event_timestamp_msは再送時も同じ値です。だから順序比較(<)には使えても、「いつ受け取ったか」の時計としては使えません。受信時刻は自分でreceived_atに打ってください。
9. イベント種別で分岐しない——「期限」に畳む
26種類のイベントに switch を書いて付与/剥奪を判断すると、RevenueCat が新しいイベント型を追加した日にあなたの課金が壊れます。公式自身が「api_version を上げずに新イベント型を追加し得る」と宣言しているのだから、これは"もし"ではなく"いつ"の問題です。
代わりに、イベントを 「権利の状態を運んでいるか」 の3分類だけに畳み、実際の可否は expires_at で判定します。
// lib/billing/decide.ts — 純粋関数。DBもネットワークも時計も触らないのでテストが容易。
/** 公式の Event Types 一覧(2026-08-06 参照)。増えたらここに足す。 */
export type KnownEventType =
| "TEST" | "INITIAL_PURCHASE" | "RENEWAL" | "CANCELLATION" | "UNCANCELLATION"
| "NON_RENEWING_PURCHASE" | "SUBSCRIPTION_PAUSED" | "EXPIRATION" | "BILLING_ISSUE"
| "PRODUCT_CHANGE" | "SUBSCRIPTION_EXTENDED" | "REFUND_REVERSED" | "INVOICE_ISSUANCE"
| "TRANSFER" | "TEMPORARY_ENTITLEMENT_GRANT" | "VIRTUAL_CURRENCY_TRANSACTION"
| "EXPERIMENT_ENROLLMENT" | "PURCHASE_REDEEMED" | "PAYWALL_IMPRESSION" | "PAYWALL_CLOSE"
| "PAYWALL_CANCEL" | "PAYWALL_EXIT_OFFER" | "PAYWALL_COMPONENT_INTERACTED"
| "SUBSCRIBER_ALIAS" | "PRICE_INCREASE_CONSENT_REQUIRED" | "PRICE_INCREASE_CONSENT_APPROVED";
type Handling =
| "entitlement" // entitlement_ids と expiration_at_ms が権利の現在値を運ぶ
| "transfer" // 権利の持ち主が入れ替わる
| "observe"; // 分析用。アクセス権は動かさない
/**
* `satisfies` で全イベント型の分類漏れをコンパイルエラーにする。
* KnownEventType に1つ足したら、ここを埋めるまでビルドが通らない。
*/
const EVENT_HANDLING = {
INITIAL_PURCHASE: "entitlement",
RENEWAL: "entitlement",
UNCANCELLATION: "entitlement",
NON_RENEWING_PURCHASE: "entitlement",
PRODUCT_CHANGE: "entitlement",
SUBSCRIPTION_EXTENDED: "entitlement",
REFUND_REVERSED: "entitlement",
TEMPORARY_ENTITLEMENT_GRANT: "entitlement",
EXPIRATION: "entitlement", // ★剥奪はここ。expiration_at_ms が過去になる
SUBSCRIPTION_PAUSED: "entitlement", // ★公式: 一時停止では剥奪しない
BILLING_ISSUE: "entitlement", // ★公式: 請求失敗でも剥奪しない
CANCELLATION: "entitlement", // ★期間満了までは有効。返金だけ別扱い
TRANSFER: "transfer",
SUBSCRIBER_ALIAS: "observe", // 公式で deprecated(新規プロジェクトには来ない)
TEST: "observe",
INVOICE_ISSUANCE: "observe",
VIRTUAL_CURRENCY_TRANSACTION: "observe",
EXPERIMENT_ENROLLMENT: "observe",
PURCHASE_REDEEMED: "observe",
PAYWALL_IMPRESSION: "observe",
PAYWALL_CLOSE: "observe",
PAYWALL_CANCEL: "observe",
PAYWALL_EXIT_OFFER: "observe",
PAYWALL_COMPONENT_INTERACTED: "observe",
PRICE_INCREASE_CONSENT_REQUIRED: "observe",
PRICE_INCREASE_CONSENT_APPROVED: "observe",
} as const satisfies Record<KnownEventType, Handling>;
const isKnown = (type: string): type is KnownEventType => type in EVENT_HANDLING;
export type AccessMutation =
| {
readonly kind: "upsert";
readonly appUserId: string;
readonly entitlementIds: readonly string[];
/** null は「期限なし」=買い切り。過去日時ならその時点で失効。 */
readonly expiresAtMs: number | null;
}
| { readonly kind: "revokeNow"; readonly appUserId: string; readonly entitlementIds: readonly string[] }
| { readonly kind: "transfer"; readonly from: readonly string[]; readonly to: readonly string[] }
| { readonly kind: "noop"; readonly reason: string };
export interface RevenueCatEvent {
readonly type: string;
readonly app_user_id?: string;
readonly entitlement_ids?: readonly string[] | null;
readonly expiration_at_ms?: number | null;
readonly cancel_reason?: string | null;
readonly transferred_from?: readonly string[];
readonly transferred_to?: readonly string[];
}
export function decide(event: RevenueCatEvent): AccessMutation {
// 未知のイベント型は「観測のみ」に落とす。提供側の機能追加を自分の障害にしない。
// (ペイウォール系イベントの type 文字列は連携ごとに変更可能なので、ここは必ず通る)
if (!isKnown(event.type)) return { kind: "noop", reason: `unknown type: ${event.type}` };
switch (EVENT_HANDLING[event.type]) {
case "transfer":
// TRANSFER は移管「先」にしか届かない。
// transferred_from の権利を落とさないと、旧ユーザーに権利が残り続ける。
return {
kind: "transfer",
from: event.transferred_from ?? [],
to: event.transferred_to ?? [],
};
case "entitlement": {
const appUserId = event.app_user_id;
const entitlementIds = event.entitlement_ids ?? [];
if (appUserId === undefined || entitlementIds.length === 0) {
return { kind: "noop", reason: "no entitlement in payload" };
}
// 返金だけは期間満了を待たずに剥奪する。
// 公式の cancel_reason 定義では CUSTOMER_SUPPORT が「返金された」を意味する。
if (event.type === "CANCELLATION" && event.cancel_reason === "CUSTOMER_SUPPORT") {
return { kind: "revokeNow", appUserId, entitlementIds };
}
return { kind: "upsert", appUserId, entitlementIds, expiresAtMs: event.expiration_at_ms ?? null };
}
case "observe":
return { kind: "noop", reason: "analytics-only event" };
}
}
この設計の根拠は公式の定義そのものです。
CANCELLATION= 「A subscription or non-renewing purchase was canceled or refunded」。cancel_reasonはUNSUBSCRIBE/BILLING_ERROR/DEVELOPER_INITIATED/PRICE_INCREASE/CUSTOMER_SUPPORT/UNKNOWN。このうちCUSTOMER_SUPPORTが「Appleサポート等で返金された」 を指します。解約=即剥奪ではありません(ユーザーは払った分の期間を使い切る権利があります)。EXPIRATIONについて公式は明確に 「The associated user's access should be removed.」 と書いています。剥奪の合図はこちらです。expiration_reasonには Google Play のSUBSCRIPTION_PAUSEDを含む7値があります。SUBSCRIPTION_PAUSEDとBILLING_ISSUEでは剥奪しない、と公式が明示しています。一時停止も請求失敗も「まだ切れていない」状態です。実際に切れたらEXPIRATIONが来ます。ここで即座に締め出すのは、Stripeの dunning で「payment_failedで即剥奪してはいけない」のと全く同じ誤りです。TEMPORARY_ENTITLEMENT_GRANTはストア障害時の一時付与(最長24時間)。その後、検証が通れば通常のINITIAL_PURCHASE、通らなければEXPIRATIONが続きます。期限つきで素直に付与しておけば、どちらに転んでも整合します。
9.1 公式の推奨と、この設計の関係(正直な注記)
公式が推奨しているのは、実は**「Webhookのペイロードから状態を再構築するのではなく、受信をトリガーに REST の顧客エンドポイントから正規の状態を取り直して同期する」**という形です。私はこれを否定しません。両者は目的が違います。
| 方式 | 長所 | 短所 | 向いている場面 |
|---|---|---|---|
| ペイロード射影(上のコード) | 追加のAPI呼び出しゼロ・低レイテンシ・レート制限に無縁 | 順序と欠損を自分で守る必要がある | 権利が単純(pro 1つ)・イベント量が多い |
| REST 再取得(公式推奨) | 常に正規の状態。順序を気にしなくてよい | イベントごとに1リクエスト(480 req/min の制約)・外部障害の影響を受ける | 権利が複雑・整合性を最優先 |
実務での最適解は併用です。 ペイロードで即座に射影して体感を速くし、waitUntil などで同じイベントの直後に REST から正規状態を取り直して上書きする。last_event_timestamp_ms のガードがあるので、どちらが先に着地しても壊れません。そして §10 の定期照合が、5回のリトライを使い切って失われたイベントを回収します。「速い経路」と「正しい経路」を分け、正しい方が最後に勝つ——これが決済系で私が一貫して採ってきた構造です。
9.2 テスト:純粋関数だから、DBもネットワークも要らない
// lib/billing/decide.test.ts
import { describe, it, expect } from "vitest";
import { decide } from "./decide";
const base = { app_user_id: "u1", entitlement_ids: ["pro"], expiration_at_ms: 1_800_000_000_000 };
describe("decide", () => {
it("解約(自主)は剥奪せず、満了時刻をそのまま反映する", () => {
expect(decide({ ...base, type: "CANCELLATION", cancel_reason: "UNSUBSCRIBE" })).toEqual({
kind: "upsert", appUserId: "u1", entitlementIds: ["pro"], expiresAtMs: 1_800_000_000_000,
});
});
it("返金(CUSTOMER_SUPPORT)は満了を待たずに即時剥奪する", () => {
expect(decide({ ...base, type: "CANCELLATION", cancel_reason: "CUSTOMER_SUPPORT" })).toEqual({
kind: "revokeNow", appUserId: "u1", entitlementIds: ["pro"],
});
});
it.each(["BILLING_ISSUE", "SUBSCRIPTION_PAUSED"])(
"%s では剥奪しない(公式が明示している禁止事項)",
(type) => {
expect(decide({ ...base, type }).kind).toBe("upsert");
},
);
it("TRANSFER は移管元の権利も落とせるよう from/to を返す", () => {
expect(
decide({ type: "TRANSFER", transferred_from: ["old"], transferred_to: ["new"] }),
).toEqual({ kind: "transfer", from: ["old"], to: ["new"] });
});
it("未知のイベント型でも例外を投げず観測に落ちる(将来の機能追加で壊れない)", () => {
expect(decide({ type: "SOME_FUTURE_EVENT" }).kind).toBe("noop");
});
});
10. REST API v2:照合・復旧の経路
| 項目 | 値 |
|---|---|
| ベースURL | https://api.revenuecat.com/v2 |
| 認証 | Authorization: Bearer <secret key>(Bearer の付与が必須。v1 のキーは v2 では使えない) |
| レート制限 | Customer Information 480 req/min、Charts & Metrics 25、Project Configuration 60、Virtual Currencies 480(エンドポイント単位ではなくドメイン単位で共有) |
| 制限ヘッダ | RevenueCat-Rate-Limit-Current-Usage / RevenueCat-Rate-Limit-Current-Limit(IETFの RateLimit-* ではない)。429 時は Retry-After(秒)とボディの backoff_ms(ミリ秒) |
| ページング | limit(既定20・範囲外はエラーではなくクランプ)+ starting_after の前方向カーソル。next_page は無いときキーごと消える(null ではない) |
| 主要エンドポイント | GET /projects/{project_id}/customers/{customer_id} / .../active_entitlements / .../subscriptions |
// lib/revenuecat/api.server.ts — サーバー専用。429 だけでなく 423 も無視しないクライアント。
import "server-only";
import { serverEnv } from "./env.server";
const BASE_URL = "https://api.revenuecat.com/v2";
export class RetryableApiError extends Error {
constructor(readonly retryAfterMs: number, readonly status: number) {
super(`revenuecat api ${status}; retry after ${retryAfterMs}ms`);
this.name = "RetryableApiError";
}
}
/**
* 単一の HTTP 境界。呼び出し側にステータスコードを漏らさず、
* 「値が返る/時間をおけば直る/恒久的に失敗」の3状態だけを見せる。
*/
async function get<T>(path: string, parse: (json: unknown) => T): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { Authorization: `Bearer ${serverEnv.REVENUECAT_SECRET_KEY}` },
cache: "no-store", // 認可判定に使うので絶対にキャッシュさせない
});
// 429 = レート制限、423 = 同じリソースを別リクエストが更新中(どちらも待てば直る)
if (response.status === 429 || response.status === 423) {
const body: unknown = await response.json().catch(() => null);
const backoffMs =
typeof body === "object" && body !== null && "backoff_ms" in body
? Number((body as { backoff_ms: unknown }).backoff_ms)
: Number(response.headers.get("Retry-After") ?? "60") * 1000;
throw new RetryableApiError(Number.isFinite(backoffMs) ? backoffMs : 60_000, response.status);
}
if (!response.ok) throw new Error(`revenuecat api ${response.status} for ${path}`);
return parse(await response.json());
}
/** 顧客の「いま有効な権利」を正規の状態として取得する。 */
export function fetchActiveEntitlements(appUserId: string) {
const { REVENUECAT_PROJECT_ID } = serverEnv;
// App User ID は URL に載るので必ずエンコードする(`/` は RevenueCat 側で禁止だが防御的に)
return get(
`/projects/${REVENUECAT_PROJECT_ID}/customers/${encodeURIComponent(appUserId)}/active_entitlements`,
(json) => json, // 実際は Zod スキーマで narrow する
);
}
v1 の
GET /subscribers/{app_user_id}を認可パスから呼ばないでください。 これは副作用のない参照ではなく、存在しない App User ID を渡すと顧客を新規作成します(200 と 201 で区別される)。ネット上のサンプルには v1 のこのエンドポイントを使うものが多く残っていますが、リクエストのたびに幽霊顧客を量産することになります。照会は v2 のactive_entitlementsを使ってください。
列挙値の大文字小文字が面で違います。 Webhook の
store/typeは 大文字(APP_STORE/INITIAL_PURCHASE)、REST API v2 のstoreと Webhook 設定時のevent_typesは 小文字(app_store/initial_purchase)です。しかも値の集合も一致しません(v2 のstoreにはexternal/paypal/galaxyがあり、Webhook 側の一覧には無い)。2つの面で同じ TypeScript のユニオン型を共有すると必ず壊れます。 別の型として定義してください。
この経路を持っておくべき理由は §8.1 の「5回・約2時間35分で打ち切り」です。日次で「自DBの有効な権利」と「RevenueCat 側の状態」を突き合わせる照合ジョブを1本用意しておくと、Webhookの取りこぼしが静かに積み上がる事故を防げます。決済で私が一貫して採ってきた「イベント駆動+定期照合」の二段構えと同じ考え方です。
11. Trusted Entitlements:「有効化しただけでは守られない」
端末を細工して RevenueCat との通信に割り込み、自分に権利があると応答させる——公式が想定している MiTM 攻撃です。Trusted Entitlements は、SDKが受け取った権利データの署名を検証する機能です。
公式が明示的に警告しているのはここです:
「Enabling Trusted Entitlements does not automatically protect your app. The SDK provides verification data, but it's your responsibility to check the verification result in your code and decide whether to grant access based on unverified entitlements.」
つまり「オンにした」は対策ではありません。verificationResult を読んで判断するコードを書くまで、何も守られていないのです。
- モード:
EntitlementVerificationMode.disabled/.informational - 既定:iOS 5.15.0+ / Android 8.11.0+ は既定で有効(それ以前の 4.25.0〜5.14.x / 6.6.0〜8.10.x は既定で無効)
- 結果の4値:
notRequested(検証していない)/verified(サーバーで検証済み)/verifiedOnDevice(StoreKit 2 により端末で生成・検証)/failed(MiTMの可能性)
// クライアント側で「検証に失敗した権利」を扱う方針を明示する
import { VERIFICATION_RESULT, type CustomerInfo } from "react-native-purchases";
import { type EntitlementId } from "./entitlements";
/**
* 検証失敗の権利は「無い」ものとして扱う。
* ただしこれはあくまで UI の防衛線であって、サーバー資源の認可は §7 の [2] が行う。
*/
export function hasTrustedEntitlement(info: CustomerInfo, id: EntitlementId): boolean {
const entitlement = info.entitlements.active[id];
if (entitlement === undefined) return false;
return entitlement.verification !== VERIFICATION_RESULT.FAILED;
}
正しい脅威モデル:クライアント側の検証は「端末を細工した個人が自分だけプレミアムを使う」を減らします。しかしあなたのサーバーのお金がかかる処理(生成AIの推論、動画変換、外部API)を守るのはこれではありません。そちらは §7 の三層モデルで、サーバーが自DBを見ることで守ります。クライアント検証とサーバー認可は代替関係ではなく、担当領域が違います。
12. ペイウォール・実験・指標の定義
Offering をリモート設定にした本当の見返りは、**「価格と訴求をストア審査なしで変えられる」**ことです。RevenueCat の Paywalls はその上に乗るUIレイヤーです。
ペイウォールUIは常に2つ目の依存パッケージ(RevenueCatUI / react-native-purchases-ui / purchases_ui_flutter など)である点に注意してください。購入用SDKだけ入れても presentPaywall は生えません。
必要なSDKバージョン(公式記載):purchases-ios 5.27.1+ / purchases-android 8.19.2+ / react-native-purchases 8.11.3+ / purchases-flutter 8.10.1+。対応は iOS 15.0+ / Android 7.0+ / macOS 12.0+ / Web、watchOS・tvOS・visionOS は非対応。複数ページのペイウォールはさらに新しいSDK(iOS 5.83.0 / Android 10.16.0 など)を要求します——エディタで作れてしまうので、古いSDKのまま作ると実機で出ません。
// SwiftUI — 権利が無いときだけペイウォールを出す
.presentPaywallIfNeeded(
requiredEntitlementIdentifier: Constants.ENTITLEMENT_ID,
purchaseCompleted: { customerInfo in ... },
restoreCompleted: { customerInfo in ... }
)
// Android (Compose) — API は現在も Experimental アノテーション付き
PaywallDialog(
PaywallDialogOptions.Builder()
.setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
.setListener(object : PaywallListener { ... })
.build()
)
// React Native
const paywallResult = await RevenueCatUI.presentPaywall();
- Targeting / Placements は「どのユーザーに、どの画面で、どの Offering を見せるか」。Placement は「Offering なし」に設定でき、
nullが返り得ます——強制アンラップするコードはクラッシュします。 - Experiments は Offering のA/B。識別子を直書きすると実験が効きません(
currentを読む前提)。またペイウォール表示より前にユーザーを識別しないと、1人が2台で2人分としてカウントされ、結果が壊れます。 - Customer Center は解約・返金依頼・プラン変更をアプリ内で完結させるUI。Stripe の Customer Portal と同じ思想で自作しないのが正解ですが、機能パリティは平等ではありません——返金申請とプラン変更は iOS のみ、購入履歴は Android 非対応です。
指標の定義を誤読しない
ダッシュボードの数字を経営判断に使うなら、定義は押さえてください。
- 実験の結果はベイズ推定です。RevenueCat が出すのは 「Chance to Win」と95%の信用区間(credible interval) であって、p値でも信頼区間でもありません。「有意差が出た」と言い換えないでください。
- Realized LTV は返金控除後・ストア手数料控除前。手取り(Proceeds)ではありません。
- MRR は期末時点のスナップショットで、トライアル・非継続課金・買い切りは除外されます。
- Active Subscriptions は「解約済みだが未失効」を有効として数え、ファミリー共有の受益者は含みません。
13. テスト:サンドボックスでは時間が速く流れる
Apple のサンドボックスは購読期間を圧縮します。公式の対応表(Apple のサンドボックスアカウント設定が既定の「Renewal every 5 minutes」の場合):
| 本番の購読期間 | サンドボックスでの更新 | TestFlight での更新 |
|---|---|---|
| 3日 | 2分 | 1日 |
| 1週間 | 3分 | 1日 |
| 1か月 | 5分 | 1日 |
| 2か月 | 10分 | 1日 |
| 3か月 | 15分 | 1日 |
| 6か月 | 30分 | 1日 |
| 1年 | 1時間 | 1日 |
自動更新は 1日あたり最大12回で停止します。「月額プランを1か月放置して更新を見る」のではなく、5分待てば更新イベントが飛ぶので、RENEWAL → EXPIRATION の一連の流れを昼休みの間に検証できます。なお Apple はサンドボックスアカウントごとに更新レート(3分/5分/30分/1時間)を選べるため、**この表は「既定値のときの表」**である点に注意してください。TestFlight は2024年12月に挙動が変わり、24時間ごと・1週間で最大6回になっています(古いブログ記事の「数分ごと」は現在の挙動ではありません)。
StoreKit Configuration ファイルの制約も押さえてください。公式いわく「StoreKit testing only works if you are running your app directly through Xcode」で、コマンドラインツールからは認識されません。さらに 「StoreKit testing won't show cancellation or refund events」 ——キャンセル・返金はレシートに現れないため、この経路では検証できません。.storekit の商品は App Store Connect に存在しなくても構いませんが、RevenueCat 側には存在している必要があり、StoreKit の公開証明書のアップロードも必須です。
サンドボックスのイベントは environment: "SANDBOX" で届きます。本番の集計・課金ロジックに混ぜないよう受信段階で分離してください(ダッシュボードの Webhook 設定でも送信対象を選べます)。ただし SANDBOX / PRODUCTION は「トランザクションの属性」であってユーザーの属性ではありません——同じ顧客が両方を持ち得ます。
公式はサンドボックスで確認すべきは「購入のフロー」であって、商品メタデータ(価格・名称)の検証ではないと明記しています。価格表示の確認はストア側の設定で行ってください。
小さいが刺さる制約:RevenueCat は1顧客あたりの App Store 購読レシートを100件で打ち切ります。 サンドボックスで検証を繰り返すと簡単に到達し、症状は汎用的な
UNKNOWNエラーです。対処は当該テスト顧客の削除。ただし顧客を削除しても Apple 側の購入履歴は消えないので、本当に「まっさらな新規ユーザー」を再現するには端末のサンドボックスアカウントもサインアウトして作り直す必要があります。
14. 既存アプリの移行:syncPurchases はバックフィルではない
すでにIAPを実装済みのアプリを載せ替える場合、公式の推奨はサーバー側インポートです。
- 推奨:
POST /receiptsに Apple のレシート/Google の購入トークンを送る。RevenueCat が検証と重複排除を行う。 - クライアント側
syncPurchasesの限界:新しいビルドを開いた人の、端末にあるレシート(iOS)または現在所有中の購入(Android)しか見えません。ストアアカウントの全注文履歴は取得できないので、これは移行のバックフィルにはなりません。 - ⚠️ 公式の警告:「Do not sync or restore on every app launch」。毎起動で呼ぶとレイテンシが増え、意図せず顧客をエイリアス化するリスクがある。呼ぶなら「旧システムでは購読中なのに RevenueCat では未購読」という条件下で1回だけ。
- ⚠️ 移行時に「自分のアプリがトランザクションを完了する」設定にする場合、現在の API は
purchasesAreCompletedBy(旧称 Observer Mode)です。旧SDKでは今も observer mode の名前が使われているため、実コードベースには両方の呼び名が混在します。iOSではこのときstoreKitVersionも併せて指定が必要です。 - ⚠️ Android SDK v9 / Google Play Billing Library 8 以降、
syncPurchasesは有効な購読と未消費の一回限りの購入だけを対象にします。旧挙動を前提にした移行計画は、過去分を静かに落とします。 - ⚠️ Google Play の古いデータには制限があり、Google Historical Import という別経路が用意されています。ただしこれはチャートのバックフィルであってイベントの再生ではありません——連携先へのイベントは飛ばず、請求問題・一部返金・自動更新状態は復元されません。
- インポートした顧客が Customer Lists に出るまで数時間、チャートの反映は24時間程度かかります。
- ⚠️ SDK経由とサーバー経由の同一トランザクションは重複排除されますが、App User ID の名寄せまではしてくれません。サーバー側インポートで使うIDとアプリが
logInするIDが食い違うと、同じ購入が別人に紐づきます。**移行の最初の設計判断は「どのIDで統一するか」**です。
移行は「切り替えの日」ではなく「二重に正しい期間」を設けるのが安全です。旧システムの購読判定と RevenueCat の Entitlement を両方見て OR を取る期間を置き、差分をログに出し、ゼロになってから旧経路を落とす。決済系の移行で最も事故が少ないのはこの手順です。
15. プラン変更と価格改定:抽象化できないストア差はここに集中する
RevenueCat は多くの差異を吸収しますが、プラン変更(アップグレード/ダウングレード)と価格改定だけは、ストアごとの挙動がそのまま表に出ます。ここを「だいたい同じだろう」と実装すると、返金と請求が食い違います。
まず前提:プラン変更は開発者が代行できない
Apple は 「Apple does not allow developers to manage subscriptions on behalf of users.」 と明記しています。Google Play は Console / API からの開発者主導の解約が可能です(プリペイドを除く)。つまりアプリ内に自前の「プラン変更」画面を作っても、最後は各ストアの管理画面に送るしかない。RevenueCat の managementURL は、その顧客が実際に購入したストア(App Store / Google Play / Amazon / RevenueCat Billing / Paddle)の管理画面へ正しくディープリンクしてくれます。自分でストアURLを分岐して組み立てないでください。
アップグレード/ダウングレードの挙動
| Apple | Google Play | RevenueCat Billing | |
|---|---|---|---|
| アップグレード | 即時に切り替わり、元のサブスクの日割り分が返金される | oldProductId を渡す。既定の置換モードは WITHOUT_PRORATION | 即時。未使用分は部分返金でクレジット |
| ダウングレード | 次回更新日まで現プランが継続し、更新時に下位プラン・下位価格へ | DEFERRED が Google の推奨 | 現サイクル末に予約。日割りなし |
| クロスグレード(同レベル) | 期間が同じなら即時、違えば次回更新日から | 置換モードで制御 | — |
Google Play の置換モードは WITHOUT_PRORATION / WITH_TIME_PRORATION / CHARGE_FULL_PRICE / CHARGE_PRORATED_PRICE / DEFERRED の5つ。既定が「日割りなし」であることを知らずにアップグレードを実装すると、ユーザーは残り期間を捨てて満額を払うことになります。
公式が名指しする罠:「When a customer upgrades products during an introductory period (including a free trial), Apple does not cancel the introductory offer but instead keeps the introductory offer active in addition to the upgraded product.」 ——トライアル中にアップグレードすると、Apple は導入オファーを取り消さず、アップグレード後の商品と並存させます。「トライアル中の人がアップグレードしたら課金が始まる」と思って組んだロジックは、ここで前提から外れます。
価格改定とグランドファザリング
- 価格は App Store Connect / Google Play で変更します(RevenueCat 側ではありません)。既存契約者は新価格へのオプトインが必要になる場合があります。
- Apple:対象の契約者にはアプリ内に自動表示されるメッセージシートで通知されます。値上げを避けるなら、既存契約者を旧価格に据え置き(グランドファザリング)、新規のみ新価格にする戦略が公式に案内されています。
- Google Play:価格変更の7日後にメールと Play の通知で告知され、契約者は30日以内に承諾しなければ更新時に解約されます。
- RevenueCat は「購入時点の価格」を記録します。 Apple の価格変更を自動検知させるには App Store Server Notifications V2 と API キーのアップロードが必要です。Google Play では、既存契約者をレガシー価格コホートとして現行価格に残すことが正しいレポーティングのために推奨されています。
- 対応する Webhook イベントは
PRICE_INCREASE_CONSENT_REQUIRED/PRICE_INCREASE_CONSENT_APPROVED、そしてEXPIRATIONのexpiration_reason: PRICE_INCREASEです。§9 の分類でこれらをobserveに置いているのは、価格の同意プロセスはアクセス権を動かさないからです——実際に失効したときはEXPIRATIONが来ます。
16. コストと「撤退可能性」
2026年8月6日時点の公式価格は明快です。
- 月次トラッキング売上(MTR)$2,500 までは無料。
- 超えたら「追跡した額の1%」。ここが誤解されがちですが、1%は「超過分」ではなく MTR 全額にかかります(公式FAQの例:$2.5K MTR なら $25)。
- MTR はストア手数料を引く前の総額(USD建て)で測ります。日本のアプリが月商 ¥400,000 なら、Apple の取り分を引いた手取りではなく総額に対して課金されます。広告収益は MTR に含まれません。
- 取引量が多い/課金モデルが複雑な場合は Enterprise(ボリュームディスカウント・専用サポート・カスタムSLA)。
- ペイウォール/Web-to-app ファネル/A/Bテストだけを使う Growth Tools も MTR の1%。
「1%は高いか」は単体では答えが出ません。 比較対象は、自前で構築・保守した場合の総コストです——2ストア分のサーバーレシート検証、App Store Server Notifications と Google RTDN の受信・再送処理、移管とエイリアス、返金、猶予期間、ダッシュボード、そしてそれらが壊れたときに気づく仕組み。ストアが仕様変更するたびに追随する保守も含みます。個人開発・小規模チームでは、この1%が最も安い保険になることが多い。一方、MTRが十分大きくエンジニアリング体制がある組織なら、自前化の損益分岐は現実的に訪れます。
そして忘れてはいけないのが撤退可能性です。ここは正直に書きます——公式ドキュメントに「RevenueCat から移行して出ていく」手順のページは存在しません。 /docs/migrating-to-revenuecat/* は全て「入ってくる」方向のドキュメントです。公式に持ち出せるのは Scheduled Data Exports(トランザクション単位の購読・売上データと仮想通貨の台帳を、CSV または Parquet で Amazon S3 / Google Cloud Storage / Azure Blob Storage へ。既定は1日1回、Enterprise はより短い間隔)で、プランによる制限があります(2023年9月以降のサインアップ、レガシーの Grow / Pro、Enterprise が対象)。
裏を返せば、購入の権威は最後まで Apple / Google のレシートと購入トークンにあります。RevenueCat はその上の正規化層に過ぎません。だからこそ、
- 自DBに
user_entitlementsの射影を持つ(§8)ことは、ベンダーロックインの保険でもある。 - 顧客ID(App User ID)を自分のUUIDにしておくと、移行時の名寄せが自明になる。
——という §5・§8 の設計は、明日の乗り換えやすさに直結します。「使うかどうか」ではなく「使いながら、いつでも降りられる形にしておく」のが、外部SaaSに対する健全な姿勢です。
17. 本番投入前のチェックリスト
- アプリのコードに商品IDが出てこないか。 見ていいのは Entitlement ID だけ(§2)。
- 新商品を Entitlement にアタッチしたか。 公式が名指しする事故第1位(§2)。
- Test Store キーで提出していないか。 公式が「NEVER」と書く唯一級の警告(§3)。
- Expo Go で「動いた」と判断していないか。 モックが走るだけで購入は起きない(§3)。
- Apple の In-App Purchase Key を登録したか。 無いとトランザクションが記録されない(§6)。
- Google の資格情報を登録して36時間待ったか。 それ以前の失敗は設定不備ではなく待ち時間(§6)。
- App User ID は不変・非PIIか。 メールアドレスを使っていないか(§5)。
logOutを安易に呼んでいないか。 カスタムIDのみならlogInで切り替える(§5)。- サーバー機能の認可を CustomerInfo で判定していないか。 真実源は自DB(§7)。
- Webhook を HMAC 署名で検証しているか。 生ボディに対して、パースより前に。署名の
tが許容差 300 秒以内かも見て、リプレイの窓を閉じる(§8.3)。 - 200 以外を返していないか。 202/204 も失敗扱いでリトライを焼く(§8.1)。
event.idに一意制約、event_timestamp_msで順序保証があるか(§8.3)。BILLING_ISSUE/SUBSCRIPTION_PAUSEDで剥奪していないか。 剥奪はEXPIRATION、返金はcancel_reason: CUSTOMER_SUPPORT(§9)。- 未知のイベント型で 4xx/5xx を返していないか。 提供側の機能追加を自分の障害にしない(§9)。
TRANSFERで移管元の権利を落としているか。 イベントは移管先にしか届かない(§9)。subscriber_attributes.$emailを含む生ペイロードをログに流していないか(§8.2)。- Webhook取りこぼしの照合ジョブがあるか。 リトライは約2時間35分で打ち切られる(§10)。
verificationResultを実際に見ているか。 有効化だけでは守られない(§11)。- サンドボックスで
RENEWAL→EXPIRATIONまで通したか。 月額は5分で更新される(§13)。 - プラン変更をストア差込みで実装していないか。 遷移先は
managementURL、Google のアップグレードは既定が日割りなし(§15)。
まとめ:課金の正しさは「翻訳」と「射影」で作る
RevenueCat の本質は、決済の代行ではなく翻訳です。Apple のレシートと Google の購入トークンという2つの方言を、pro という1つの単語に訳す。そのうえで、あなたの実装が担うべき仕事はこの3つだけになります。
- アプリは Entitlement だけを見る。 商品IDも価格もアプリのバイナリから追い出す(§2)。
- サーバーは自DBだけを見る。 Webhookを起点に
user_entitlementsへ射影し、認可はローカルの1クエリで完結させる(§7・§8)。 - イベント種別ではなく期限で判断する。
CANCELLATION/BILLING_ISSUE/SUBSCRIPTION_PAUSEDで剥奪せず、EXPIRATIONとexpires_atに委ねる。未知のイベントは観測に落とす(§9)。
この3つを守ると、新しいプランの追加も、価格改定も、iOS→Android→Webの拡張も、「課金の作り直し」にならず設定変更で済むようになります。逆に、どこか1つでもクライアントを信じたり、イベント種別に業務ロジックを持たせたりすると、そこが半年後の障害の起点になります。
私はこの水準の課金設計を、Stripeでのマルチチャネル・サブスク基盤(イベントID一意制約+順序保証+PII墨消しによる冪等Webhook、純粋関数の料金解決、433テスト)と、本番二重課金0件を達成した決済信頼性レイヤーで実装・運用してきました。RevenueCat は自分の Expo アプリ(Palmia / memofu)の課金レイヤーとして採用しています。モバイルのサブスク課金基盤の新規構築、既存IAPからの移行、iOS/Android/Webを跨いだ Entitlement 統合、Webhookとアクセス権整合の立て直しをご検討でしたら、要件定義から本番運用・テスト容易性の担保まで、この記事の水準でお引き受けします。
(本記事の RevenueCat 仕様は2026年8月6日時点の公式ドキュメントに基づきます。SDKのバージョン要件・料金・イベント型は更新が速い領域です。実装前に必ず公式ドキュメントでご確認ください。)