構築するもの
多くのアプリは、2種類の通貨で成り立っています。プレイを通じて獲得するソフトカレンシー(コイン、エネルギー、 XP)と、実際のお金で購入するハードカレンシー(ジェム、クレジット、トークン)です。このコードラボでは、 RevenueCat Virtual Currencies を土台に、そのハードカレンシー側である「ジェム」ストアを構築します。 対象は iOS・Android・Flutter・React Native・Kotlin Multiplatform です。
最終的に、次のものが手に入ります。
- 消費型のアプリ内課金で加算される、RevenueCat 上の
GEM仮想通貨。 - ジェムパックをローカライズされたストア価格で一覧表示する、ネイティブなストア画面。
- RevenueCat がレシートを検証したあとにジェム残高を自動加算する購入処理。
- SDK から読み取り、アプリに表示するリアルタイムのジェム残高。
- Secret API キーでジェムを差し引き、残高不足もきれいに処理するバックエンドの「消費」エンドポイント。
最初の決断: 各通貨をどこで管理するか
コードを書く前に、残高の信頼できる唯一の情報源(source of truth)を決めます。 多くのチームは、すでに自前のサーバーでソフトカレンシーを運用した状態でここにたどり着き、両方の通貨を1か所にまとめるべきか悩みます。 構成は2通りあります。
| 方式 | ソフトカレンシー | ハードカレンシー |
|---|---|---|
| A. 分離(このケースで推奨) | 自前のサーバー | RevenueCat |
| B. 統合 | RevenueCat | RevenueCat |
すでに動作するソフトカレンシー用サーバーがあるなら、方式A のほうがリスクは低くなります。 高頻度でゲームプレイ主導のソフト残高は、すでにうまく動いている場所にそのまま残し、ハードカレンシーは RevenueCat に任せます。 RevenueCat がもっとも価値を発揮するのがこの部分です。レシート検証、自動加算、返金時の自動巻き戻し、アトミックな消費、監査証跡が手に入ります。 既存のソフト残高を移行する必要もありません。
方式B(両方の通貨で RevenueCat を単一の信頼できる情報源とする方式)は、既存の残高ストレージを持たない 新規アプリに対して RevenueCat が推奨する方式です。 すべての残高が1つのシステムに集まるため、ゼロから始める場合はいちばんシンプルなモデルになります。
ここでは RevenueCat の決済エンジンと残高を使いつつ、ストアの UI は自前のネイティブ実装にします (RevenueCat のペイウォール UI は不要です)。こうすれば、すでに設計したソフトカレンシーのストアと、ジェムの店構えの見た目を揃えられます。
対象読者と必要なもの
ゲームやソーシャルアプリに消費型のハードカレンシーを追加するモバイル開発者向けです。 自分のプラットフォームの UI と非同期モデルに慣れていて、App Store Connect または Google Play にアプリをセットアップ済みであることが前提です (実際のアプリでも、ローカルテスト用の StoreKit 構成ファイルやライセンステスターでもかまいません)。 Virtual Currencies には、比較的新しい SDK が必要です。
| プラットフォーム | SDK | 最低バージョン |
|---|---|---|
| iOS | purchases-ios | 5.32.0 |
| Android | purchases-android | 9.1.0 |
| Flutter | purchases_flutter | 9.1.0 |
| React Native | react-native-purchases | 9.1.0 |
| Kotlin Multiplatform | purchases-kmp | 2.1.0+16.2.0 |
仮想通貨を作成する
RevenueCat ダッシュボードで、 プロジェクトの Product catalog を開いて Virtual Currencies を選び、 + New virtual currency をクリックします。重要なフィールドは2つです。
- Code: SDK と API で使う識別子(たとえば
GEM)。コード内で参照するので、慎重に決めてください。 - Name: 表示名(たとえば
Gems)。
任意でアイコンと説明も追加できます。保存すると、その通貨がすべてのユーザーに対して残高 0 の状態で作成されます。
ジェムパックの商品と付与量を作成する
ジェムは実際のお金で購入するので、各ジェムパックは消費型のアプリ内課金です。 ここはすべてダッシュボードとストアの構成で、まだコードは書きません。
1. ストアで消費型の商品を作成する
App Store Connect や Google Play Console で、パックごとに消費型の商品を1つずつ作成します(たとえば
gems_300、gems_1200、gems_6500)。それぞれに価格帯を設定します。
消費型の商品は繰り返し購入でき、これはまさに通貨のチャージに必要な性質です。
2. 商品をインポートしてオファリングに入れる
RevenueCat で、これらの商品をアプリの下に追加し、オファリング(たとえば
gems)を作成して、各商品をパッケージとして追加します。オファリングは、アプリが実行時に購入可能なパックを取得するしくみで、
アプリを更新せずにあとからパックの並べ替えや入れ替えができます。
3. 各商品を GEM 通貨に関連付ける
GEM 通貨を開いて Add associated product をクリックし、ジェムパックの商品を選んで、
付与する数量を入力します。たとえば次のとおりです。
| 商品 | 付与量 |
|---|---|
gems_300 | 300 GEM |
gems_1200 | 1200 GEM |
gems_6500 | 6500 GEM |
これ以降、ユーザーがこれらの商品を購入するたびに、RevenueCat がストアのレシートを検証し、 設定した数量をジェム残高に自動的に加算します。加算のコードを書く必要はありません。
SDK をインストールして設定する
プラットフォーム向けの RevenueCat SDK をインストールし、アプリ起動時に公開 API キーで一度だけ設定します。
キーの接頭辞は、ビルドの配信先ストアによって変わります。Apple のキーは appl_、
Google のキーは goog_、Amazon は amzn_ で始まります。
クロスプラットフォーム SDK では、実行時にプラットフォームごとに適切なキーを選びます。
// GemStoreApp.swift
import SwiftUI
import RevenueCat
@main
struct GemStoreApp: App {
init() {
Purchases.logLevel = .debug
Purchases.configure(withAPIKey: "appl_YOUR_PUBLIC_SDK_KEY")
}
var body: some Scene {
WindowGroup {
GemStoreView()
}
}
}
// App.kt
import android.app.Application
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
class App : Application() {
override fun onCreate() {
super.onCreate()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration.Builder(this, "goog_YOUR_PUBLIC_SDK_KEY").build()
)
}
}
// main.dart
import 'dart:io' show Platform;
import 'package:purchases_flutter/purchases_flutter.dart';
Future<void> configureRevenueCat() async {
await Purchases.setLogLevel(LogLevel.debug);
final config = Platform.isIOS
? PurchasesConfiguration("appl_YOUR_PUBLIC_SDK_KEY")
: PurchasesConfiguration("goog_YOUR_PUBLIC_SDK_KEY");
await Purchases.configure(config);
}
// revenuecat.ts
import { Platform } from 'react-native';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
export function configureRevenueCat() {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
Purchases.configure({
apiKey: Platform.OS === 'ios' ? 'appl_YOUR_PUBLIC_SDK_KEY' : 'goog_YOUR_PUBLIC_SDK_KEY',
});
}
// commonMain
import com.revenuecat.purchases.kmp.LogLevel
import com.revenuecat.purchases.kmp.Purchases
// Each target supplies the key (appl_ on iOS, goog_ on Android).
expect val revenueCatApiKey: String
fun configureRevenueCat() {
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(apiKey = revenueCatApiKey)
}
ユーザーを識別する(残高はこれに紐づきます)
仮想通貨の残高は、App User ID で識別されるユーザーに属します。何もしなければ、
RevenueCat は匿名 ID を割り当てるので、そこで購入したジェムはその匿名ユーザーに紐づき、新しい端末や再インストール後のユーザーには引き継がれません。
ユーザーがサインインしたらすぐに、自前の安定したユーザー ID を渡して logIn を呼び出せば、
どこでもそのジェム残高がユーザーのものになります。
// After your own auth resolves a user id:
let (_, created) = try await Purchases.shared.logIn("your-stable-user-id")
print("RevenueCat customer ready (new customer: \(created))")
// Use this SAME id on your backend when you spend gems (see step 8).
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitLogIn
// Use this SAME id on your backend when you spend gems (see step 8).
// LogInResult is a Poko class (no destructuring); read the property.
val result = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = result.created
import 'package:purchases_flutter/purchases_flutter.dart';
// Use this SAME id on your backend when you spend gems (see step 8).
final LogInResult result = await Purchases.logIn("your-stable-user-id");
final bool created = result.created;
import Purchases from 'react-native-purchases';
// Use this SAME id on your backend when you spend gems (see step 8).
const { created } = await Purchases.logIn('your-stable-user-id');
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitLogIn
// Use this SAME id on your backend when you spend gems (see step 8).
val login = Purchases.sharedInstance.awaitLogIn("your-stable-user-id")
val created = login.created
ストアを構築する
gems オファリングを取得し、各パッケージをローカライズされた価格文字列とともに表示します。
RevenueCat は、ユーザーのストアフロント通貨に合わせて整形済みの価格を返すので、価格をハードコードしてはいけません。
ここで参照している buy 関数と refreshBalance 関数は、続く2つのステップで追加します。
// GemStoreModel.swift
import SwiftUI
import RevenueCat
@MainActor
final class GemStoreModel: ObservableObject {
@Published var packages: [Package] = []
@Published var gemBalance: Int = 0
@Published var errorMessage: String?
/// Load the purchasable gem packs.
func loadStore() async {
do {
let offerings = try await Purchases.shared.offerings()
// Named "gems" offering, or fall back to the current one.
let offering = offerings.all["gems"] ?? offerings.current
packages = offering?.availablePackages ?? []
} catch {
errorMessage = "Could not load the store: \(error.localizedDescription)"
}
}
// buy(_:) and refreshBalance() are added in the next steps.
}
// GemStoreView.swift
import SwiftUI
import RevenueCat
struct GemStoreView: View {
@StateObject private var model = GemStoreModel()
// A real two-way binding, so any dismissal clears the error state.
private var showError: Binding<Bool> {
Binding(get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } })
}
var body: some View {
NavigationStack {
List {
Section("Your balance") {
Label("\(model.gemBalance) gems", systemImage: "diamond.fill")
.font(.headline)
}
Section("Buy gems") {
ForEach(model.packages, id: \.identifier) { pkg in
Button {
Task { await model.buy(pkg) }
} label: {
HStack {
Text(pkg.storeProduct.localizedTitle)
Spacer()
Text(pkg.storeProduct.localizedPriceString)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle("Gem Store")
.task {
await model.loadStore()
await model.refreshBalance()
}
.alert("Something went wrong", isPresented: showError) {
Button("OK", role: .cancel) { }
} message: {
Text(model.errorMessage ?? "")
}
}
}
}
// GemStoreViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitOfferings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GemStoreViewModel : ViewModel() {
data class UiState(
val packages: List<Package> = emptyList(),
val gemBalance: Int = 0,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
init {
viewModelScope.launch {
val offerings = Purchases.sharedInstance.awaitOfferings()
val offering = offerings.all["gems"] ?: offerings.current
_state.update { it.copy(packages = offering?.availablePackages ?: emptyList()) }
refreshBalance()
}
}
// buy(...) and refreshBalance() are added in the next steps.
}
// GemStoreScreen.kt
import android.app.Activity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun GemStoreScreen(vm: GemStoreViewModel, activity: Activity) {
val ui by vm.state.collectAsState()
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
Text("${ui.gemBalance} gems", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
}
items(ui.packages, key = { it.identifier }) { pkg ->
ListItem(
headlineContent = { Text(pkg.product.title) },
trailingContent = { Text(pkg.product.price.formatted) },
modifier = Modifier.clickable { vm.buy(pkg, activity) }
)
}
}
}
// gem_store_page.dart
import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
class GemStorePage extends StatefulWidget {
const GemStorePage({super.key});
@override
State<GemStorePage> createState() => _GemStorePageState();
}
class _GemStorePageState extends State<GemStorePage> {
List<Package> _packages = [];
int _gemBalance = 0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final offerings = await Purchases.getOfferings();
final offering = offerings.all["gems"] ?? offerings.current;
if (mounted) setState(() => _packages = offering?.availablePackages ?? []);
await refreshBalance();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Gem Store')),
body: ListView(
children: [
ListTile(
title: Text('$_gemBalance gems',
style: Theme.of(context).textTheme.headlineSmall),
),
const Divider(),
for (final pkg in _packages)
ListTile(
title: Text(pkg.storeProduct.title),
trailing: Text(pkg.storeProduct.priceString),
onTap: () => buy(pkg),
),
],
),
);
}
// buy() and refreshBalance() are added in the next steps.
}
// GemStoreScreen.tsx
import React, { useEffect, useState } from 'react';
import { FlatList, Text, TouchableOpacity, View } from 'react-native';
import Purchases, { PurchasesPackage } from 'react-native-purchases';
export function GemStoreScreen() {
const [packages, setPackages] = useState<PurchasesPackage[]>([]);
const [gemBalance, setGemBalance] = useState(0);
useEffect(() => {
(async () => {
const offerings = await Purchases.getOfferings();
const offering = offerings.all['gems'] ?? offerings.current;
setPackages(offering?.availablePackages ?? []);
await refreshBalance(setGemBalance);
})();
}, []);
return (
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 22, fontWeight: '600' }}>{gemBalance} gems</Text>
<FlatList
data={packages}
keyExtractor={(p) => p.identifier}
renderItem={({ item: pkg }) => (
<TouchableOpacity
onPress={() => buy(pkg, gemBalance, setGemBalance)}
style={{ flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 12 }}
>
<Text>{pkg.product.title}</Text>
<Text style={{ color: '#888' }}>{pkg.product.priceString}</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
// GemStoreScreen.kt (commonMain, Compose Multiplatform)
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitOfferings
import com.revenuecat.purchases.kmp.models.Package
import kotlinx.coroutines.launch
@Composable
fun GemStoreScreen() {
val scope = rememberCoroutineScope()
var packages by remember { mutableStateOf<List<Package>>(emptyList()) }
var gemBalance by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
val offerings = Purchases.sharedInstance.awaitOfferings()
val offering = offerings.all["gems"] ?: offerings.current
packages = offering?.availablePackages ?: emptyList()
gemBalance = refreshBalance()
}
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
Text("$gemBalance gems", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
}
items(packages, key = { it.identifier }) { pkg ->
ListItem(
headlineContent = { Text(pkg.storeProduct.title) },
trailingContent = { Text(pkg.storeProduct.price.formatted) },
modifier = Modifier.clickable { scope.launch { gemBalance = buy(pkg, gemBalance) } }
)
}
}
}
ジェムを購入する
選択したパッケージで purchase API を呼び出します。RevenueCat はストアの購入を実行してレシートを検証し、
その商品が GEM に関連付けられているため、自身のサーバー上でジェムを自動的に加算します。
やることは、キャンセルを処理してから残高を更新するだけです。
// GemStoreModel.swift (continued)
extension GemStoreModel {
func buy(_ package: Package) async {
do {
let result = try await Purchases.shared.purchase(package: package)
guard !result.userCancelled else { return } // user closed the sheet
// Gems are credited on RevenueCat's servers a beat after purchase()
// returns, so poll briefly until the new balance lands.
let before = gemBalance
for _ in 0..<5 {
await refreshBalance()
if gemBalance > before { break }
try? await Task.sleep(for: .milliseconds(500))
}
} catch {
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
// Add to GemStoreViewModel
import android.app.Activity
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.awaitPurchase
import kotlinx.coroutines.delay
fun buy(pkg: Package, activity: Activity) = viewModelScope.launch {
try {
Purchases.sharedInstance.awaitPurchase(
PurchaseParams.Builder(activity, pkg).build()
)
// Poll briefly until the credited balance lands.
val before = state.value.gemBalance
repeat(5) {
refreshBalance()
if (state.value.gemBalance > before) return@launch
delay(500)
}
} catch (e: PurchasesException) {
if (e.code == PurchasesErrorCode.PurchaseCancelledError) return@launch
_state.update { it.copy(error = "Purchase failed: ${e.message}") }
}
}
// Add to _GemStorePageState
import 'package:flutter/services.dart' show PlatformException;
Future<void> buy(Package package) async {
try {
await Purchases.purchasePackage(package);
// Poll briefly until the credited balance lands.
final before = _gemBalance;
for (var i = 0; i < 5; i++) {
await refreshBalance();
if (_gemBalance > before) break;
await Future.delayed(const Duration(milliseconds: 500));
}
} on PlatformException catch (e) {
final code = PurchasesErrorHelper.getErrorCode(e);
if (code == PurchasesErrorCode.purchaseCancelledError) return; // closed the sheet
// surface "Purchase failed" as you prefer
}
}
import Purchases, { PurchasesPackage, PURCHASES_ERROR_CODE } from 'react-native-purchases';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function buy(
pkg: PurchasesPackage,
before: number,
setGemBalance: (n: number) => void,
) {
try {
await Purchases.purchasePackage(pkg);
// Poll briefly until the credited balance lands.
for (let i = 0; i < 5; i++) {
const balance = await refreshBalance(setGemBalance);
if (balance > before) break;
await sleep(500);
}
} catch (e: any) {
if (e.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) return; // closed the sheet
// surface "Purchase failed" as you prefer
}
}
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.PurchasesException
import com.revenuecat.purchases.kmp.ktx.awaitPurchase
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
import kotlinx.coroutines.delay
suspend fun buy(pkg: Package, before: Int): Int {
return try {
Purchases.sharedInstance.awaitPurchase(packageToPurchase = pkg)
// Poll briefly until the credited balance lands.
var balance = before
repeat(5) {
balance = refreshBalance()
if (balance > before) return balance
delay(500)
}
balance
} catch (e: PurchasesException) {
if (e.error.code == PurchasesErrorCode.PurchaseCancelledError) before // closed the sheet
else before // surface "Purchase failed" as you prefer
}
}
buy は一度だけ読み取るのではなくポーリングします。アプリがフォアグラウンドに戻ったときにも残高を更新しておけば、
取りこぼした更新が自然に修復されます。同じ更新は、完了した購入がユーザーキャンセルとして報告されるまれなケースもカバーします。
残高を表示する
virtual currencies API で残高を読み取り、all マップからコードを使って対象の通貨を取り出します。
残高は SDK によってキャッシュされ、自動では更新されません。そのため、残高を変える操作(購入やバックエンドの消費)のあとは、
キャッシュを無効化して再取得してください。
// GemStoreModel.swift (continued)
extension GemStoreModel {
/// Invalidate first so we never show a stale value after a purchase or spend.
func refreshBalance() async {
do {
Purchases.shared.invalidateVirtualCurrenciesCache()
let currencies = try await Purchases.shared.virtualCurrencies()
gemBalance = currencies.all["GEM"]?.balance ?? 0
} catch {
errorMessage = "Could not load your balance: \(error.localizedDescription)"
}
}
}
// Add to GemStoreViewModel
import com.revenuecat.purchases.awaitGetVirtualCurrencies
suspend fun refreshBalance() {
try {
Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
val currencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()
_state.update { it.copy(gemBalance = currencies.all["GEM"]?.balance ?: 0) }
} catch (e: Exception) {
_state.update { it.copy(error = "Could not load your balance: ${e.message}") }
}
}
// Add to _GemStorePageState
Future<void> refreshBalance() async {
try {
await Purchases.invalidateVirtualCurrenciesCache();
final currencies = await Purchases.getVirtualCurrencies();
if (mounted) setState(() => _gemBalance = currencies.all["GEM"]?.balance ?? 0);
} catch (e) {
// surface error as you prefer
}
}
import Purchases from 'react-native-purchases';
export async function refreshBalance(
setGemBalance: (n: number) => void,
): Promise<number> {
await Purchases.invalidateVirtualCurrenciesCache();
const currencies = await Purchases.getVirtualCurrencies();
const balance = currencies.all['GEM']?.balance ?? 0;
setGemBalance(balance);
return balance;
}
// commonMain
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitVirtualCurrencies
suspend fun refreshBalance(): Int {
Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
val currencies = Purchases.sharedInstance.awaitVirtualCurrencies()
return currencies.all["GEM"]?.balance ?: 0
}
最初の描画を速くしたい場合は、ネットワークの応答が返る前にキャッシュ値を読み取れます。
// iOS exposes a synchronous cached property (nil before the first fetch):
let cached = Purchases.shared.cachedVirtualCurrencies?.all["GEM"]?.balance
// Android also exposes a synchronous cached property (null before the first fetch):
val cached = Purchases.sharedInstance.cachedVirtualCurrencies?.all["GEM"]?.balance
// Flutter's cached accessor is an async method (not a property):
final cached = (await Purchases.getCachedVirtualCurrencies())?.all["GEM"]?.balance;
// React Native's cached accessor is an async method (not a property):
const cached = (await Purchases.getCachedVirtualCurrencies())?.all['GEM']?.balance;
// KMP's cached accessor is a method (not a property):
val cached = Purchases.sharedInstance.getCachedVirtualCurrencies()?.all["GEM"]?.balance
getCachedVirtualCurrencies())です。
各 VirtualCurrency は balance のほかに code・name・serverDescription も持つので、
アプリ内で「Gems」をハードコードせず、ダッシュボードからラベルを反映できます。
ジェムを消費する (バックエンド)
ジェムの消費は、RevenueCat の Secret API キー(sk_ で始まります)を使って、
自分が管理するサーバーで行う必要があります。シークレットキーは残高を動かせるので、アプリに同梱してはいけません。
流れは次のとおりです。アプリがバックエンドに消費を依頼し、バックエンドが RevenueCat を通じて差し引きます。
ユーザーが支払えたかどうかは、この差し引きの成否そのものが決めます。
差し引きエンドポイント(バックエンド)
ユーザーの virtual currency transactions エンドポイントに adjustments マップを POST します。負の数で消費し、正の数で付与します。
マップ全体はアトミックに適用されます。マップ内のいずれかの通貨で残高が足りなければ、何も差し引かれず、
RevenueCat は HTTP 422 を返します。価格はアイテム名からサーバーが決めます。
クライアントが送ってきた金額を差し引くことはありません。この部分は、アプリのプラットフォームに関係なく共通です。
// POST /spend (Node serverless handler)
// Env: RC_SECRET_KEY (sk_...), RC_PROJECT_ID. Never expose these to the client.
// The server owns the price list. Never deduct an amount the client sends:
// a tampered client could buy a 5000-gem item for 1 gem.
const GEM_PRICES = { extra_life: 50, legendary_skin: 5000 };
export async function POST(req) {
const { item, idempotencyKey } = await req.json();
// Derive the customer from the authenticated session, NOT from the request body.
// The client must not be able to spend another user's gems by sending their id.
const appUserId = await getUserIdFromSession(req); // your auth
const cost = GEM_PRICES[item];
if (!Number.isInteger(cost) || cost <= 0) {
return Response.json({ error: "Unknown item" }, { status: 400 });
}
const url =
`https://api.revenuecat.com/v2/projects/${process.env.RC_PROJECT_ID}` +
`/customers/${encodeURIComponent(appUserId)}/virtual_currencies/transactions`;
const headers = {
Authorization: `Bearer ${process.env.RC_SECRET_KEY}`,
"Content-Type": "application/json",
// The SAME key on a retry is applied once, so a network retry can't double-spend.
"Idempotency-Key": idempotencyKey,
};
const rcRes = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ adjustments: { GEM: -cost } }),
});
// 422 = balance too low. The deduction is atomic, so nothing was taken.
if (rcRes.status === 422) {
return Response.json({ error: "Not enough gems" }, { status: 402 });
}
if (!rcRes.ok) {
return Response.json({ error: "Spend failed" }, { status: 502 });
}
// The gems are gone, atomically. Grant the item, and refund if that fails.
try {
await grantItemToUser(appUserId, item);
} catch (e) {
// Compensate so we never charge for nothing.
await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ adjustments: { GEM: cost } }),
});
return Response.json({ error: "Could not grant item, gems refunded" }, { status: 500 });
}
return Response.json({ ok: true });
}
GET .../virtual_currencies の読み取りは、残高の表示だけに使ってください。
アプリから呼び出して 402 に対応する
ここでの 402("Payment Required")は、バックエンドが自身のクライアントに返すアプリケーション独自の取り決めです。 RevenueCat の 422 とは別物で、「RevenueCat が残高不足と言っている」ことを、アプリが理解できるステータスに翻訳しているだけです。
// GemStoreModel.swift (continued)
struct SpendRequest: Encodable {
let item: String
let idempotencyKey: String
}
enum SpendError: Error { case insufficientGems, failed }
extension GemStoreModel {
// Create `idempotencyKey` once when the user taps buy, and reuse the SAME key on
// every retry of this spend. A fresh UUID per attempt gives no double-spend safety.
func spend(on item: String, idempotencyKey: String) async {
do {
var req = URLRequest(url: URL(string: "https://your-api.example.com/spend")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(yourSessionToken)", forHTTPHeaderField: "Authorization")
// The server looks up the price from the item; the client never sends a cost.
req.httpBody = try JSONEncoder().encode(
SpendRequest(item: item, idempotencyKey: idempotencyKey)
)
let (_, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 500
if status == 402 { throw SpendError.insufficientGems }
guard (200..<300).contains(status) else { throw SpendError.failed }
await refreshBalance()
} catch SpendError.insufficientGems {
errorMessage = "You need more gems for that."
} catch {
errorMessage = "Could not complete that purchase."
}
}
}
// Add to GemStoreViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val http = OkHttpClient()
// Create idempotencyKey once per spend intent and reuse it on every retry.
fun spend(item: String, idempotencyKey: String) = viewModelScope.launch {
try {
val ok = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("item", item) // server looks up the price; client never sends a cost
.put("idempotencyKey", idempotencyKey)
.toString()
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("https://your-api.example.com/spend")
.header("Authorization", "Bearer $yourSessionToken") // app token, NOT the RC key
.post(body)
.build()
http.newCall(req).execute().use { res ->
if (res.code == 402) return@withContext false
if (!res.isSuccessful) throw IllegalStateException("Spend failed")
true
}
}
if (!ok) {
_state.update { it.copy(error = "You need more gems for that.") }
return@launch
}
refreshBalance()
} catch (e: Exception) {
_state.update { it.copy(error = "Could not complete that purchase.") }
}
}
// Add to _GemStorePageState
import 'dart:convert';
import 'package:http/http.dart' as http;
// Create idempotencyKey once per spend intent and reuse it on every retry.
Future<void> spend(String item, String idempotencyKey) async {
try {
final res = await http.post(
Uri.parse('https://your-api.example.com/spend'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $yourSessionToken', // app token, NOT the RC key
},
body: jsonEncode({'item': item, 'idempotencyKey': idempotencyKey}),
);
if (res.statusCode == 402) {
// show "You need more gems for that."
return;
}
if (res.statusCode ~/ 100 != 2) throw Exception('Spend failed');
await refreshBalance();
} catch (e) {
// show "Could not complete that purchase."
}
}
// Create idempotencyKey once per spend intent and reuse it on every retry.
export async function spend(
item: string,
idempotencyKey: string,
yourSessionToken: string,
setGemBalance: (n: number) => void,
) {
try {
const res = await fetch('https://your-api.example.com/spend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${yourSessionToken}`, // app token, NOT the RC key
},
body: JSON.stringify({ item, idempotencyKey }),
});
if (res.status === 402) {
// show "You need more gems for that."
return;
}
if (!res.ok) throw new Error('Spend failed');
await refreshBalance(setGemBalance);
} catch {
// show "Could not complete that purchase."
}
}
// commonMain (Ktor client)
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class SpendRequest(val item: String, val idempotencyKey: String)
// Create idempotencyKey once per spend intent and reuse it on every retry.
suspend fun spend(
client: HttpClient,
item: String,
idempotencyKey: String,
yourSessionToken: String,
): Boolean {
val res: HttpResponse = client.post("https://your-api.example.com/spend") {
header(HttpHeaders.Authorization, "Bearer $yourSessionToken") // app token, NOT the RC key
contentType(ContentType.Application.Json)
setBody(Json.encodeToString(SpendRequest(item, idempotencyKey)))
}
return when {
res.status.value == 402 -> false
res.status.isSuccess() -> { refreshBalance(); true }
else -> throw IllegalStateException("Spend failed")
}
}
エンドツーエンドでテストする
購入して残高が増えるのを確認する
- ストアのテスト環境でアプリを実行します(iOS: Xcode の StoreKit 構成ファイル、またはサンドボックステスター。Android: internal testing 上のライセンステスター)。
- サインインして安定した App User ID を確保し、ジェムストアを開きます。
gems_1200を購入します。付与が反映されると、残高が 1200 増えるはずです。加算は購入完了の直後に適用されるため、buy()のポーリングがそれを待ちます。- ダッシュボードで付与を確認します。Customers を開いて App User ID を見つけ、仮想通貨の残高と取引履歴を確認します。
消費して残高不足の経路を試す
- アイテムにジェムを消費し、残高が減るのを確認します。
- 保有量を超える消費を試します。バックエンドは RevenueCat から 422 を受け取り、アプリに 402 を返し、アプリは残高を変えずに「You need more gems」と表示するはずです。
VIRTUAL_CURRENCY_TRANSACTION
Webhook(source: in_app_purchase 付き)を発行します。これは残高の読み取り専用のコピーをデータウェアハウスに同期するのに便利です。
信頼できる情報源は RevenueCat のままにしてください。この Webhook はレポート用であって、書き込む2つ目の残高のためのものではありません。
まとめと次のステップ
構築したもの
GEM 仮想通貨を作成し、検証済みの購入で残高に自動加算される消費型のジェムパックを販売し、
プラットフォーム上で SDK からリアルタイムの残高を表示し、Secret API キーを使ってバックエンドから安全にジェムを消費しました。
残高不足を判定する信頼できるガードには、RevenueCat のアトミックな 422 を使いました。
自前の UI が、RevenueCat の決済エンジンと残高の上に載っている構成です。
信頼できる唯一の情報源のチェックリスト
- 各通貨には、信頼できる情報源が1つだけある。ここでは、ジェムを最初から最後まで RevenueCat が管理する。
- アプリは SDK から残高を読み取るだけで、直接書き込まない。
- 付与は購入時に自動で行われ、消費はシークレットキーを使ってバックエンド経由で行う。
- 同じ App User ID が、クライアント、残高、バックエンド呼び出しを結び付ける。
- 自前のサーバーでソフトカレンシーも運用しているなら、それはそのまま残す。1つの通貨を2つのシステムにミラーリングしない。
さらに進む
- このシリーズの次回: ハードカレンシーを、自前のサーバー上のソフトカレンシーに交換します。冪等キーと補償(saga)パターンを使い、途中の失敗で通貨が失われたり重複したりしないようにします。
- 自分のプラットフォームで RevenueCat が初めてなら、まず IAP の基礎から始めてください: iOS、Android、Flutter、React Native、Kotlin Multiplatform。
- ユーザーの識別と CustomerInfo の取得: ここで使ったアイデンティティの構成要素です。
- RevenueCat: Virtual Currencies と 残高の信頼できる情報源ガイド。