How to check free trial eligibility on iOS and Android
On iOS, ask StoreKit 2: product.subscription?.isEligibleForIntroOffer is true only when the user can still get an introductory offer in that subscription group. On Android, Google Play does the check for you, because subscriptionOfferDetails lists only the offers the user is eligible for. With the RevenueCat SDK, call checkTrialOrIntroductoryPriceEligibility on iOS and read subscriptionOptions on Android. Show "Start free trial" only on a clear yes.
This post covers each store's rule, the code, the button text and sandbox tests. Every Apple, Google and RevenueCat fact links its source and was checked in October 2026.
The short answer#
- Apple gives each customer one introductory offer per subscription group. Existing subscribers never qualify, even when they switch plans (Apple).
- StoreKit 2 answers with
Product.SubscriptionInfo.isEligibleForIntroOffer, available since iOS 15 (Apple). - Google Play returns only the offers a user is eligible for, and offers a base plan to anyone who picks a stale offer (Android Developers).
- The RevenueCat SDK returns one of four statuses on iOS. On Android it always returns unknown, so you read the product's subscription options instead (pub.dev).
- When in doubt, show the regular price. A paywall that promises a trial the user will not get misleads them, and Apple removes apps that trick users into a subscription (App Review Guidelines 3.1.2).
Who gets a free trial on the App Store?#
Apple ties the trial to the subscription group. A customer who has used an introductory offer on any product in a group cannot get another one in that group (Apple). A free trial is one of three kinds of introductory offer, next to "pay as you go" and "pay up front", so the same rule covers all three.
| Customer | Eligible for the intro offer? |
|---|---|
| Never subscribed to the group | Yes, always |
| Subscribed before, has lapsed, never used an intro offer in the group | Yes |
| Subscribed before and used an intro offer in the group | No |
| Subscribes now and upgrades, downgrades or crossgrades | No, even if they never used an intro offer |
So a monthly subscriber should not see "7 days free" on a yearly plan in the same group. To reach lapsed users who already had their trial, use a promotional offer or a win-back offer instead.
How do you check eligibility with StoreKit 2?#
Read the isEligibleForIntroOffer property. Apple describes it as true when the customer can get an intro offer on this subscription or any other in the same group. Apple also warns that it can be true when you never set up an intro offer, so check that an offer exists as well (Apple).
import StoreKit
/// Returns the free trial to show, or nil when the paywall should show the plain price.
func freeTrialToShow(for product: Product) async -> Product.SubscriptionOffer? {
guard let subscription = product.subscription,
let intro = subscription.introductoryOffer, // nil when no intro offer is set up
intro.paymentMode == .freeTrial else { return nil }
// true only if the user has not used an intro offer in this group.
return await subscription.isEligibleForIntroOffer ? intro : nil
}If you only know the group ID, the static Product.SubscriptionInfo.isEligibleForIntroOffer(for:) answers for the whole group (Apple). Both APIs are async, so check once when the paywall loads.
Who gets a free trial on Google Play?#
On Google Play you set the rule per offer. A trial usually uses "new customer acquisition", which means the user never had this subscription, or never had any subscription in the app, depending on the option you pick. "Developer determined" means your app decides, and such an offer cannot be bought outside your app (Google Play Console Help). Our base plans and offers guide covers the third type, upgrade, and how offers map to iOS.
You do not compute eligibility yourself. queryProductDetailsAsync returns up to 50 offers per subscription that the user is eligible for (Android Developers). So if a free trial offer is in the list, the user can get it.
// productDetails comes from queryProductDetailsAsync. Play lists only eligible offers.
val offers = productDetails.subscriptionOfferDetails.orEmpty()
val trial = offers.firstOrNull { offer ->
offer.offerId != null && // null means the plain base plan
offer.pricingPhases.pricingPhaseList.firstOrNull()?.priceAmountMicros == 0L
}
val basePlan = offers.first { it.offerId == null }
val chosen = trial ?: basePlan // buy with chosen.offerTokengetOfferId returns null for a regular base plan (reference). A free phase has a price of 0 micros, and each phase also gives you its formatted price, billing period and cycle count (reference).
A developer-determined offer is in the list too, because Play cannot judge your rule. Filter it out by tag unless your logic says the user qualifies.
How does the RevenueCat SDK report eligibility?#
checkTrialOrIntroductoryPriceEligibility takes product IDs and returns a status for each one (pub.dev). The iOS SDK names the same method checkTrialOrIntroDiscountEligibility.
| Status | Meaning | What the paywall shows |
|---|---|---|
| Eligible | The user can get the trial or intro price | The trial or intro terms |
| Ineligible | The user cannot get it | The regular price |
| No intro offer exists | The product has no trial or intro price | The regular price |
| Unknown | The SDK could not decide | The regular price |
The statuses come from the Flutter enum. The SDK's own note says to show the regular price on unknown (purchases-ios source).
Where the answer comes from depends on the platform:
- iOS with StoreKit 2, the SDK default, checks on the device with
isEligibleForIntroOffer(TrialOrIntroPriceEligibilityChecker.swift, StoreKitVersion.swift). - iOS with StoreKit 1 reads the receipt on the device, then asks the backend about any product it could not decide (TrialOrIntroPriceEligibilityChecker.swift).
- Android always returns unknown. RevenueCat's docs say to use
subscriptionOptionsthere, because it holds only the offers the customer is eligible for (RevenueCat).
In Flutter, one function covers both stores:
import 'dart:io' show Platform;
import 'package:purchases_flutter/purchases_flutter.dart';
Future<bool> canStartFreeTrial(StoreProduct product) async {
if (Platform.isAndroid) {
// Google Play only lists offers this user is eligible for.
final options = product.subscriptionOptions ?? [];
return options.any((o) => o.freePhase != null);
}
if (product.introductoryPrice == null) return false;
final result = await Purchases.checkTrialOrIntroductoryPriceEligibility([product.identifier]);
return result[product.identifier]?.status ==
IntroEligibilityStatus.introEligibilityStatusEligible;
}subscriptionOptions and freePhase are Google Play only fields on StoreProduct and SubscriptionOption (StoreProduct, SubscriptionOption). On Android, buying the package lets the SDK pick the longest free trial the user is eligible for. Tag a developer-determined offer rc-ignore-offer if it must never be picked that way (RevenueCat).
What should the button and price say?#
The button and the line under the price must match what the store will charge. Apple asks you to describe clearly what the user gets for the price before they subscribe (guideline 3.1.2(c)).
| Case | Button | Price line |
|---|---|---|
| Eligible for a free trial | Start free trial | 7 days free, then $59.99 a year |
| Eligible for an intro price | Subscribe | $1.99 for the first month, then $9.99 a month |
| Ineligible, unknown or no offer | Subscribe | $59.99 a year |
A few rules keep the text honest:
- Build the text from the store's data. Take the trial length and price from the offer, not from a typed string.
- Put the price after the trial in the same line. Users should see what they pay when the trial ends.
- Check each plan on its own. On a two-plan paywall, the yearly plan may have a trial while the monthly plan does not.
- Do not add a trial switch. Show the trial as part of a plan. Our post on the free trial toggle rejection explains why.
For eligible users, a trial timeline paywall shows what happens on each day of the trial.
How do you test eligibility in the sandbox?#
Each store lets you reset a test user, so you can test the eligible case again.
- Apple sandbox. Clear the Sandbox Apple Account's purchase history in App Store Connect, or on the device under Settings, Developer. Then sign out and back in to clear the cache. After that, the account is eligible for introductory offers again (Apple).
- Google Play. A license tester can use the Play Billing Lab option "Test free trial or introductory offer" to take a trial again and again. Google also asks you to test with a normal account now and then (Android Developers).
Test the ineligible case too: after a trial, the paywall should show the regular price. Our sandbox testing guide walks through each test setup.
Do it with RevenueDot#
RevenueDot is an open-source server that speaks the RevenueCat SDK's API. You keep the stock SDK and change its proxy URL, so the code above does not change. Here is how eligibility works with it:
- StoreKit 2 on iOS. The SDK checks eligibility on the device and does not ask the server. This is the SDK's default, so most apps need nothing more.
- StoreKit 1 on iOS. RevenueDot implements the SDK's
intro_eligibilityendpoint, but it answersnullfor every product. The SDK reads that as unknown, and the paywall should show the regular price (what differs). If you still run StoreKit 1 and want trial text, switch the SDK to StoreKit 2. - Google Play. The SDK reads eligible offers from Play on the device, as shown above.
RevenueDot paywalls handle the text for you. In the editor, a text component holds a second, intro-offer text, and the preview has a switch to show the paywall as an eligible or an ineligible customer would see it. Texts can use device-filled variables such as {{ product.offer_period_with_unit }} and {{ product.price_per_period }} (paywalls guide). The Trial timeline template starts you with a trial explainer.
No real store purchase has run end to end against RevenueDot yet, so test each store in its sandbox before launch. RevenueCat is free up to $2,500 in monthly tracked revenue, then 1% (RevenueCat), and the fee calculator works out your bill. See the RevenueCat comparison and pricing.
Start free on RevenueDot Cloud (free up to $10,000 monthly tracked revenue).
FAQ#
Can a user get a second free trial on iOS?#
Not in the same subscription group. Apple allows one introductory offer per group per customer (Apple). To give returning users a deal, use a promotional offer, which needs a server signature.
Why does checkTrialOrIntroductoryPriceEligibility always return unknown on Android?#
The RevenueCat SDK documents it as iOS only, and Android always returns unknown (pub.dev). On Android, Play already filters the offers, so check subscriptionOptions for a free phase.
Is isEligibleForIntroOffer only available on iOS 17?#
No. Both the property and the isEligibleForIntroOffer(for:) method list iOS 15.0 as their first version (Apple).
What happens if a user taps an offer they cannot get on Google Play?#
Play tells them they are not eligible and lets them buy the base plan instead (Android Developers). Load product details when the paywall opens, so it shows current offers.
About RevenueDot. RevenueDot is an open-source (AGPL-3.0) backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Start free on RevenueDot Cloud, free up to $10,000 in monthly tracked revenue, or self-host it with Docker and Postgres. Point the SDK's proxy URL at RevenueDot and keep your app code, your offerings and your customers. Read the quickstart or the code on GitHub.