Rovenue SDK
Guides

Reacting to Changes

Subscribe to real-time SDK events for entitlement, identity, and credit updates.

The SDK emits a ChangeEvent whenever its internal state changes — after a purchase, an identity update, or a virtual-currency balance change. Subscribe to drive UI updates without polling.

In React Native you rarely need the imperative listener directly — the hooks (useEntitlement, useEntitlements, useCurrentUser, useVirtualCurrencies, useVirtualCurrency) subscribe and unsubscribe automatically. Use the imperative API for non-component code or for Swift/Kotlin.

Types on this page

ChangeEvent is an enum whose value tells you which cache to re-read. Full definition: Types → ChangeEvent.

// React Native — string union
type ChangeEvent =
  | 'ENTITLEMENTS_CHANGED'
  | 'IDENTITY_CHANGED'
  | 'VIRTUAL_CURRENCIES_CHANGED'
  | 'REMOTE_CONFIG_CHANGED';

On Swift the change stream is AsyncStream<ChangeEvent> and the cases are .entitlementsChanged, .identityChanged, .virtualCurrenciesChanged, .remoteConfigChanged.

On Kotlin the change stream is SharedFlow<ChangeEvent> and the constants are ChangeEvent.ENTITLEMENTS_CHANGED, ChangeEvent.IDENTITY_CHANGED, ChangeEvent.VIRTUAL_CURRENCIES_CHANGED, ChangeEvent.REMOTE_CONFIG_CHANGED.

Event valueWhen it fires
ENTITLEMENTS_CHANGEDAfter the entitlement cache is refreshed — e.g. after a purchase, refreshEntitlements(), or a foreground sync.
IDENTITY_CHANGEDAfter identify() succeeds or logOut() resets the user.
VIRTUAL_CURRENCIES_CHANGEDAfter refreshVirtualCurrencies() or a server-side grant/spend that updates the cache.
REMOTE_CONFIG_CHANGEDAfter the remote-config cache is refreshed — e.g. after refreshRemoteConfig() or a foreground sync.

Subscribe to the change stream

import { Rovenue } from '@rovenue/react-native-sdk';

// addChangeListener returns an unsubscribe function — call it to clean up
const unsub = Rovenue.addChangeListener((event) => {
  switch (event) {
    case 'ENTITLEMENTS_CHANGED':
      console.log('Entitlements updated — re-check access');
      break;
    case 'IDENTITY_CHANGED':
      console.log('Identity changed — re-read current user');
      break;
    case 'VIRTUAL_CURRENCIES_CHANGED':
      console.log('Virtual-currency balances changed — re-read balances');
      break;
    case 'REMOTE_CONFIG_CHANGED':
      console.log('Remote config changed — re-read values');
      break;
  }
});

In React components, use the hooks instead — they handle subscription lifecycle automatically:

import {
  useEntitlement,
  useEntitlements,
  useCurrentUser,
  useVirtualCurrencies,
  useVirtualCurrency,
} from '@rovenue/react-native-sdk';

function ProBadge() {
  const pro = useEntitlement('pro'); // re-renders on ENTITLEMENTS_CHANGED
  return pro?.isActive ? <Crown /> : null;
}
import Rovenue

// Rovenue.shared.changes is an AsyncStream<ChangeEvent>
// Consume it inside a Task that you own so you can cancel it later
let task = Task {
  for await event in Rovenue.shared.changes {
    switch event {
    case .entitlementsChanged:
      print("Entitlements updated — re-check access")
    case .identityChanged:
      print("Identity changed — re-read current user")
    case .virtualCurrenciesChanged:
      print("Virtual-currency balances changed — re-read balances")
    case .remoteConfigChanged:
      print("Remote config changed — re-read values")
    }
  }
}
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.ChangeEvent

// Rovenue.shared.changes is a SharedFlow<ChangeEvent>
// Collect from a scope you own — see the warning below
viewModelScope.launch {
  Rovenue.shared.changes.collect { event ->
    when (event) {
      ChangeEvent.ENTITLEMENTS_CHANGED       -> println("Entitlements updated")
      ChangeEvent.IDENTITY_CHANGED           -> println("Identity changed")
      ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> println("Virtual-currency balances changed")
      ChangeEvent.REMOTE_CONFIG_CHANGED      -> println("Remote config changed")
    }
  }
}

Unsubscribe when done

Always clean up your subscription to prevent memory leaks or stale callbacks.

// Call the function returned by addChangeListener
unsub();

// In a React effect:
useEffect(() => {
  const unsub = Rovenue.addChangeListener((event) => {
    // handle event
  });
  return () => unsub(); // cleanup on unmount
}, []);
// Cancel the Task you created in Step 1
task.cancel()

// In a SwiftUI view model, store the task and cancel on deinit:
deinit {
  task.cancel()
}
// If you collected with launchIn, cancel the resulting Job:
val job = Rovenue.shared.changes
  .onEach { event -> /* handle */ }
  .launchIn(viewModelScope)

// Cancel explicitly if needed (viewModelScope cancels automatically on ViewModel cleared):
job.cancel()

Kotlin: collect changes from a coroutine scope you control (e.g. viewModelScope) — not from a scope the SDK owns. Calling shutdown() cancels the SDK's internal scope, which also cancels any collector running inside it. See App lifecycle for shutdown sequencing.

Re-read the updated cache after an event

A ChangeEvent is a signal to re-read — the event itself does not carry the new value. After receiving it, call the corresponding cache read to get the updated data.

import { Rovenue } from '@rovenue/react-native-sdk';

const unsub = Rovenue.addChangeListener(async (event) => {
  switch (event) {
    case 'ENTITLEMENTS_CHANGED': {
      const all = await Rovenue.entitlementsAll();
      console.log('Active entitlements:', all.filter(e => e.isActive));
      break;
    }
    case 'IDENTITY_CHANGED': {
      const user = await Rovenue.currentUser();
      console.log('Current user:', user.rovenueId, user.appUserId);
      break;
    }
    case 'VIRTUAL_CURRENCIES_CHANGED': {
      const balances = Rovenue.virtualCurrencies();
      console.log('New balances:', balances);
      break;
    }
    case 'REMOTE_CONFIG_CHANGED': {
      const config = await Rovenue.getRemoteConfig();
      console.log('Remote config:', config);
      break;
    }
  }
});
let task = Task {
  for await event in Rovenue.shared.changes {
    switch event {
    case .entitlementsChanged:
      let all = await Rovenue.shared.entitlementsAll()
      print("Active entitlements:", all.filter { $0.isActive })
    case .identityChanged:
      let user = await Rovenue.shared.currentUser()
      print("Current user:", user.rovenueId, user.appUserId ?? "anonymous")
    case .virtualCurrenciesChanged:
      let balances = await Rovenue.shared.virtualCurrencyBalances()
      print("New balances:", balances)
    case .remoteConfigChanged:
      let config = await Rovenue.shared.remoteConfigAllJSON()
      print("Remote config:", config)
    }
  }
}
viewModelScope.launch {
  Rovenue.shared.changes.collect { event ->
    when (event) {
      ChangeEvent.ENTITLEMENTS_CHANGED -> {
        val all = Rovenue.shared.entitlementsAll()
        println("Active: ${all.filter { it.isActive }}")
      }
      ChangeEvent.IDENTITY_CHANGED -> {
        val user = Rovenue.shared.currentUser()
        println("Current user: ${user.rovenueId} / ${user.appUserId}")
      }
      ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> {
        val balances = Rovenue.shared.virtualCurrencyBalances()
        println("New balances: $balances")
      }
      ChangeEvent.REMOTE_CONFIG_CHANGED -> {
        val config = Rovenue.shared.remoteConfigAllJson()
        println("Remote config: $config")
      }
    }
  }
}

Project webhook (v1)

The project webhook URL receives one signed JSON POST per store notification, plus the deliveries Rovenue originates itself. Exactly two producers write v1 deliveries: every store notification (one per inbound notification), and every Rovenue-originated state change (written in the same transaction as the change). Receipts, restores, server-to-server transfers and CSV imports write no v1 delivery — every receipt-path fact is also announced by the store's own notification for the same transaction. Each body names both the store's own event and the state the purchase is in after Rovenue processed it:

{
  "eventType": "DID_CHANGE_RENEWAL_STATUS",
  "event": "subscription.uncancelled",
  "events": ["subscription.uncancelled"],
  "subscriberId": "sub_123",
  "purchaseId": "pur_456",
  "store": "APPLE",
  "productId": "prod_789",
  "productIdentifier": "pro_monthly",
  "status": "ACTIVE",
  "expiresDate": "2030-01-01T00:00:00.000Z",
  "autoRenewStatus": true,
  "webhookEventId": "whe_abc",
  "timestamp": "2026-10-02T12:00:00.000Z"
}
  • eventType is the store's own event, unchanged — or, for a Rovenue-originated delivery, one of EXPIRATION, RECONCILIATION, SCHEDULED_CANCEL, SCHEDULED_REVOKE, MANUAL_GRANT, MANUAL_REVOKE, OPERATOR_REFUND.
  • events names every key the delivery carries, in order: lifecycle keys first, then subscription.product_changed / subscription.offer_redeemed, then the revenue key(s) (revenue.<TYPE>). A recovery delivery reads event: "subscription.recovered" with events: ["subscription.recovered", "revenue.RENEWAL"].
  • event is events[0], or null when the delivery names no key. That happens only when the source changed nothing a key describes: Apple RENEWAL_EXTENDED, RENEWAL_EXTENSION, PRICE_INCREASE, REFUND_DECLINED, CONSUMPTION_REQUEST, TEST; Google SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED, SUBSCRIPTION_PRICE_CHANGE_CONFIRMED, SUBSCRIPTION_PRICE_CHANGE_UPDATED; or a stale or withheld delivery. A cancel (auto-renew turned off) is not in that list — it delivers event: "subscription.cancel_requested" with autoRenewStatus: false, and the matching un-cancel delivers subscription.uncancelled with autoRenewStatus: true.
  • store, productId, productIdentifier, status, expiresDate and autoRenewStatus are the purchase's state at delivery time. They are null when the notification names no purchase.
  • Deliveries are deduplicated per inbound store notification (webhookEventId), so a store redelivery is sent once while a cancel and the later un-cancel are two deliveries.
  • Deliveries Rovenue originates without a store notification carry "webhookEventId": null: the scheduled expiry (eventType: "EXPIRATION", event: "subscription.expired"), a scheduled cancellation (event: "subscription.cancel_requested"), a comp grant (eventType: "MANUAL_GRANT", event: "revenue.INITIAL"), an immediate comp revoke (eventType: "MANUAL_REVOKE", event: "subscription.expired", events ending in revenue.CANCELLATION) and an operator refund (eventType: "OPERATOR_REFUND"). They are sent once per purchase and event, not per store notification — do not use webhookEventId as their idempotency key.

On this page