rc-webhooks について
rc-webhooksは、サーバーでRevenueCatのWebhookを処理するためのスキルです。署名検証、イベントの重複排除、イベント種別に応じた権限更新を扱います。
主な機能
- 01Webhook署名の検証
- 02event.idによる重複排除
- 03イベント種別ごとの処理
- 04権限の付与と取り消し
活用シーン
- 01RevenueCatのWebhookエンドポイント構築
- 02イベントの重複処理防止
- 03サブスクリプション変更に伴う権限更新
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ revenuecat/ ai-toolkit --skill rc-webhooksインストール後、Claude Code で/rc-webhooksを入力して読み込まれたか確認します。
詳細
RevenueCat Webhooks
1 つのエンドポイントを設定します。RevenueCat は、すべてのストアについて正規化された JSON イベントスキーマを 1 つ送信します。サーバー側で行うことは、署名を検証し、event.id で重複を排除し、イベントタイプごとに振り分けることです。
Phase 1: Discover
コードに手を加える前に、何を接続するのか確認します。
- POST と JSON 本文を受け付ける、サーバー側の HTTPS エンドポイントを所有しています。
- RevenueCat ダッシュボードの「Integrations」から「Webhooks」に進み、Webhook シークレットを取得しています。
- 処理済みイベント ID と、
app_user_idごとのエンタイトルメント状態を記録する永続ストレージがあります。 - RevenueCat はすでにプロダクトをエンタイトルメントにマッピングしています。そのため、
event.typeで分岐し、event.entitlement_idsを読み取ります。バックエンドでプロダクトとエンタイトルメントの対応表を管理する必要はありません。
すべてのイベントは、次の外側の構造を持ちます。
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000001",
"type": "INITIAL_PURCHASE",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"period_type": "NORMAL",
"purchased_at_ms": 1700000000000,
"expiration_at_ms": 1702592000000,
"store": "PLAY_STORE",
"environment": "PRODUCTION",
"entitlement_ids": ["pro_access"],
"transaction_id": "GPA.1234-5678-9012-34567"
}
}
Phase 2: Plan
ハンドラーを書く前に、イベントタイプごとの適切なアクションを選びます。
| イベントタイプ | 意味 | ハンドラーのアクション |
|---|---|---|
INITIAL_PURCHASE | このユーザーとプロダクトに対する最初の有料トランザクションです。 | entitlement_ids のエンタイトルメントを付与します。 |
RENEWAL | EXPIRATION 後の再サブスクリプションを含む、サブスクリプションの更新です。 | entitlement_ids のエンタイトルメントを付与または延長します。 |
CANCELLATION | ユーザーが自動更新をオフにしました。アクセスは expiration_at_ms まで継続します。 | expiration_at_ms に取り消すようスケジュールします。すぐに取り消してはいけません。 |
UNCANCELLATION | ユーザーが有効期限前に自動更新を再びオンにしました。 | スケジュール済みの取り消しをキャンセルします。エンタイトルメントは有効なままにします。 |
EXPIRATION | サブスクリプションが実際に終了しました。 | エンタイトルメントをすぐに取り消します。 |
BILLING_ISSUE | 支払いに失敗しました。ユーザーは猶予期間中、またはアカウント保留中の可能性があります。 | アカウントにフラグを設定します。まだ取り消してはいけません。復旧に失敗した場合、RevenueCat は EXPIRATION を送信します。 |
PRODUCT_CHANGE | ユーザーがプランを変更しました(アップグレード、ダウングレード、または同一階層内の変更)。 | 記録されているプロダクトを更新します。エンタイトルメントの状態は entitlement_ids に従います。 |
SUBSCRIBER_ALIAS | 2 つのアプリユーザー ID が 1 つの ID に統合されました。 | エイリアスされた ID のローカルレコードを統合します。 |
TRANSFER | トランザクションが、あるアプリユーザー ID から別の ID に移動しました。 | エンタイトルメントを古い ID から新しい ID に移します。 |
この表には、次の重要な判断が反映されています。
CANCELLATIONはアクセス状態の変更ではありません。これはユーザーの意図を示すシグナルです。すぐに取り消すのは、ユーザーがまだ所有している有料アクセスを削除するバグです。EXPIRATIONがアクセス状態の変更です。この時点で取り消します。EXPIRATION後に再サブスクリプションすると、INITIAL_PURCHASEではなくRENEWALが発生します。RENEWALの分岐は、エンタイトルメントが現在取り消されているユーザーに対しても安全に実行できる必要があります。つまり、延長するだけでなく、付与する必要があります。BILLING_ISSUEは取り消しではありません。BILLING_ISSUEで取り消すと、Google Play の猶予期間中、またはアカウント保留中のユーザーまでアクセスできなくなります。
Phase 3: Execute
検証、重複排除、振り分けを行うハンドラーを接続します。
署名を検証して解析する
post("/revenuecat/webhook") {
val body = call.receiveText()
val signature = call.request.headers["X-RevenueCat-Signature"]
if (!verifySignature(body, signature, webhookSecret)) {
call.respond(HttpStatusCode.Unauthorized); return@post
}
val event = Json.decodeFromString<RevenueCatEnvelope>(body).event
handleEvent(event)
call.respond(HttpStatusCode.OK)
}
イベントが永続化されたらすぐに 2xx を返します。処理に時間がかかる場合は、キューに登録してから応答してください。ハンドラーの応答が遅いと再試行が発生し、同じイベントが重複して配信されます。
event.id で重複を排除する
RevenueCat は同じイベントを再配信することがあります。event.id は冪等性キーです。
suspend fun handleEvent(event: RcEvent) {
if (processedEvents.insertIfAbsent(event.id)) {
dispatch(event)
}
// Already processed: fall through, responder still returns 200.
}
insertIfAbsent はストア内でアトミックである必要があります(event_id に一意インデックスを設定し、重複キーエラーを無視する挿入処理を行う方法が有効です)。イベント ID の挿入と同じトランザクション内で、後続の書き込みをすべて行ってください。そうすることで、ハンドラーの途中でクラッシュしても、イベントが処理済みと記録されたのに適用されていない状態を防げます。
タイプごとに振り分ける
suspend fun dispatch(e: RcEvent) = when (e.type) {
"INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION" ->
db.grantEntitlements(e.appUserId, e.entitlementIds, e.expirationAtMs)
"CANCELLATION" ->
db.scheduleRevocation(e.appUserId, e.entitlementIds, e.expirationAtMs)
"EXPIRATION" ->
db.revokeEntitlements(e.appUserId, e.entitlementIds)
"BILLING_ISSUE" ->
db.flagBillingIssue(e.appUserId)
"PRODUCT_CHANGE" ->
db.updateProduct(e.appUserId, e.productId, e.entitlementIds)
"SUBSCRIBER_ALIAS", "TRANSFER" ->
db.mergeIdentity(e)
else -> Unit
}
ハンドブックに準拠するための注意事項:
RENEWALでのgrantEntitlementsは、冪等かつ加算的である必要があります。これにより、EXPIRATION後の再サブスクリプションでアクセスが復旧します。scheduleRevocationは、(app_user_id, entitlement_id)をキーとして、expiration_at_msに実行される保留中のジョブを保存します。ジョブの実行前にUNCANCELLATIONが届いた場合は、そのジョブをキャンセルします。先にEXPIRATIONが届いた場合は、EXPIRATIONハンドラーで取り消し、保留中のジョブを削除します。
CANCELLATION ペイロード(アクセスは継続)
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000010",
"type": "CANCELLATION",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"purchased_at_ms": 1700000000000,
"expiration_at_ms": 1702592000000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
ユーザーは 1702592000000 まで pro_access を保持します。そのタイムスタンプで権限を取り消すようスケジュールしてください。
EXPIRATION ペイロード(即時に取り消し)
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000011",
"type": "EXPIRATION",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"expiration_at_ms": 1702592000000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
これを処理したらすぐに、user_12345 の pro_access を取り消してください。
期限切れ後の RENEWAL(再登録)
期限切れになったユーザーが再登録すると、RevenueCat は INITIAL_PURCHASE ではなく RENEWAL を送信します。
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000012",
"type": "RENEWAL",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"purchased_at_ms": 1705270400000,
"expiration_at_ms": 1707862400000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
RENEWAL の分岐では、エンタイトルメントがすでに存在すると想定せず、付与する必要があります。既存の有効期限を延長するだけでは、再登録したユーザーは引き続きアクセスできません。
検証チェックリスト
- 署名検証では、
X-RevenueCat-Signatureがない、または値が誤っているリクエストを拒否します。 - 同じ
event.idを持つイベントが再送された場合、何もせず、引き続き 200 を返します。 CANCELLATIONではアクセスを取り消しません。ユーザーはexpiration_at_msまでエンタイトルメントを保持します。EXPIRATIONでは、entitlement_ids内の ID に対するアクセスを取り消します。EXPIRATIONの後にRENEWALが届いた場合、同じapp_user_idのアクセスを復元します。BILLING_ISSUEでは、アクセスを取り消さずにアカウントにフラグを設定します。- 後続の処理が非同期の場合でも、ハンドラーは再試行期間内に 2xx を返します。