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.

One codelab, every platform. Each client code block has a platform switcher. Pick your platform on any tab and every client snippet in the codelab switches with it, so you can read start to finish in one language. The backend and dashboard steps are the same for everyone.

By the end you will have:

  • A GEM virtual 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.

Pick one source of truth per currency, and do not split a single currency across systems. The danger case is keeping a currency's "real" balance on your server while mirroring it into RevenueCat (or vice versa). Two writers for one balance means drift and reconciliation headaches. This codelab follows approach A: RevenueCat owns the gem balance end to end.

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
iOSpurchases-ios5.32.0
Androidpurchases-android9.1.0
Flutterpurchases_flutter9.1.0
React Nativereact-native-purchases9.1.0
Kotlin Multiplatformpurchases-kmp2.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.

Good to know. A project supports up to 100 virtual currencies, a single balance can go up to 2,000,000,000, and balances can never go negative (a deduction that would cross zero is rejected, as you will see in the spend step). Virtual Currencies is part of the RevenueCat Pro plan.

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_300300 GEM
gems_12001200 GEM
gems_65006500 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.

Refunds reverse automatically. If a consumable purchase is refunded (configure your in-app purchase key so RevenueCat can detect refunds), RevenueCat removes a prorated amount of the granted currency, floored at 0 so the balance never goes negative. That refund-handling is one of the main reasons to let RevenueCat own the hard currency rather than crediting gems yourself from a purchase callback.

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.

Use the platform switcher below. Pick your platform on any code block and every client snippet in the rest of this codelab follows. (See the per-platform minimum SDK versions in step 1.)
swift
// 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()
        }
    }
}
kotlin
// 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()
        )
    }
}
dart
// 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);
}
typescript
// 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',
  });
}
kotlin
// 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.

swift
// 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).
kotlin
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
dart
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;
typescript
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');
kotlin
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
The App User ID is the join key. The same id ties together the client, the gem balance in RevenueCat, and the backend spend call. Use your backend user id, never an email (emails change). See the Identify users guide for login and logout details.

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.

swift
// 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.
}
swift
// 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 ?? "")
            }
        }
    }
}
kotlin
// 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.
}
kotlin
// 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) }
            )
        }
    }
}
dart
// 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.
}
tsx
// 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>
  );
}
kotlin
// 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) } }
            )
        }
    }
}
Your UI, RevenueCat's engine. This is the "engine only" path: you fetch products with the offerings API and render them however you like, so your gem store matches the soft-currency store you already built. The RevenueCat Paywalls UI SDK is optional and not used here.

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.

swift
// 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)"
        }
    }
}
kotlin
// 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}") }
    }
}
dart
// 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
  }
}
typescript
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
  }
}
kotlin
// 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
    }
}
Do not credit gems yourself in this callback. RevenueCat already granted them based on the validated receipt. Adding gems again here would double-credit and, worse, would not be reversed on a refund. Treat the SDK balance as the truth and only re-read it.
The balance is eventually consistent. The grant lands on RevenueCat's servers a moment after the purchase call returns, which is why 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.

swift
// 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)"
        }
    }
}
kotlin
// 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}") }
    }
}
dart
// 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
  }
}
typescript
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;
}
kotlin
// 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:

swift
// iOS exposes a synchronous cached property (nil before the first fetch):
let cached = Purchases.shared.cachedVirtualCurrencies?.all["GEM"]?.balance
kotlin
// Android also exposes a synchronous cached property (null before the first fetch):
val cached = Purchases.sharedInstance.cachedVirtualCurrencies?.all["GEM"]?.balance
dart
// Flutter's cached accessor is an async method (not a property):
final cached = (await Purchases.getCachedVirtualCurrencies())?.all["GEM"]?.balance;
typescript
// React Native's cached accessor is an async method (not a property):
const cached = (await Purchases.getCachedVirtualCurrencies())?.all['GEM']?.balance;
kotlin
// KMP's cached accessor is a method (not a property):
val cached = Purchases.sharedInstance.getCachedVirtualCurrencies()?.all["GEM"]?.balance
The cached accessor differs by platform. It is a synchronous property on iOS and Android, but an async method (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.

javascript
// 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 });
}
Let the 422 be your guard, not a pre-check. You might be tempted to read the balance first and compare. Do not rely on that: between the read and the write the balance can change (a classic time-of-check vs time-of-use race). The deduction is atomic and authoritative, so just attempt it and treat 422 as "insufficient." Use a 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.

swift
// 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."
        }
    }
}
kotlin
// 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.") }
    }
}
dart
// 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."
  }
}
typescript
// 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."
  }
}
kotlin
// 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")
    }
}
What if the deduction succeeds but granting the item fails? The backend above refunds the gems if the grant throws, which is enough when the item lives in your own database. When the deduction and the reward live in different systems (the classic "exchange gems for a soft currency on your server" case), that one-shot refund is not enough: you need a compensation or saga pattern with idempotency keys and a reconciliation job. That is exactly the subject of the next codelab in this series.

Test End to End

Buy and see the balance grow

  1. 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).
  2. Sign in so you have a stable App User ID, then open the gem store.
  3. Buy gems_1200. The balance should climb by 1200 once the grant lands. The poll in buy() waits for it, since the credit is applied a moment after the purchase completes.
  4. 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

  1. Spend some gems on an item and watch the balance drop.
  2. 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.
Mirror grants to your analytics, if you want. Purchase-driven grants emit a 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