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
- In the RevenueCat dashboard, open your project and go to Product catalog → Virtual Currencies.
- Click + New virtual currency.
- Fill in the required fields:
- Code — the programmatic identifier used in all API calls (e.g.,
GLD). - Name — a human-readable label (e.g.,
Gold). - Description (optional) — shown in the dashboard and customer timeline.
-
Icon (optional) — a visual representation in the dashboard.
-
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
-
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
Option A: RevenueCat as Source of Truth ✅ (Recommended for new apps)
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+ |
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
- Your app sends a spend request to your backend (e.g.,
POST /api/use-coins). - Your backend calls the RevenueCat Developer API v2.
- RevenueCat validates the balance and atomically deducts.
- Your backend returns success/failure to the app.
- 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
}
}'
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.
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:
- Expiring currencies first (to minimize waste).
- Soonest-expiring first among those.
- Non-expiring currency last.
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
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 |
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(). VerifyvirtualCurrencies()reflects the new balance. - [ ] Spend → atomic failure: Attempt a spend larger than the balance. Confirm
422response 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_TRANSACTIONevents 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 │
└─────────────────────────────────────────────────────────┘