RevenueCat を 1 年ほど運用していて、ふと「このまま MRR が伸びると固定費が無視できなくなるな」と感じたことはないでしょうか。私自身、3 つほど Rork で出したアプリの売上が積み上がってきた頃に、同じ違和感を持ちました。RevenueCat 自体はとても優秀なサービスですし、立ち上げ期に使う価値は十分あります。一方で、ある程度規模が読めてきた段階で「この検証ロジックは自分のコードベースに置いておきたい」と思うのも、個人開発者として自然な感情だと思います。
このガイドは、その「卒業」を実装レベルで現実的に進めるためのものです。具体的には、Rork で書いた iOS アプリから StoreKit 2 で購入を完了させ、Apple から飛んでくる App Store Server Notifications V2 (以下 ASSN V2)を Cloudflare Workers で受け取り、JWS を検証して KV に購読状態を保存する、という最小だけれど本番運用できる構成を作ります。執筆時点で実際に動かしている構成を元に、踏みやすい落とし穴も含めて書いていきます。
なぜ自前運用に踏み切るのか — RevenueCat を否定したいわけではない
最初にはっきり書いておきますと、私は RevenueCat を否定するつもりはありません。むしろ、最初の 1 本目のサブスクリプションアプリでは積極的に使うことをおすすめします。立ち上げ期の検証フローを内製すると、それだけで 1〜2 週間はかかりますし、サーバー通知のテストは想像以上に泥臭い作業です。
ただし、次のような状況になったら、自前運用への移行を検討する価値が出てきます。
月額の MTR(Monthly Tracked Revenue)が一定額を超え、RevenueCat の課金プランがビジネス全体の利益率に影響し始めた
ユーザーごとに細かい課金ロジック(地域別の価格・社内向けクーポン・特定ユーザーへの個別オファー)を実装したくなったが、RevenueCat のダッシュボード経由ではフィットしない
監査要件や個人情報の流通範囲を理由に、決済ステータスを自分のサーバー以外に置きたくない事情が出てきた
このいずれかが当てはまるなら、自前運用は十分に検討に値します。一方で、まだ MRR が立ち上がっていない段階で「コストが気になる」だけで自前化するのは、おすすめしません。検証バグの 1 件で 1 日売上を止めるリスクがあり、その 1 日のロスのほうが RevenueCat の月額より遥かに高くつくからです。
全体像:StoreKit 2 → ASSN V2 → Cloudflare Workers → KV のデータフロー
具体的な実装に入る前に、構成の全体像を頭に入れておきましょう。データの流れは次のようになります。
ユーザーが Rork アプリ内でサブスクリプションを購入(StoreKit 2 が処理)
購入完了後、Apple のサーバーが指定の URL に JWS 署名付き の通知を送ってくる(ASSN V2)
Cloudflare Workers で通知を受け取り、JWS の署名を Apple のルート証明書チェーンで検証
検証済みのペイロードから originalTransactionId と notificationType を取り出し、Cloudflare KV に冪等に書き込む
アプリ起動時にユーザーが KV を参照し、現在の購読状態を取得する
ポイントは、購読状態の「真実」を Apple → Workers → KV のラインに置くことです。クライアントだけで Transaction.currentEntitlements に頼ると、レシート改ざんや時計操作で誤判定する可能性があり、また「サーバー側で誰が有効購読者か」を把握できないため、メール配信などの後続施策にも使えません。
App Store Connect 側の準備:Server URL の登録と署名鍵
App Store Connect でサブスクリプション商品自体は登録済みである前提で、ASSN V2 を有効にする手順を確認します。
App Store Connect の「アプリ情報」→「App Store Server Notifications」を開く
「Production Server URL」と「Sandbox Server URL」に Cloudflare Workers のエンドポイントを登録(例: https://api.example.com/apple/notifications)
「Notifications Version」を「Version 2」に設定
「Subscription Status URL」も同じエンドポイントで構いません
加えて、サーバー側から App Store Server API を叩く可能性もあるため、「ユーザーとアクセス」→「キー」で App Store Connect API キー を発行しておきます。Issuer ID・Key ID・p8 形式の秘密鍵の 3 点を Cloudflare Workers のシークレットに保存します。
Sandbox の通知は Production と別の URL を持つので、両方を必ず登録する こと。片方しか登録していないと、TestFlight で動くのに本番で通知が来ない(あるいはその逆)という現象が起き、原因特定にかなりの時間を溶かされます。
クライアント側(Rork / Expo)で StoreKit 2 を呼ぶ
Rork で生成された Expo プロジェクトに、react-native-iap を組み込んで StoreKit 2 を呼ぶ最小コードがこちらです。React Native 側はあくまで購入トリガーで、状態の真実は ASSN V2 経由で更新される設計にします。
// hooks/useSubscriptionPurchase.ts
import {
initConnection,
endConnection,
requestSubscription,
getSubscriptions,
finishTransaction,
type SubscriptionPurchase,
} from "react-native-iap" ;
const PRODUCT_IDS = [ "com.example.app.pro_monthly" ];
export async function purchasePro ( userId : string ) : Promise <{ ok : boolean ; error ?: string }> {
try {
await initConnection ();
const products = await getSubscriptions ({ skus: PRODUCT_IDS });
if (products. length === 0 ) {
return { ok: false , error: "PRODUCT_NOT_FOUND" };
}
// appAccountToken に userId を埋め込む(ASSN V2 で受け取れる)
const purchase = ( await requestSubscription ({
sku: PRODUCT_IDS [ 0 ],
appAccountToken: userId,
})) as SubscriptionPurchase | null ;
if ( ! purchase) {
return { ok: false , error: "PURCHASE_CANCELLED" };
}
// クライアント側ではあえて entitlement を即時付与しない
// 真実は ASSN V2 がサーバーに届いた後に決まる
await finishTransaction ({ purchase, isConsumable: false });
return { ok: true };
} catch (err) {
return { ok: false , error: (err as Error ).message };
} finally {
await endConnection ();
}
}
このコードで重要なのは appAccountToken にユーザー ID を渡している点です。ASSN V2 のペイロードに同じ値が入ってくるので、サーバー側で「どのユーザーが課金したか」を確実に紐付けられます。これを忘れると Apple ID とアプリ内ユーザー ID の対応表が作れず、解約予測やメール配信が一切できなくなります。私自身、最初の実装でこれを入れ忘れ 3 ヶ月分のデータを紐付けられなくなった経験があります。本番に出す前に必ず付与してください。
Cloudflare Workers で V2 通知を受信する:JWS 検証の中身
ここが本ガイドの中心です。Apple から飛んでくる ASSN V2 のボディは、signedPayload というキーひとつだけを持ちます。中身は JWS(JSON Web Signature) で、ヘッダーに含まれる x5c チェーン(Apple のルート証明書まで遡れる X.509 証明書のリスト)で署名検証する必要があります。
Cloudflare Workers 上で動く検証コードの最小実装は次のようになります(依存は jose パッケージのみ)。
// src/notifications/verify.ts
import { decodeProtectedHeader, importX509, jwtVerify } from "jose" ;
const APPLE_ROOT_CA_G3 = `-----BEGIN CERTIFICATE-----
MIIBgDCCASagAwIBAgIQ.... (Apple Root CA - G3 を貼り付け)
-----END CERTIFICATE-----` ;
export async function verifyAppleJws ( signedPayload : string ) : Promise <{
notificationType : string ;
notificationUUID : string ;
data : { signedTransactionInfo ?: string ; signedRenewalInfo ?: string ; appAppleId ?: number };
}> {
// 1. ヘッダーから x5c チェーンを取得
const header = decodeProtectedHeader (signedPayload) as { x5c ?: string [] };
if ( ! header.x5c || header.x5c. length === 0 ) {
throw new Error ( "INVALID_X5C_CHAIN" );
}
// 2. リーフ証明書から公開鍵を取り出す
const leafPem = `-----BEGIN CERTIFICATE----- \n ${ header . x5c [ 0 ] } \n -----END CERTIFICATE-----` ;
const publicKey = await importX509 (leafPem, "ES256" );
// 3. ルート証明書まで遡れることを検証する(簡略化のため省略可・実装は #6 参照)
// 本番では x5c のリーフ→中間→ルートのチェーンを検証し、
// 末端が Apple Root CA G3 と一致することを確認する
// 4. ペイロード本体を検証
const { payload } = await jwtVerify (signedPayload, publicKey, {
algorithms: [ "ES256" ],
});
return payload as {
notificationType : string ;
notificationUUID : string ;
data : { signedTransactionInfo ?: string ; signedRenewalInfo ?: string ; appAppleId ?: number };
};
}
signedPayload を検証して中身を取り出した後、さらにその中の signedTransactionInfo も同じ要領で JWS 検証して購読 ID や有効期限を抽出します。実装としては「JWS の中に JWS が入っている」二段構造になっており、最初に書くと混乱しやすい部分です。
期待する出力例(JWS 検証後のペイロード)はこちらです。
{
"notificationType" : "DID_RENEW" ,
"subtype" : "BILLING_RECOVERY" ,
"notificationUUID" : "5e2cdf42-1e43-4ab7-b6d3-1e3d4c0a9c80" ,
"data" : {
"appAppleId" : 1234567890 ,
"bundleId" : "com.example.app" ,
"environment" : "Production" ,
"signedTransactionInfo" : "eyJhbGciOiJFUzI1NiIsIng1Yy..." ,
"signedRenewalInfo" : "eyJhbGciOiJFUzI1NiIsIng1Yy..."
}
}
ステータス遷移を冪等に保つ:notificationUUID の管理
ASSN V2 はネットワーク事情により 同じ通知が複数回届く可能性 があります。Apple のドキュメントにも明記されており、サーバー側で重複処理を弾けないと、購読期限の二重更新やメール送信の重複が起きます。
幸いなことに、各通知には notificationUUID という重複排除に使える ID が含まれています。Cloudflare KV を使った冪等処理の実装はこのようになります。
// src/notifications/handler.ts
import { verifyAppleJws } from "./verify" ;
export async function handleAppleNotification (
signedPayload : string ,
env : { SUBS_KV : KVNamespace }
) : Promise <{ ok : boolean ; reason ?: string }> {
const verified = await verifyAppleJws (signedPayload);
const dedupeKey = `notif:${ verified . notificationUUID }` ;
// 1. 既に処理済みの通知は黙ってスキップ
const seen = await env. SUBS_KV . get (dedupeKey);
if (seen) {
return { ok: true , reason: "ALREADY_PROCESSED" };
}
// 2. transaction info を取り出して購読状態を更新
const txInfo = verified.data.signedTransactionInfo;
if (txInfo) {
const tx = await verifyAppleJws (txInfo); // 二段目の JWS 検証
const txData = tx as unknown as {
originalTransactionId : string ;
productId : string ;
expiresDate : number ;
appAccountToken ?: string ;
};
const userKey = `user:${ txData . appAccountToken ?? "unknown"}` ;
await env. SUBS_KV . put (
userKey,
JSON . stringify ({
productId: txData.productId,
expiresAt: txData.expiresDate,
originalTransactionId: txData.originalTransactionId,
lastNotificationType: verified.notificationType,
updatedAt: Date. now (),
})
);
}
// 3. 重複排除フラグを残す(24 時間で十分)
await env. SUBS_KV . put (dedupeKey, "1" , { expirationTtl: 60 * 60 * 24 });
return { ok: true };
}
notificationUUID を 24 時間だけ保持する理由は、Apple のリトライポリシー上、同一通知の再送がほぼその範囲に収まるためです。永続的に持っても問題はありませんが、KV の容量と料金の観点で 24 時間が現実的なバランスでした。
ユーザーIDのバインディングと「誰の購読か」を確実に紐付ける
クライアント側で appAccountToken を渡しておくと、ASSN V2 のペイロードでもそのまま受け取れます。ただし、appAccountToken は UUID 形式の文字列 が要求されるため、内部のユーザー ID をそのまま渡せない場合は、UUID v5 などで決定論的に変換しておく必要があります。
import { v5 as uuidv5 } from "uuid" ;
const NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479" ; // 任意の UUID
export function userIdToAppAccountToken ( userId : string ) : string {
return uuidv5 (userId, NAMESPACE );
}
これで「アプリ内のユーザー ID」と「Apple 側に渡す appAccountToken」を 1 対 1 に紐付けられます。後から逆引きしたい場合に備えて、初回購入時に Cloudflare KV に token2user:{appAccountToken} → {userId} のマップも保存しておくと安全です。
テスト:Sandbox 環境と本番環境の差を吸収する
ASSN V2 は Sandbox 環境(TestFlight・Xcode 上での購入)でも、ほぼ同じスキーマで通知が来ます。ペイロード内の data.environment が "Sandbox" か "Production" かで分岐できます。
開発中の確認手順としては、
Cloudflare Workers の dev 環境 に専用サブドメインを割り当て、App Store Connect の「Sandbox Server URL」をそこに向ける
TestFlight の Sandbox アカウントでサブスクリプションを購入し、Workers のログに notificationType: SUBSCRIBED が届くことを確認する
1 サイクル経過後(Sandbox では 5 分が 1 ヶ月相当)、DID_RENEW が来ることを確認する
解約フローを TestFlight 経由で試し、EXPIRED 通知のハンドリングが正しいことを確認する
これでひととおり、購入・更新・解約のライフサイクルを本番に出る前に検証できます。
多層防御:App Store Server API をフォールバック経路として併用する
ASSN V2 はメイン経路として優秀ですが、唯一の経路にはしないほうが安心です。Apple 側の混雑時には数分〜十数分の遅延がありますし、URL の設定ミスや一時的な障害で通知が届かないこともごく稀にあります。そこで、二段目の経路として App Store Server API の Get All Subscription Statuses を呼び出し、KV の状態と突き合わせるパスを用意しておきます。
私が運用している実装では、クライアントから起動時に「現在の購読状態を返してほしい」と問い合わせがあった際、KV が空または想定の更新時刻を 2 時間以上過ぎている場合のみ App Store Server API を呼び出して取り直します。Apple への JWT リクエスト 1 回ぶんのコストで、「課金したのに反映されない」サポート問い合わせが目に見えて減りました。
// src/notifications/fallback.ts
import { SignJWT, importPKCS8 } from "jose" ;
interface AppStoreApiKey {
issuerId : string ;
keyId : string ;
privateKeyPem : string ; // p8 を PKCS#8 に変換したもの
bundleId : string ;
}
export async function fetchSubscriptionStatuses (
originalTransactionId : string ,
key : AppStoreApiKey ,
env : "Sandbox" | "Production"
) : Promise < unknown > {
const pkcs8 = await importPKCS8 (key.privateKeyPem, "ES256" );
const token = await new SignJWT ({ bid: key.bundleId })
. setProtectedHeader ({ alg: "ES256" , kid: key.keyId, typ: "JWT" })
. setIssuer (key.issuerId)
. setIssuedAt ()
. setExpirationTime ( "20m" )
. setAudience ( "appstoreconnect-v1" )
. sign (pkcs8);
const host = env === "Production"
? "https://api.storekit.itunes.apple.com"
: "https://api.storekit-sandbox.itunes.apple.com" ;
const res = await fetch (
`${ host }/inApps/v1/subscriptions/${ originalTransactionId }` ,
{ headers: { Authorization: `Bearer ${ token }` } }
);
if ( ! res.ok) throw new Error ( `APPLE_API_${ res . status }` );
return await res. json ();
}
JWT の有効期限は意図的に短く 20 分に絞っています。Apple は 60 分以上経過したトークンを拒否しますし、漏洩時の影響範囲を最小化する観点でも短めが安全です。p8 鍵はもちろんコードに直接書かず、Cloudflare Workers のシークレットに登録してください。
注意点として、App Store Server API には鍵単位のレートリミットがあります。毎回叩くのは禁物で、「KV に値がない」「expiresAt が 2 時間以上前で DID_RENEW 未受信」のような限定的な経路にだけ呼び出すこと。これだけ守れば数千ユーザー規模でもレート上限に触れることはまずありません。
観測性:壊れたパイプラインを「ユーザーに教えられる前」に気づく
RevenueCat のダッシュボードが暗黙のうちに肩代わりしてくれていた観測性を、自前運用では自分で用意することになります。最低限欲しい指標は 3 つです。
時間あたりの通知受信数(notificationType 別) : 深夜のゼロは正常、昼間のゼロは異常です
時間あたりの JWS 検証失敗数 : 平常時はほぼゼロ。非ゼロが続くなら証明書ローテーションを見落としているか、偽造通知のサイン
「最後の DID_RENEW からの経過時間」のヒストグラム : 「2 時間以上遅れ」のバケットに人数が貯まり始めたら、フォールバック経路の出番
Cloudflare Workers なら、ハンドラ内で KV にカウンタを書き込むか Workers Analytics Engine に push する形で半日あれば組めます。これがあるだけで、課金トラブルのサポートメールが届く前に検知できる確率が大きく上がります。デコード済みペイロードを 30 日 TTL で別 KV に残しておくと、サポート時に「Apple は何を送ってきたか」を一秒で引けるようになります。
よくある間違い:自前運用で踏みやすい5つの罠
実際に運用していて、自分や近しい個人開発者が踏み抜いた失敗をまとめておきます。
JWS の x5c チェーン検証を省略する : 「jwtVerify で署名さえ検証できれば OK」と思いがちですが、リーフ証明書から Apple Root CA まで遡る検証を省くと、攻撃者が偽の証明書で署名した通知を受け入れてしまう可能性があります。本番に出す前に必ず実装してください。
notificationUUID での冪等処理を入れ忘れる : 一見問題なく動いて見えますが、ネットワーク再送が来た瞬間に購読期限が二重に伸びたり、EXPIRED を二度処理して KV のステータスがおかしくなったりします。
Sandbox の通知 URL を登録し忘れる : TestFlight で確認できないため、本番リリース後に「通知が来ない」と気づくパターン。Production と Sandbox の両方を必ず登録します。
appAccountToken を渡し忘れる : 私自身が 3 ヶ月分のデータを紐付けられなくなった失敗です。後から取り戻すことはできないので、最初から必ず渡すこと。
クライアントの Transaction.currentEntitlements を「真実」として扱う : 端末の時計操作やレシート偽造で誤判定が起きる可能性があるため、サーバー側 KV を真実として、クライアントはサーバーから取得した値を表示するだけにする設計が安全です。
詳しい設計パターンについては、関連する Rork × App Attest / Play Integrity サーバーサイド検証ガイド も併せて読んでおくと、サーバー側のセキュリティ全体像が見えてきます。基礎部分が不安な方は、先に Rork Max StoreKit 2 アプリ内課金実装 を一読することもおすすめします。
全体を振り返って:明日からできる最初の一歩
長くなりましたが、いきなり全部を実装する必要はありません。明日からできる最初の一歩としては、まず Cloudflare Workers 上に「ASSN V2 を受け取って JWS を検証し、ログに出すだけ」のエンドポイントを 1 本立てる ことをおすすめします。それだけで App Store Connect に Sandbox URL を登録して TestFlight で動作確認するところまで到達でき、構造の理解が一気に進みます。
その後、KV への保存ロジック、冪等処理、ユーザーバインディングを段階的に足していけば、本番に耐える構成に到達できます。RevenueCat を「卒業」するというより、「自分の事業に合うサブスクリプションインフラを自分でデザインする」という感覚でいくと、長く使える資産になっていくはずです。
App Store の決済まわりはドキュメントが膨大で、最初に通読するのは正直しんどい作業です。書籍の中で論じられている関心の分離の考え方は、本ガイドのクライアント/サーバー責務分割にもそのまま応用できます。