Rovenue SDK
Guides

Processing Purchases

Use the SDK to fetch offerings and drive the purchase flow end-to-end.

The SDK drives the full purchase lifecycle — fetching products from the store, presenting them, triggering StoreKit 2 (iOS) or Play Billing 6 (Android), and validating the receipt server-side — all in a single purchase(pkg) call. Your app only decides which package to present.

To let the dashboard decide which offering a subscriber sees at a given location — per audience, with A/B tests — see Placements.

Rovenue handles receipt validation server-side before resolving the purchase promise. Your app receives verified entitlements and updated virtual-currency balances in the result — no additional polling or webhook listener is needed on the client. See Methods — Purchases.

Types on this page

type Offerings = {
  current: Offering | null;           // default offering; null if none configured
  all: Record<string, Offering>;      // all offerings by identifier
};

type Offering = {
  identifier: string;    // e.g. "default" or "annual_promo"
  isDefault: boolean;    // true for the offering marked default in the dashboard
  packages: Package[];   // ordered list of packages in this offering
};

type Package = {
  identifier: string;    // e.g. "$rc_monthly", "$rc_annual"
  product: StoreProduct; // localised product from the store
};

type StoreProduct = {
  id: string;                      // store product ID
  type: ProductType;               // "subscription" | "consumable" | "non_consumable"
  displayName: string;             // localised product name
  priceString: string | null;      // localised price string, e.g. "$4.99"
  price: number | null;            // raw price in the account currency
  currencyCode: string | null;     // ISO 4217 currency code, e.g. "USD"
};

type ProductType = 'subscription' | 'consumable' | 'non_consumable';

type PurchaseResult = {
  entitlements: Entitlement[];              // verified entitlements after the purchase; empty when isDeferred
  virtualCurrencies: Record<string, number>; // updated virtual-currency balances (code → amount)
  productId: string;                        // store product ID that was purchased
  storeTransactionId: string;               // store-issued transaction identifier; empty when isDeferred
  isDeferred: boolean;                      // true when pending external approval (Ask-to-Buy) — not an error
};
TypeKey fieldsDescription
Offeringscurrent, allContainer returned by getOfferings().
Offeringidentifier, packagesA named group of packages configured in the dashboard.
Packageidentifier, productA purchasable unit wrapping a StoreProduct.
StoreProducttype, priceString, priceLive pricing data from the store; localised automatically.
PurchaseResultentitlements, virtualCurrencies, isDeferredReturned after server-validated purchase.

See Types for the full Entitlement shape included in PurchaseResult.


Purchase flow

Fetch offerings

Offerings are configured in the Rovenue dashboard and cached locally after the first fetch. Fetch them at app launch or when your paywall opens so pricing is always available instantly.

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

const offerings = await Rovenue.getOfferings();
// offerings.current — the default Offering (or null if none configured)
// offerings.all     — Record<string, Offering> for all named offerings
let offerings = try await Rovenue.shared.getOfferings()
// offerings.current — the default Offering? 
// offerings.all     — [String: Offering]
val offerings = Rovenue.shared.getOfferings()
// offerings.current — the default Offering?
// offerings.all     — Map<String, Offering>

Present packages to the user

Iterate over offering.packages to build your paywall UI. Each Package exposes a StoreProduct with localised pricing directly from the store — use priceString for display and price for analytics.

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

const offerings = await Rovenue.getOfferings();
const packages = offerings.current?.packages ?? [];

function Paywall() {
  return (
    <View>
      {packages.map((pkg) => (
        <TouchableOpacity key={pkg.identifier} onPress={() => handlePurchase(pkg)}>
          <Text>{pkg.product.displayName}</Text>
          <Text>{pkg.product.priceString}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
}
let offerings = try await Rovenue.shared.getOfferings()
let packages = offerings.current?.packages ?? []

for pkg in packages {
  print("\(pkg.product.displayName) — \(pkg.product.priceString ?? "?")")
}
val offerings = Rovenue.shared.getOfferings()
val packages = offerings.current?.packages ?: emptyList()

for (pkg in packages) {
  println("${pkg.product.displayName} — ${pkg.product.priceString}")
}

priceString and price are fetched live from the store at runtime, so they reflect the user's locale and currency without any extra work on your part.

Call purchase(pkg) on selection

Pass the chosen Package to Rovenue.purchase(). The SDK presents the native store sheet (StoreKit 2 on iOS, Play Billing on Android), waits for the transaction to complete, and validates the receipt server-side before resolving. The resolved PurchaseResult carries verified entitlements and updated virtual-currency balances — no additional polling needed.

Rovenue decides server-side — based on your product configuration in the dashboard — whether a purchase grants an entitlement (subscriptions, one-time unlocks) or adds credits (consumables). Both product types go through the same purchase(pkg) call.

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

async function handlePurchase(pkg) {
  try {
    const result = await Rovenue.purchase(pkg);

    if (result.isDeferred) {
      // Ask-to-Buy / parental controls — not an error. Wait for
      // 'ENTITLEMENTS_CHANGED' via addChangeListener before granting entitlements.
      showPendingMessage();
      return;
    }

    // result.entitlements — verified, up-to-date array
    // result.virtualCurrencies — updated balances (for currency-pack products)
    console.log('Purchase succeeded:', result.productId);
    console.log('Transaction ID:', result.storeTransactionId);

    navigateToSuccess();
  } catch (e) {
    if (!(e instanceof RovenueError)) throw e; // not an SDK error — re-throw
    switch (e.kind) {
      case 'PurchaseCanceled':
        // User dismissed the store sheet — no action needed
        break;
      case 'ProductNotAvailable':
        // Product ID not available in this store environment
        showError('This product is not available. Please try again later.');
        break;
      case 'StoreProblem':
        // Unexpected StoreKit / Play Billing error
        showError('Store error. Please try again.');
        break;
      default:
        throw e; // unexpected — re-throw
    }
  }
}
func handlePurchase(pkg: Package) async {
  do {
    let result = try await Rovenue.shared.purchase(pkg)

    if result.isDeferred {
      // Ask-to-Buy / deferred — inform the user and wait for a change event
      showPendingMessage()
      return
    }

    // result.entitlements — verified, up-to-date array
    // result.virtualCurrencies — updated balances (for currency-pack products)
    print("Purchase succeeded: \(result.productId)")
    print("Transaction ID: \(result.storeTransactionId)")

    navigateToSuccess()
  } catch let e as RovenueError {
    switch e.kind {
    case .purchaseCanceled:
      break // User dismissed the store sheet — no action needed
    case .productNotAvailable:
      showError("This product is not available. Please try again later.")
    case .storeProblem:
      showError("Store error. Please try again.")
    default:
      throw e
    }
  }
}
// `activity` is your current Activity — Play Billing requires it.
suspend fun handlePurchase(activity: Activity, pkg: Package) {
  try {
    val result = Rovenue.shared.purchase(activity, pkg)

    if (result.isDeferred) {
      // Deferred payment — inform the user and wait for a change event
      showPendingMessage()
      return
    }

    // result.entitlements — verified, up-to-date list
    // result.virtualCurrencies — updated balances (for currency-pack products)
    println("Purchase succeeded: ${result.productId}")
    println("Transaction ID: ${result.storeTransactionId}")

    navigateToSuccess()
  } catch (e: RovenueException) {
    when (e.kind) {
      ErrorKind.PURCHASE_CANCELED -> { /* User dismissed the store sheet — no action needed */ }
      ErrorKind.PRODUCT_NOT_AVAILABLE -> showError("This product is not available. Please try again later.")
      ErrorKind.STORE_PROBLEM -> showError("Store error. Please try again.")
      else -> throw e
    }
  }
}

Errors to handle explicitly

  • RovenueError/RovenueException with kind === 'PurchaseCanceled' — the user dismissed the store sheet. Safe to ignore; do not show an error.
  • result.isDeferred (not an error) — Ask-to-Buy (iOS) or a pending Play Billing transaction. Show a "waiting for approval" message and listen for an ENTITLEMENTS_CHANGED event via addChangeListener.
  • kind === 'ProductNotAvailable' — the product ID is not available in this store environment. Check your dashboard configuration.
  • kind === 'StoreProblem' — an unexpected StoreKit / Play Billing error. Retry or surface to the user.

See Errors for the full error reference.

Restore purchases

Apple's App Store guidelines require a Restore Purchases button in your paywall UI. Call restorePurchases() to re-validate all prior transactions for the current user — for example, after a reinstall or when a user switches devices.

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

// Triggered by a "Restore Purchases" button
await Rovenue.restorePurchases();

// Entitlements are refreshed; read from cache
const pro = Rovenue.entitlement('pro');
console.log('pro restored:', pro?.isActive);
// Triggered by a "Restore Purchases" button
try await Rovenue.shared.restorePurchases()

// Entitlements are refreshed; read from cache
let pro = await Rovenue.shared.entitlement("pro")
print("pro restored:", pro?.isActive ?? false)
// Triggered by a "Restore Purchases" button (needs the current Activity)
Rovenue.shared.restorePurchases(activity)

// Entitlements are refreshed; read from cache
val pro = Rovenue.shared.entitlement("pro")
println("pro restored: ${pro?.isActive}")

On completion, the SDK emits an ENTITLEMENTS_CHANGED change event with the refreshed entitlement state. If you have a listener attached via addChangeListener, it fires automatically — you do not need to poll. See Reacting to changes.


Redeeming promotional offers (iOS)

iOS allows app publishers to offer promotional discounts on subscriptions. These appear in StoreProduct.discounts and can be passed to purchase() to apply a time-limited discount at the moment of purchase. The SDK automatically requests a server-signed offer signature (valid 24 hours) — no additional setup required.

Overview

  • Promotional offers come from StoreProduct.discounts with type = 'promotional' or 'winBack'.
  • Introductory offers (type: 'introductory') are applied automatically by the App Store and must NOT be passed to purchase() — doing so throws.
  • The server signs offers using your project's existing Apple In-App Purchase credentials (no new .p8 file needed).
  • A signature is valid for 24 hours; the SDK requests a fresh one per purchase automatically.
  • iOS: Promotional offer redemption is supported via purchase(..., promotionalOffer:) or purchase(..., promotionalOfferId:).
  • Android: Promotional offers are not supported on native apps. React Native apps accept (but ignore) the promotionalOfferId option on Android.

Introductory offers are automatic

Do not pass offers with type: 'introductory' to purchase(). The App Store applies them automatically based on eligibility. Attempting to pass one results in an error.

// ❌ Wrong
const intro = product.discounts.find(d => d.type === 'introductory');
await purchase(pkg, { promotionalOfferId: intro?.identifier });

// ✅ Correct
const promo = product.discounts.find(d => d.type === 'promotional' || d.type === 'winBack');
await purchase(pkg, { promotionalOfferId: promo?.identifier });

Usage by platform

Pass the offer's identifier in the options object:

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

const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
const product = pkg?.product;

// Find a promotional offer (skip 'introductory')
const promoOffer = product?.discounts?.find(
  d => d.type === 'promotional' || d.type === 'winBack'
);

if (promoOffer) {
  try {
    const result = await Rovenue.purchase(pkg, {
      promotionalOfferId: promoOffer.identifier,
    });
    console.log('Purchase with promo succeeded:', result.productId);
  } catch (e) {
    // Handle errors (see [Errors](/docs/reference/errors))
  }
} else {
  // No promotional offer available; purchase at full price
  const result = await Rovenue.purchase(pkg);
}

Note: On Android, the promotionalOfferId parameter is present but ignored; the purchase proceeds at the standard price.

Pass a Discount object (or use the id-based overload):

let offerings = try await Rovenue.shared.getOfferings()
let pkg = offerings.current?.packages.first
let product = pkg?.product

// Find a promotional offer (skip 'introductory')
let promoOffer = product?.discounts.first { discount in
  discount.type == .promotional || discount.type == .winBack
}

if let offer = promoOffer {
  do {
    // Pass the Discount object directly
    let result = try await Rovenue.shared.purchase(product!, promotionalOffer: offer)
    print("Purchase with promo succeeded: \(result.productId)")

    // Or use the id-based overload:
    // let result = try await Rovenue.shared.purchase(product!, promotionalOfferId: offer.identifier!)
  } catch {
    // Handle errors (see [Errors](/docs/reference/errors))
  }
} else {
  // No promotional offer available; purchase at full price
  let result = try await Rovenue.shared.purchase(product!)
}

Important: Passing a .introductory offer throws an error with code .ineligible and message "Introductory offers are applied automatically; do not pass them as a promotional offer."

Promotional offer redemption is not supported on native Android. The native Kotlin SDK's purchase() method does not expose a promotional-offer parameter.

val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()

if (pkg != null) {
  try {
    // Native Android uses the standard purchase flow; promotional offers are unavailable
    val result = Rovenue.shared.purchase(activity, pkg)
    println("Purchase succeeded: ${result.productId}")
  } catch (e: Exception) {
    // Handle errors (see [Errors](/docs/reference/errors))
  }
}

Note: Promotional offer redemption is iOS-only. Google Play uses a different offer model (offerToken) which the SDK does not currently expose for purchase. React Native apps on Android will have the promotionalOfferId option accepted but ignored, falling back to standard pricing.

Server prerequisites

Your project must have a valid Apple In-App Purchase .p8 certificate file (from App Store Connect) configured in the Rovenue dashboard under Settings → Apple In-App Purchase. This certificate is already used for receipt validation; the same credential is reused to sign promotional offers.

If signing fails, the API returns error code apple_offer_signing_unavailable — check that your Apple credentials are complete and valid.


Selecting a subscription offer (Android)

Android products can expose multiple subscription offers through subscriptionOptions — for example, a monthly base plan and a monthly-with-free-trial offer. You can select a specific offer at purchase time to redeem the user's chosen variant.

Overview

  • Subscription offers come from product.subscriptionOptions, a list of SubscriptionOption objects available on the product.
  • Each SubscriptionOption has a stable identity (basePlanId and offerId) that the SDK uses to resolve the live Play offer at purchase time.
  • When no option is passed to purchase(), the SDK uses the default option (product.defaultOption) — the lowest-price base plan, not Play's arbitrary first offer.
  • If the selected offer is no longer available on Google Play, purchase fails with RovenueException (Kotlin) or RovenueError (React Native) — refresh offerings and retry.
  • iOS: the subscriptionOption parameter is ignored on iOS (iOS uses promotional offers instead).

Server-configured default

The default offer (product.defaultOption) can be configured server-side in the Rovenue dashboard:

  • Per-product configuration in the Android section: set an Base plan ID (and optionally an Offer ID).
  • When configured, product.defaultOption resolves to this server-chosen plan instead of the lowest-price option.
  • This ensures the advertised default price in your UI matches the actual plan the SDK purchases when the user selects a package without explicitly choosing an offer variant.
  • When left blank, the SDK falls back to the lowest-priced base plan (current behavior).
  • Important: an offer ID cannot be configured without a base plan ID — the API rejects orphaned offers.
  • If the configured base plan or offer no longer exists on Google Play, the SDK gracefully falls back to the lowest-priced base plan at purchase time (does not fail).
  • Android-only: iOS has no base-plan concept; these configuration fields are ignored on iOS.

Free trials and introductory prices

Rovenue labels each Google Play charge from the order Google actually billed. A free-trial start is recorded as a $0 INITIAL event, the first paid order after the trial as TRIAL_CONVERSION at the amount the buyer paid, and an introductory period at its introductory price. While a free trial runs, the subscription's status is TRIAL.

To read each order, grant the service account you saved under Stores → Google Play the Play Console permission View financial data, orders, and cancellation survey responses (Users and permissions → the service account → App permissions). Without it, Rovenue falls back to the live subscription state only when that state provably describes the order, and otherwise records no revenue event for it (the server log names the reason). Saving the credentials shows a warning on the Stores card while the permission is missing.

Usage by platform

Pass the chosen SubscriptionOption in the options object:

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

const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
const product = pkg?.product;

// Get available subscription offers for this product
const options = product?.subscriptionOptions ?? [];

// Select the offer (e.g., the free-trial variant)
const selectedOption = options.find(opt => opt.tags.includes('free_trial'));

if (selectedOption && pkg) {
  try {
    const result = await Rovenue.purchase(pkg, {
      subscriptionOption: selectedOption,
    });
    console.log('Purchase with offer succeeded:', result.productId);
  } catch (e) {
    if (e instanceof RovenueError && e.kind === 'ProductNotAvailable') {
      // The selected offer is no longer available; refresh offerings and retry
      showError('This offer is no longer available. Please try again.');
    } else {
      // Handle other errors (see [Errors](/docs/reference/errors))
    }
  }
} else if (pkg) {
  // No specific offer selected; purchase the default option
  const result = await Rovenue.purchase(pkg);
}

Note: On iOS, the subscriptionOption parameter is present but ignored; the purchase proceeds with the standard iOS flow.

Subscription offer selection is not directly supported on native Swift. The native Kotlin SDK exposes this feature for Android; React Native apps can use the feature cross-platform.

let offerings = try await Rovenue.shared.getOfferings()
let pkg = offerings.current?.packages.first
let product = pkg?.product

if let product = product {
  do {
    // Native Swift does not expose subscriptionOptions
    // Use the standard purchase flow; the selected offer is an Android-only feature
    let result = try await Rovenue.shared.purchase(product)
    print("Purchase succeeded: \(result.productId)")
  } catch {
    // Handle errors (see [Errors](/docs/reference/errors))
  }
}

Note: Subscription offer selection is Android-only (Play Billing feature). If you need this functionality on iOS, use React Native instead.

Pass the chosen SubscriptionOption to purchase():

import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.ErrorKind

val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()
val product = pkg?.product

if (product != null) {
  // Get available subscription offers for this product
  val options = product.subscriptionOptions ?: emptyList()

  // Select the offer (e.g., the free-trial variant)
  val selectedOption = options.find { opt -> opt.tags.contains("free_trial") }

  if (selectedOption != null) {
    try {
      // Pass the chosen option to purchase
      val result = Rovenue.shared.purchase(activity, product, selectedOption)
      println("Purchase with offer succeeded: ${result.productId}")
    } catch (e: RovenueException) {
      if (e.kind == ErrorKind.PRODUCT_NOT_AVAILABLE) {
        // The selected offer is no longer available; refresh offerings and retry
        showError("This offer is no longer available. Please try again.")
      } else {
        // Handle other errors (see [Errors](/docs/reference/errors))
      }
    }
  } else {
    // No specific offer selected; purchase the default option
    val result = Rovenue.shared.purchase(activity, product)
    println("Purchase with default offer succeeded: ${result.productId}")
  }
}

You can also pass the option when purchasing a Package:

val pkg = offerings.current?.packages?.firstOrNull()
if (pkg != null && selectedOption != null) {
  val result = Rovenue.shared.purchase(activity, pkg, selectedOption)
}

Handling stale or unavailable offers

If the SubscriptionOption you selected is no longer available on Google Play (e.g., because the offer was removed or expired), the SDK will raise RovenueException with kind == ErrorKind.PRODUCT_NOT_AVAILABLE at purchase time. The error is raised only at the moment of purchase — not when building the UI.

What to do:

  1. Catch RovenueException and check kind == ErrorKind.PRODUCT_NOT_AVAILABLE in your purchase handler.
  2. Call Rovenue.getOfferings() again to refresh the product list.
  3. Show the user a message like "This offer is no longer available" and let them select another.
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.ErrorKind

try {
  val result = Rovenue.shared.purchase(activity, product, selectedOption)
} catch (e: RovenueException) {
  if (e.kind == ErrorKind.PRODUCT_NOT_AVAILABLE) {
    // Refresh offerings
    val refreshed = Rovenue.shared.getOfferings()
    showError("This offer is no longer available. Please select another.")
  } else {
    // Handle other errors
  }
}

On this page