The Problem You're Actually Solving

You've built the coin shop. The backend endpoint that grants coins after a purchase looks simple enough — until it isn't. Your server webhook fires, coins are added, but the receipt was already processed twice. Or the user force-quits the app mid-transaction, leaving them with the item and their coins. Or a subscription renewal fires at 3 AM and your balance table goes negative because of a race condition.

Managing virtual currency balances is a classic distributed-systems trap: multiple writers, no single clock, and real money on the line. RevenueCat's Virtual Currency feature is built to absorb most of that complexity — validating purchases, atomically adjusting balances, handling subscription renewal cycles, and exposing webhooks when things change. This guide covers the full lifecycle, from dashboard setup to production-safe spend flows, for Android and iOS developers.


1. Dashboard Setup: Defining Your Currencies

Virtual currencies are defined at the project level in RevenueCat and you can create up to 100 per project. Source

Create a currency

  1. In the RevenueCat dashboard, open your project and go to Product catalog → Virtual Currencies.
  2. Click + New virtual currency.
  3. Fill in the required fields:
  4. Code — the programmatic identifier used in all API calls (e.g., GLD).
  5. Name — a human-readable label (e.g., Gold).
  6. Description (optional) — shown in the dashboard and customer timeline.
  7. Icon (optional) — a visual representation in the dashboard.

  8. Click Add associated product to link one or more store products. Every time a customer successfully purchases a linked product, RevenueCat automatically credits the configured amount to their balance. A single product can grant multiple currencies simultaneously. Source

  9. Hit Save.

Balance limits: A customer's balance for any single currency must stay between 0 and 2,000,000,000. Negative balances are not supported. Source


2. The Most Important Decision: Source of Truth

Before you write a single line of SDK code, answer this question: where does the canonical balance live? RevenueCat offers two patterns. Source

RevenueCat validates purchases, grants coins automatically, deducts balances atomically, and your SDK reads the balance directly. No custom balance table required.

Step Who does the work
Customer buys a coin pack RevenueCat validates receipt + credits balance
App reads balance SDK → virtualCurrencies()
Customer spends coins Your backend → POST /virtual_currencies/transactions to RevenueCat
App re-reads balance SDK → invalidate cache → virtualCurrencies()

Option B: Your Backend as Source of Truth (For existing systems)

If you already have a balance table, keep it. Use RevenueCat webhooks (VIRTUAL_CURRENCY_TRANSACTION) to learn how much to credit, and let your backend be the authoritative store. Your app fetches balances from your backend; spending bypasses RevenueCat entirely. Source

Warning: With Option B, RevenueCat's dashboard balance will drift out of sync as spending events occur outside RevenueCat. That's expected and acceptable, but be aware when using RevenueCat's customer timeline for support. Source

Choose Option A if you're starting fresh. The rest of this guide focuses on that path.


3. Reading Balances in the SDK

Supported SDK versions

SDK Minimum Version
iOS 5.32.0+
Android 9.1.0+
React Native 9.1.0+
Flutter 9.1.0+
Unity 8.1.0+
KMP 2.1.0+ / 16.2.0+

Source

Fetching the balance

Swift (iOS)

// Fetch virtual currencies from RevenueCat
Purchases.shared.virtualCurrencies { virtualCurrencies, error in
    guard let virtualCurrencies = virtualCurrencies else {
        print("Error fetching currencies: \(error?.localizedDescription ?? "unknown")")
        return
    }
    // Access individual currency balance by code
    if let gold = virtualCurrencies["GLD"] {
        print("Gold balance: \(gold.balance)")
    }
}

Kotlin (Android)

Purchases.sharedInstance.getVirtualCurrencies(
    onSuccess = { virtualCurrencies ->
        val goldBalance = virtualCurrencies["GLD"]?.balance ?: 0
        Log.d("Coins", "Gold balance: $goldBalance")
    },
    onError = { error ->
        Log.e("Coins", "Failed to fetch currencies: ${error.message}")
    }
)

The cache pitfall ⚠️

virtualCurrencies() returns a cached value. When your backend updates the balance (e.g., after a purchase or spend), the cached object does not auto-refresh. You must explicitly invalidate it. Source

Swift

// After a purchase completes OR after a spend call returns:
Purchases.shared.invalidateVirtualCurrenciesCache()

// Now safe to fetch fresh data
Purchases.shared.virtualCurrencies { currencies, error in
    // This will reflect the updated balance
}

Kotlin

// Always invalidate before re-fetching post-purchase or post-spend
Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
Purchases.sharedInstance.getVirtualCurrencies(
    onSuccess = { currencies -> updateUI(currencies) },
    onError = { error -> handleError(error) }
)

For rendering UI immediately (e.g., while loading), use the synchronous cachedVirtualCurrencies property. Treat it as a best-effort snapshot, not authoritative data. Source


4. Granting Currency on Purchase

When a customer purchases a product associated with your currency, RevenueCat grants the coins automatically — no backend code required for the grant. This is the core value proposition of Option A. Source

Swift — trigger a purchase

// Assume `package` is fetched from Offerings
Purchases.shared.purchase(package: package) { transaction, customerInfo, error, userCancelled in
    guard error == nil, !userCancelled else { return }

    // RevenueCat has already credited the coins server-side.
    // Invalidate the cache so the next fetch is fresh.
    Purchases.shared.invalidateVirtualCurrenciesCache()

    // Update your UI
    self.refreshBalanceDisplay()
}

Kotlin

Purchases.sharedInstance.purchaseWith(
    purchaseParams = PurchaseParams.Builder(activity, packageToPurchase).build(),
    onError = { error, userCancelled -> /* handle */ },
    onSuccess = { storeTransaction, customerInfo ->
        // Coins already credited by RevenueCat. Refresh cache.
        Purchases.sharedInstance.invalidateVirtualCurrenciesCache()
        refreshBalanceDisplay()
    }
)

5. Spending / Deducting Currency

Spend calls must originate from your backend, never from the client directly. This is a security requirement: the API endpoint requires a secret key, which must never be embedded in a mobile app. Source

The spend flow

  1. Your app sends a spend request to your backend (e.g., POST /api/use-coins).
  2. Your backend calls the RevenueCat Developer API v2.
  3. RevenueCat validates the balance and atomically deducts.
  4. Your backend returns success/failure to the app.
  5. The app invalidates the SDK cache and re-fetches.

Backend API call

curl --request POST \
  'https://api.revenuecat.com/v2/projects/<PROJECT_ID>/customers/<APP_USER_ID>/virtual_currencies/transactions' \
  --header 'Authorization: Bearer sk_YOUR_SECRET_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "adjustments": {
      "GLD": -20,
      "SLV": -10
    }
  }'

Source

Atomicity guarantee: If the customer lacks sufficient balance in any one of the specified currencies, the entire transaction fails with HTTP 422 and no currency is deducted from any currency. This is the crash-safety mechanism — you never end up in a partial-deduction state. Source

Rate limits

The Developer API enforces 480 requests per minute. If exceeded, the API returns 429 Too Many Requests with a Retry-After header. Implement exponential backoff on your backend for this endpoint. Source


6. Subscriptions and the Auto-Refill Feature

Subscriptions can grant virtual currency on a recurring billing cycle — for example, 100 credits delivered every month. RevenueCat automatically tracks renewals and deposits currency when a successful renewal is detected. Source

Setting up subscription currency

In the RevenueCat dashboard, when configuring your virtual currency, associate a subscription product and set the grant amount. RevenueCat handles the rest: initial purchase, monthly renewals, refunds, and upgrade/downgrade proration.

Free trial grants

You can configure a separate, smaller grant for the free trial period. For example: 25 trial credits vs. 100 paid credits per cycle. This gives users a taste without the full allocation. Trial credits are granted when the trial begins, and — critically — they do not expire when the trial ends, even if the user never converts. Source

Product change proration

When a subscriber upgrades or downgrades, RevenueCat calculates the prorated currency grant based on the amount the customer is actually charged, not the full subscription price. This prevents over-granting on downgrades or mid-cycle plan switches.

App Store behavior (key rules): - All interchangeable subscriptions must be in the same subscription group in App Store Connect. - Upgrades: currency granted immediately, prorated to what was charged. - Downgrades: currency grant deferred until the next renewal when the charge occurs.

Source

Play Store behavior: Grant timing depends on the ReplacementMode you pass in the SDK. CHARGE_FULL_PRICE and CHARGE_PRORATED_PRICE grant immediately at charge time. WITHOUT_PRORATION and WITH_TIME_PRORATION defer until the customer is billed. Source


7. Expiring Currencies

Subscription-granted currencies can be configured to auto-expire at the end of the billing cycle — useful for "use it or lose it" token models, seasonal campaigns, or preventing runaway balance accumulation.

Apple compliance note: Currencies granted via one-time in-app purchases cannot expire, per Apple's App Store Review Guidelines §3.1.1. Only subscription-granted currency can have expiration enabled. Source

Enable expiration in the currency's dashboard settings with the "Auto-expire at the end of billing cycle" toggle.

Deduction order (automatic)

When a customer spends currency, RevenueCat automatically deducts in this priority order — no code changes needed:

  1. Expiring currencies first (to minimize waste).
  2. Soonest-expiring first among those.
  3. Non-expiring currency last.

Source

Example: A user has 1,000 subscription credits (expires March 31) + 500 purchased credits (never expires). If they spend 750, all 750 come from the subscription pool. They end the day with 250 expiring + 500 permanent = 750 total.


8. Modeling Virtual Items with Multiple Currencies

RevenueCat doesn't have a native "inventory item" primitive, but you can model a small, fixed set of items by treating each item type as its own virtual currency. Source

Limit: You can configure up to 100 virtual currencies per project. Source

Example: Coins, Tickets, and Passes

Configure three currencies: COINS, TICKETS, PASSES. When a player "buys" a ticket for 2 coins, your backend calls a single atomic transaction:

curl --request POST \
  'https://api.revenuecat.com/v2/projects/<PROJECT_ID>/customers/<APP_USER_ID>/virtual_currencies/transactions' \
  --header 'Authorization: Bearer sk_YOUR_SECRET_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "adjustments": {
      "COINS": -2,
      "TICKETS": 1
    }
  }'

If the player has fewer than 2 COINS, the entire call fails (422) and their TICKETS balance is unchanged. No partial state, no orphaned inventory. Source


9. Webhooks and Event Monitoring

RevenueCat emits VIRTUAL_CURRENCY_TRANSACTION webhook events for:

  • One-time purchase completions
  • Subscription initial purchases, renewals, refunds, and lifecycle changes
  • Manual dashboard adjustments

Source

Note: Adjustments made via the Developer API (spend calls) appear in the customer timeline but do not generate webhook events. Source

Webhook payload (multi-currency example)

{
  "event": "VIRTUAL_CURRENCY_TRANSACTION",
  "virtual_currency_transaction_id": "txn_abc123",
  "source": "in_app_purchase",
  "purchase_environment": "PRODUCTION",
  "adjustments": [
    {
      "amount": 1000,
      "currency": { "code": "GLD", "name": "Gold", "description": "Premium currency" }
    },
    {
      "amount": 50,
      "currency": { "code": "SLV", "name": "Silver", "description": "Standard currency" }
    }
  ]
}

The source field distinguishes between in_app_purchase (automated) and admin_api (manual dashboard adjustment). Use this for fraud monitoring and audit reconciliation. Source


10. Testing Safely

Sandbox settings

In your RevenueCat project's General settings, configure the "Allow testing entitlements and virtual currency for" option:

Setting Effect
Anybody (default) All sandbox/Test Store purchases grant currency
Allowed App User IDs only Only allowlisted test user IDs receive currency
Nobody Sandbox purchases never grant currency

Source

Important: If you change this setting to restrict access, previously granted sandbox currency is NOT removed from existing balances. Only newly purchased sandbox products will be affected going forward. Source

Testing checklist

  • [ ] Purchase → balance grant: Make a sandbox purchase. Call invalidateVirtualCurrenciesCache(). Verify virtualCurrencies() reflects the new balance.
  • [ ] Spend → atomic failure: Attempt a spend larger than the balance. Confirm 422 response and balance is unchanged.
  • [ ] Multi-currency spend: Spend two currencies in one call. Confirm both deduct only if both have sufficient balance.
  • [ ] Subscription renewal: Use Apple Sandbox's accelerated renewal. Confirm currency is re-credited on each renewal.
  • [ ] Upgrade/downgrade: Change plans and verify prorated currency grant, not full amount.
  • [ ] Cache staleness: Read cachedVirtualCurrencies, make a backend spend, read it again without invalidating — confirm it's stale. Then invalidate and confirm it's fresh.

11. Security Reminders

  • Never embed your RevenueCat secret key in your mobile app. The spend endpoint must be called from your server. Source
  • Use RevenueCat's OAuth scopes (customer_information:purchases:read_write) to grant minimum-required permissions to service accounts. Source
  • Monitor the customer timeline and VIRTUAL_CURRENCY_TRANSACTION events for anomalous patterns (large grants, repeated spend-then-purchase cycles). Source

Quick-Reference Architecture Diagram

┌─────────────────────────────────────────────────────────┐
│                    PURCHASE FLOW                        │
│  Mobile App ──purchase()──► RevenueCat SDK              │
│                                ├──► Store Validation    │
│                                └──► Balance +credited   │
│  Mobile App ◄──invalidate cache + re-fetch balance──   │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                     SPEND FLOW                          │
│  Mobile App ──POST /spend──► Your Backend               │
│                                └──► RC API POST /txns   │
│                                      ├─ 200: deducted   │
│                                      └─ 422: no change  │
│  Mobile App ◄──result──Your Backend                     │
│  Mobile App: invalidate cache + re-fetch balance        │
└─────────────────────────────────────────────────────────┘

Sources