What You'll Build
Many apps run on two kinds of currency: a soft currency earned through play (coins, energy, XP) and a hard currency bought with real money (gems, credits, tokens). This codelab builds the hard-currency side: a "gems" store, powered by RevenueCat Virtual Currencies. It covers iOS, Android, Flutter, React Native, and Kotlin Multiplatform.
By the end you will have:
- A
GEMvirtual currency in RevenueCat, fed by consumable in-app purchases. - A native store screen that lists gem packs at their localized store prices.
- Purchases that auto-credit the gem balance after RevenueCat validates the receipt.
- A live gem balance shown in the app, read from the SDK.
- A backend "spend" endpoint that deducts gems with the Secret API key, with insufficient balance handled cleanly.
The first decision: where does each currency live?
Before any code, decide your source of truth for balances. Most teams arrive here already running a soft currency on their own server, and ask whether to keep both currencies in one place. There are two shapes:
| Approach | Soft currency | Hard currency |
|---|---|---|
| A. Split (recommended for this case) | Your own server | RevenueCat |
| B. Unified | RevenueCat | RevenueCat |
If you already have a working soft-currency server, approach A is the lower risk: you keep the high-frequency, gameplay-driven soft balance where it already works, and let RevenueCat own the hard currency, where it adds the most value: receipt validation, automatic crediting, automatic reversal on refund, atomic spends, and an audit trail. There is no migration of your existing soft balance.
Approach B (RevenueCat as the single source of truth for both) is what RevenueCat recommends for greenfield apps with no existing balance storage. It is the simplest model when you are starting fresh, because every balance lives in one system.
We will use RevenueCat's payment engine and balance, with your own native store UI (no RevenueCat Paywall UI required). That keeps your gem storefront visually consistent with the soft-currency store you already designed.
Who this is for, and what you need
Mobile developers adding a consumable hard currency to a game or social app. You should be comfortable with your platform's UI and async model, and have an app set up in App Store Connect or Google Play (real, or a StoreKit configuration file / license tester for local testing). Virtual Currencies needs a recent SDK:
| Platform | SDK | Minimum version |
|---|---|---|
| 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 |
Create a Virtual Currency
In the RevenueCat dashboard, open your project's Product catalog and select Virtual Currencies, then + New virtual currency. Two fields matter:
- Code: the identifier you use in the SDK and API, for example
GEM. Pick it carefully, you will reference it in code. - Name: the display name, for example
Gems.
You can also add an optional icon and description. Save, and the currency exists with a starting balance of 0 for every customer.
Create Gem-Pack Products and Grant Amounts
Gems are bought with real money, so each gem pack is a consumable in-app purchase. This is all dashboard and store configuration, no code yet.
1. Create consumable products in your store
In App Store Connect and/or the Google Play Console, create one consumable product per pack, for example
gems_300, gems_1200, and gems_6500, each with its own price tier.
Consumables can be bought repeatedly, which is exactly what a currency top-up needs.
2. Import the products and put them in an offering
In RevenueCat, add those products under your app, then create an offering (for example
gems) and add each product as a package. The offering is how your app fetches the
purchasable packs at runtime, and it lets you reorder or swap packs later without an app update.
3. Associate each product with the GEM currency
Open your GEM currency, click Add associated product, pick a gem-pack product,
and enter the amount it grants. For example:
| Product | Grants |
|---|---|
gems_300 | 300 GEM |
gems_1200 | 1200 GEM |
gems_6500 | 6500 GEM |
From now on, every time a customer buys one of these products, RevenueCat validates the store receipt and automatically adds the configured amount to their gem balance. You write no crediting code.
Install and Configure the SDK
Install the RevenueCat SDK for your platform, then configure it once at app launch with your public API key.
The key prefix depends on the store the build ships to: Apple keys start with appl_,
Google keys with goog_, Amazon with amzn_. On the
cross-platform SDKs you select the right key at runtime by platform.
// 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)
}
Identify the user (this is what the balance is attached to)
A virtual currency balance belongs to a customer, identified by the App User ID. If you do
nothing, RevenueCat assigns an anonymous ID; gems bought then live on that anonymous customer and will not
follow the user to a new device or a fresh install. As soon as the user signs in, call logIn with
your own stable user id so the gem balance is theirs everywhere.
// 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
Build the Store
Fetch the gems offering and render each package with its localized price string.
RevenueCat returns prices already formatted for the user's storefront currency, so never hardcode a price.
The buy and refreshBalance functions referenced here are added in the next two steps.
// 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) } }
)
}
}
}
Buy Gems
Call the purchase API on the selected package. RevenueCat runs the store purchase, validates the receipt, and,
because the product is associated with GEM, credits the gems automatically on its
servers. Your job is just to handle cancellation and then refresh the balance.
// 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 polls instead of reading once. Refresh the balance on
app foreground too, so any missed update self-heals. That same refresh also covers the rare case where a
completed purchase reports user-cancelled.
Display the Balance
Read the balance with the virtual currencies API and pull your currency out of the all map by its
code. The balance is cached by the SDK and does not update on its own, so after anything that
changes it (a purchase, or a backend spend) invalidate the cache and fetch again.
// 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
}
For a fast first paint you can read the cached value before the network returns:
// 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()) on
Flutter, React Native, and KMP. Each
VirtualCurrency also carries code, name, and
serverDescription alongside balance, so you can drive labels from the dashboard
instead of hardcoding "Gems" in the app.
Spend Gems (Backend)
Spending gems must happen on a server you control, using the RevenueCat
Secret API key (it starts with sk_). The secret key can move balances, so it must
never ship in your app. The flow is: the app asks your backend to spend, your backend deducts through
RevenueCat, and the deduction itself is the authority on whether the user could afford it.
The deduction endpoint (your backend)
Post an adjustments map to the customer's virtual currency transactions endpoint. A negative
number spends, a positive number grants. The whole map is applied atomically: if any currency
in it lacks the balance, nothing is deducted and RevenueCat returns HTTP 422. The
server decides the price from the item name; it never deducts an amount the client sends. This
part is the same regardless of your app's platform.
// 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 read only to display the balance.
Call it from the app and react to 402
The 402 ("Payment Required") here is an application convention your backend returns to its own client. It is distinct from RevenueCat's 422; you are translating "RevenueCat says insufficient" into a status your app understands.
// 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")
}
}
Test End to End
Buy and see the balance grow
- Run the app against your store's test setup (iOS: a StoreKit configuration file in Xcode, or a sandbox tester; Android: a license tester on internal testing).
- Sign in so you have a stable App User ID, then open the gem store.
- Buy
gems_1200. The balance should climb by 1200 once the grant lands. The poll inbuy()waits for it, since the credit is applied a moment after the purchase completes. - Confirm the grant in the dashboard: open Customers, find your App User ID, and check the virtual currency balance and the transaction history.
Spend and hit the insufficient path
- Spend some gems on an item and watch the balance drop.
- Try to spend more than you hold. Your backend should receive a 422 from RevenueCat, return 402 to the app, and the app should show "You need more gems," with the balance unchanged.
VIRTUAL_CURRENCY_TRANSACTION webhook (with source: in_app_purchase), which is handy
for syncing a read-only copy of balances into your data warehouse. Keep RevenueCat as the source of truth; the
webhook is for reporting, not for a second balance you write to.
Recap & What's Next
What you built
You created a GEM virtual currency, sold consumable gem packs that auto-credit the balance on a
validated purchase, displayed the live balance from the SDK on your platform, and spent gems safely from a
backend using the Secret API key, with RevenueCat's atomic 422 as the authoritative insufficient-balance guard.
Your own UI sits on top of RevenueCat's payment engine and balance.
Source-of-truth checklist
- Each currency has exactly one source of truth. Here, RevenueCat owns gems end to end.
- The app reads the balance from the SDK and never writes it directly.
- Grants happen automatically on purchase; spends go through your backend with the secret key.
- The same App User ID links the client, the balance, and the backend call.
- If you also run a soft currency on your own server, keep it there. Do not mirror one currency into two systems.
Keep going
- Coming next in this series: exchanging hard currency for a soft currency that lives on your own server, with idempotency keys and a compensation (saga) pattern so a mid-flight failure never loses or duplicates currency.
- New to RevenueCat on your platform? Start with the IAP fundamentals: iOS, Android, Flutter, React Native, or Kotlin Multiplatform.
- Identify users and Get CustomerInfo: the identity building blocks used here.
- RevenueCat: Virtual Currencies and the balance source-of-truth guide.