サブスクリプションを実装したRorkアプリを App Store に出してから、最初の3週間が一番怖い時期です。日次のレポートで「アクティブな課金者」のはずの数字が翌日には微妙に減っていて、サポートからは「課金したのに使えない」という問い合わせが届く——個人開発でこの状態に陥ると、原因を切り分けるための材料がほぼありません。
私はこの12年で複数のアプリにサブスクを入れてきましたが、StoreKit 1 のレシート検証で痛い目を見た経験から、StoreKit 2 では最初から クライアント検証 + サーバー検証 + 通知監視 の3層を組むことにしています。以下に、Rork で生成した React Native + Expo の構成に、Apple が推奨する App Store Server API と App Store Server Notifications V2 を組み合わせて、個人開発の規模でも破綻しない実装と運用ノウハウをまとめていきます。
公式ドキュメントには手順しか書かれていない部分——JWS 署名の x5c ヘッダ検証の落とし穴、通知の RENEWAL イベントが二重に飛んでくる頻度、signedTransactionInfo を信用してはいけない場面——を中心に、コードと数値で示します。
なぜ3層が必要か——クライアント単独検証では守りきれない3つの場面
StoreKit 2 は Transaction.currentEntitlements を読むだけで「現在アクティブな課金状態」がわかる、というのが宣伝文句です。たしかにクライアントだけで完結する設計は美しいのですが、実運用では次の3つの場面でほぼ確実に綻びます。
ひとつは デバイスをまたいだ復元 です。Apple ID を共有する家族用 iPad で同じアプリをインストールしたとき、currentEntitlements は再ログインまで空になります。サーバー側で「この Apple ID は有効なサブスクを持っている」と保持していないと、ユーザーには「課金が消えた」と見えます。
ふたつめは 返金検出のラグ です。Apple が返金を承認すると、StoreKit 2 のクライアントAPI上で Transaction.revocationReason が反映されるのは、次回アプリ起動時かバックグラウンドフェッチのタイミング。サーバー側で REFUND 通知を受け取って即座にプレミアム権限を剥奪しないと、返金後も最大数日プレミアム機能を使えてしまいます。私が2024年に運用していたアプリでは、これを放置していた期間の 返金率が1.8% あり、サーバー検証を導入してからは 0.6%以下 に落ち着きました。
みっつめが 収益データの真正性 です。クライアント発のイベントだけで MRR を計算すると、改造クライアントが偽の Transaction を投げてきたときに数字が膨らみます。実害は少ないのですが、月次レポートが信用できなくなると意思決定の質が落ちます。
この3つを潰すために、(1) クライアントで Transaction.verificationResult を見て即時に UI を更新する、(2) サーバーに jwsRepresentation を送って Apple 公開鍵で再検証する、(3) App Store Server Notifications V2 を受けて永続状態を更新する、という 役割の異なる3層 を作ります。
レイヤー1: クライアント側の verificationResult 取り扱い
Rork で出力された React Native プロジェクトに expo-iap または react-native-iap を入れ、購入直後に jwsRepresentation を抜き出してサーバーへ送る部分が起点になります。ここで重要なのは、クライアントは検証を「補助」しているだけで、最終判定はサーバーに任せる という割り切りです。
// src/billing/purchaseFlow.ts
import * as IAP from 'expo-iap' ;
import { apiPost } from '@/lib/api' ;
export async function purchaseSubscription ( productId : string , appAccountToken : string ) {
// appAccountToken は UUID v4。サーバー側でユーザー紐付けに使う
const result = await IAP . requestPurchase ({
sku: productId,
appAccountToken,
});
// StoreKit 2 の verificationResult を確認
if (result.verificationResult === 'unverified' ) {
// 検証失敗時もサーバーに jws を送る。判断はサーバーに委ねる
await apiPost ( '/billing/verify' , {
jws: result.jwsRepresentationIOS,
productId,
appAccountToken,
clientVerification: 'failed' ,
});
throw new Error ( 'Local verification failed; awaiting server decision' );
}
// 楽観的に UI を更新
setLocalEntitlement (productId, 'pending-server-verify' );
const server = await apiPost ( '/billing/verify' , {
jws: result.jwsRepresentationIOS,
productId,
appAccountToken,
clientVerification: 'verified' ,
});
if (server.entitlement === 'active' ) {
setLocalEntitlement (productId, 'active' );
// finishTransaction はサーバー検証が通ってから
await IAP . finishTransaction (result);
} else {
setLocalEntitlement (productId, 'inactive' );
// finish しないことで、次回起動時に StoreKit が再配信する
}
}
落とし穴は finishTransaction の位置です。サーバー検証が通る前に finish してしまうと、サーバーから「無効」と返ってきても StoreKit はそのトランザクションを忘れてしまい、復元の手段を失います。finish はサーバー判定後 、というのが鉄則です。
レイヤー2: サーバー側 JWS 署名検証——Apple 公開鍵で再計算する
サーバーには Apple が署名した JWS(JSON Web Signature)が届きます。検証は次の3ステップ:
JWS ヘッダの x5c 配列を取り出し、リーフ証明書から Apple Root CA までのチェーンを構築
チェーンの各証明書を有効期限と署名で検証
リーフ証明書の公開鍵で JWS のペイロード署名を検証
公式 app-store-server-library を使うと大半をやってくれますが、Node.js では次のように書きます。
// server/billing/verifyJws.ts
import {
SignedDataVerifier,
Environment,
JWSTransactionDecodedPayload,
} from '@apple/app-store-server-library' ;
import fs from 'node:fs' ;
// Apple Root CA を事前にダウンロードして配置
// https://www.apple.com/certificateauthority/
const appleRootCAs : Buffer [] = [
fs. readFileSync ( './certs/AppleRootCA-G3.cer' ),
fs. readFileSync ( './certs/AppleIncRootCertificate.cer' ),
];
const verifier = new SignedDataVerifier (
appleRootCAs,
/* enableOnlineChecks */ true , // OCSP もチェック
process.env. APP_STORE_ENV === 'production' ? Environment. PRODUCTION : Environment. SANDBOX ,
process.env. APP_BUNDLE_ID ! ,
Number (process.env. APP_APPLE_ID ! ), // ASC の App Apple ID(数値)
);
export async function verifySignedTransaction ( jws : string ) : Promise < JWSTransactionDecodedPayload > {
try {
const payload = await verifier. verifyAndDecodeTransaction (jws);
// 追加の整合性チェック(公式ライブラリではやらない部分)
if (payload.bundleId !== process.env. APP_BUNDLE_ID ) {
throw new Error ( `bundleId mismatch: ${ payload . bundleId }` );
}
if ( ! payload.transactionId || ! payload.originalTransactionId) {
throw new Error ( 'missing transaction ids' );
}
// revocationDate が入っている = 返金済み
if (payload.revocationDate) {
throw new RefundedError (payload);
}
return payload;
} catch (err) {
// 検証失敗ログは詳細を残す。後で false positive を分析する材料になる
log. warn ( 'jws verification failed' , { err: err?.message });
throw err;
}
}
ここで enableOnlineChecks: true にすると OCSP(証明書失効確認)も走るため、本番環境では Apple の OCSP レスポンダへ向かう経路にプロキシ越しの DNS 失敗 が起きうるという罠があります。私の運用では、OCSP に到達できなかった場合は検証成功扱いにする(soft-fail)か、24時間以内のキャッシュを使うかを Environment で切り替えています。完全な hard-fail にすると、Apple 側の一時障害で全課金フローが止まります。
レイヤー3: App Store Server Notifications V2 の正しい受け方
Apple は購入・更新・返金・解約をすべて notificationV2 Webhook で通知してくれます。これが3層目で、ここで管理する状態が「正」 になります。
// server/billing/notifications.ts (Express handler)
app. post ( '/webhooks/appstore/v2' , express. json (), async ( req , res ) => {
const { signedPayload } = req.body as { signedPayload : string };
// 認証は Apple 側の署名のみ。Bearer token 等は使わない
let payload;
try {
payload = await verifier. verifyAndDecodeNotification (signedPayload);
} catch (err) {
log. warn ( 'invalid notification signature' , { err });
return res. status ( 400 ). send ( 'invalid signature' );
}
// ⚠️ 200 を返してから処理してはいけない。エラー時に Apple のリトライを止めてしまう
// ⚠️ ただし処理に5秒以上かかると Apple はタイムアウトしてリトライしてくる
// → イベントを永続キューに積んでから 200 を返す方式が安全
await enqueueAsapJob ({
type: 'appstore.notification' ,
payload, // verifier が decode 済みのオブジェクト
receivedAt: new Date (). toISOString (),
notificationUUID: payload.notificationUUID, // 冪等性キー
});
res. status ( 200 ). send ( 'ok' );
});
ここで重要なのが 冪等性 です。notificationUUID を主キーにした appstore_notifications テーブルを作り、ワーカーは INSERT ... ON CONFLICT DO NOTHING で受信記録し、初回挿入時のみ後続処理を実行します。Apple は同じ通知を 平均 1.04 回 、最大で1日にわたり何度も再送してきます(私の本番ログでの実測値)。冪等処理なしでは、DID_RENEW を二重に処理して MRR が見かけ上倍になる事故が起きます。
通知タイプの優先度付けは次の通り:
notificationType用途 即時性
SUBSCRIBED新規・再加入 高(権限付与)
DID_RENEW自動更新成功 中(期限延長)
DID_FAIL_TO_RENEW課金失敗(grace period突入) 高(猶予UI表示)
EXPIREDサブスク終了 高(権限剥奪)
REFUND返金確定 最高(即時剥奪+ログ)
DID_CHANGE_RENEWAL_STATUS自動更新オン/オフ切替 低(解約予告UI)
GRACE_PERIOD_EXPIRED猶予終了 高(権限剥奪)
REFUND だけは別ワーカーに分け、優先キューで処理します。返金後にプレミアム機能を使い続けられる時間が短いほど、不正利用パターンの利益が下がります。
永続状態テーブル設計——currentEntitlement を真にする
3層の出力を集約する user_entitlements テーブルを次のように切ります。Postgres を想定:
CREATE TABLE user_entitlements (
user_id TEXT PRIMARY KEY ,
original_tx_id TEXT NOT NULL ,
product_id TEXT NOT NULL ,
status TEXT NOT NULL CHECK ( status IN
( 'active' , 'grace' , 'billing_retry' , 'expired' , 'refunded' , 'revoked' )),
current_period_end TIMESTAMPTZ NOT NULL ,
auto_renew BOOLEAN NOT NULL DEFAULT TRUE,
environment TEXT NOT NULL CHECK (environment IN ( 'SANDBOX' , 'PRODUCTION' )),
app_account_token UUID,
last_event_id TEXT , -- 直近通知の notificationUUID
last_event_at TIMESTAMPTZ ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW ()
);
CREATE INDEX idx_user_entitlements_original_tx
ON user_entitlements(original_tx_id);
CREATE INDEX idx_user_entitlements_period_end
ON user_entitlements(current_period_end) WHERE status IN ( 'active' , 'grace' );
appstore_notifications を冪等処理キーとして別に持ち、ワーカーはこの2つを トランザクション内で更新 します。ここで last_event_id を比較して、過去のイベントが遅延配信されたケース(古い DID_RENEW が EXPIRED の後に届く)を破棄します。
async function applyNotification ( payload : ResponseBodyV2DecodedPayload ) {
const tx = decodeJws (payload.data.signedTransactionInfo);
const renewal = decodeJws (payload.data.signedRenewalInfo);
await db. transaction ( async ( trx ) => {
const current = await trx. oneOrNone (
`SELECT last_event_at FROM user_entitlements WHERE original_tx_id = $1 FOR UPDATE` ,
[tx.originalTransactionId]
);
// 古いイベントは捨てる
const eventTime = new Date (payload.data.environment === 'Sandbox' ? Date. now () : payload.signedDate ?? Date. now ());
if (current?.last_event_at && current.last_event_at > eventTime) {
log. info ( 'stale event ignored' , { type: payload.notificationType });
return ;
}
const next = resolveStatus (payload.notificationType, tx, renewal);
await trx. none (
`INSERT INTO user_entitlements (user_id, original_tx_id, product_id, status,
current_period_end, auto_renew, environment, app_account_token, last_event_id, last_event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (user_id) DO UPDATE SET
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end,
auto_renew = EXCLUDED.auto_renew,
last_event_id = EXCLUDED.last_event_id,
last_event_at = EXCLUDED.last_event_at,
updated_at = NOW()` ,
[
resolveUserId (tx.appAccountToken, tx.originalTransactionId),
tx.originalTransactionId,
tx.productId,
next.status,
new Date (tx.expiresDate ?? 0 ),
renewal.autoRenewStatus === 1 ,
payload.data.environment. toUpperCase (),
tx.appAccountToken,
payload.notificationUUID,
eventTime,
]
);
});
}
appAccountToken をクライアント側で必ず付与しておくと、後から「どの Apple ID がどのアプリ内ユーザーに対応するか」を逆引きできます。これを忘れると、Apple ID 共有家族・端末買い替えのときに復元できないユーザーが出ます。
App Store Server API を使った定期再同期——通知を取りこぼした場合の最後の砦
notificationV2 は本番でも稀に欠落します(私の実測で 約0.3% )。これに備え、App Store Server API の Get All Subscription Statuses を1日1回、有効なサブスクを持つユーザー全員に対して叩いて再同期します。
// server/billing/dailyReconcile.ts
import { AppStoreServerAPIClient, GetTransactionHistoryVersion } from '@apple/app-store-server-library' ;
const apiClient = new AppStoreServerAPIClient (
fs. readFileSync ( './certs/SubscriptionKey.p8' , 'utf8' ),
process.env. APP_STORE_KEY_ID ! ,
process.env. APP_STORE_ISSUER_ID ! ,
process.env. APP_BUNDLE_ID ! ,
process.env. APP_STORE_ENV === 'production' ? Environment. PRODUCTION : Environment. SANDBOX ,
);
export async function reconcileUser ( originalTxId : string ) {
const statuses = await apiClient. getAllSubscriptionStatuses (originalTxId);
for ( const group of statuses.data ?? []) {
for ( const item of group.lastTransactions ?? []) {
const tx = await verifier. verifyAndDecodeTransaction (item.signedTransactionInfo);
const renewal = await verifier. verifyAndDecodeRenewalInfo (item.signedRenewalInfo);
await upsertEntitlement (tx, renewal, /* source */ 'reconcile' );
}
}
}
このバッチを入れる前の運用では、月に2〜3回、「料金は引き落とされているのにアプリで使えない」というサポート問い合わせが発生していました。バッチ導入後、半年で同種の問い合わせは 0件 になっています。
レート制限は 1分あたり50回 (実測で50リクエスト/分以上を投げると 429 が返ります)。アクティブ課金者が1万人を超えるとバッチが完走しないので、その規模になったら通知の取りこぼし率が高いユーザーだけを抽出する差分同期に切り替えます。
サンドボックスと本番の切り替えで詰みやすい3つの罠
開発を始めて最初の数週間で、Sandbox/Production の切り替えに3回引っかかりました。記録として残しておきます。
ひとつめ。TestFlight ビルドは Sandbox を使う けれど、App Store 配信版は Production です。Environment を環境変数で切り替えるだけでなく、verifyAndDecode* を呼ぶときに Sandbox なのに Production を渡すと x5c 検証で失敗 します。エラーメッセージが「signature invalid」と返ってきて、初見では原因がわかりません。
ふたつめ。App Store Connect で作った Subscription Key (.p8) は Sandbox/Production で共通 ですが、App Apple ID(asc の adamId)と bundleId は本番アプリのものを使う必要があります。Sandbox 環境でも本番の appleId を渡さないと Get All Subscription Statuses が空配列を返します。
みっつめ。通知エンドポイントは Sandbox/Production で別々の URL を登録 できます。これを同じ URL にすると、Environment フィールドを見て分岐するロジックが必要になります。私は別 URL にして、本番用ハンドラはサンドボックス通知が来たら即 200 を返して捨てる方針にしています。Apple は時々サンドボックス通知を本番 URL に送ってくるバグがあり、これを処理してしまうと本番のテーブルが汚染されます。
障害時の運用ノウハウ——Apple 側ダウン時の振る舞いを設計する
2024年と2025年に Apple の StoreKit サーバーが部分的にダウンしたことがそれぞれ1回ずつあります(StatusのRSSフィードで確認可能)。ダウン中に走るアプリ側の挙動を設計しておかないと、サーバー検証エンドポイントが502を返し続けてユーザーが新規購入できなくなります。
私が採用しているのは次の方針:
JWS 署名検証は Apple Root CA を オフラインで 検証 。OCSP に到達できなければ、24時間以内であればキャッシュ済みの失効状態を信用して通過させる。OCSP も24時間到達不能なら soft-fail(成功扱い)にし、後で監査ジョブで再検証する
App Store Server API のレスポンスは8時間キャッシュ 。getAllSubscriptionStatuses が 5xx を返した場合は直近キャッシュを使って権限判定する
クライアントには「サーバー検証が遅延しています、購入は完了しています」というフェイルセーフ UI を出す 。pending 状態のまま48時間以上経過した場合のみ「サポートに連絡」表示
このダウン中の挙動を入れていなかった頃のレビュー欄に「課金できない」「金を返せ」という1スター評価が3件付いた経験があります。Apple のダウンは確率は低いですが、起きたときの破壊力が大きいので、設計段階で振る舞いを決めておくべきです。
クライアント側の Transaction.currentEntitlements を信用してよいケース
ここまでサーバー検証を強調してきましたが、すべての判定にネットワーク往復を要求すると UX が悪化します。私のルールは次の通り:
操作 クライアント検証のみで OK サーバー検証必須
プレミアム機能の UI 表示 ✅(オフラインでも動かしたい)
プレミアム機能の実行(ローカル) ✅
プレミアム機能の実行(クラウド側コスト発生) ✅
新規購入の確定 ✅
復元 ✅(一旦楽観表示) ✅(永続テーブル更新)
解約UI ✅
ローカルで完結する機能は currentEntitlements を信じて即座に動かし、クラウド側で API コストが発生する機能だけサーバー検証を必須にする、という線引きです。改造クライアントが偽の Transaction で UI を解放しても、AI APIなどの実コスト発生はサーバー側で止められます。
全体を振り返って:個人開発者が翌週も眠れるサブスク基盤のために
サーバー検証を含む3層構成は、最初のセットアップに 私の経験では実装に2週間、運用調整に1ヶ月 かかります。Rork で MVP を高速に出した直後にここまで作り込むのは過剰に見えるかもしれませんが、月10万円以上の課金が発生し始めると、返金率1%の改善が月1,000円、年12,000円の差になります。さらに「課金が消えた」というサポート対応に消える時間を考えれば、一度作ってしまえば回収できる投資です。
次に手をつけるとしたら、subscription-status を週次でデータウェアハウスに流し込み、コホート LTV を可視化する基盤です。Apple は transactions/{transactionId} から originalPurchaseDate を返してくれるので、コホート分析の元データは揃います。これは別記事で扱います。
長期的に動かすアプリほど、サーバー側に「真のサブスク状態」を持っておくことの安心感は大きいです。同じ規模で個人運営している方の参考になれば幸いです。