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
};| Type | Key fields | Description |
|---|---|---|
Offerings | current, all | Container returned by getOfferings(). |
Offering | identifier, packages | A named group of packages configured in the dashboard. |
Package | identifier, product | A purchasable unit wrapping a StoreProduct. |
StoreProduct | type, priceString, price | Live pricing data from the store; localised automatically. |
PurchaseResult | entitlements, virtualCurrencies, isDeferred | Returned after server-validated purchase. |
See Types for the full
Entitlementshape included inPurchaseResult.
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 offeringslet 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/RovenueExceptionwithkind === '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 anENTITLEMENTS_CHANGEDevent viaaddChangeListener.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.discountswithtype='promotional'or'winBack'. - Introductory offers (
type: 'introductory') are applied automatically by the App Store and must NOT be passed topurchase()— doing so throws. - The server signs offers using your project's existing Apple In-App Purchase credentials (no new
.p8file 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:)orpurchase(..., promotionalOfferId:). - Android: Promotional offers are not supported on native apps. React Native apps accept (but ignore) the
promotionalOfferIdoption 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 ofSubscriptionOptionobjects available on the product. - Each
SubscriptionOptionhas a stable identity (basePlanIdandofferId) 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) orRovenueError(React Native) — refresh offerings and retry. - iOS: the
subscriptionOptionparameter 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.defaultOptionresolves 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:
- Catch
RovenueExceptionand checkkind == ErrorKind.PRODUCT_NOT_AVAILABLEin your purchase handler. - Call
Rovenue.getOfferings()again to refresh the product list. - 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
}
}Related: Checking Entitlements · Reacting to changes · Methods — Purchases · Types · Errors