Subscriptions and Vulture Pro
As of 2026-10-19 (vulture-ios 3176c4cc4, vulture-android 85fd9cc6).
Vulture is free, with an optional subscription, Vulture Pro. It is sold monthly and yearly with an introductory free trial. Both platforms sell it through RevenueCat and check one entitlement. The plan for sharing one subscription and one coupon across platforms is in Cross-platform subscriptions.
Products per store
Section titled “Products per store”| App Store (iOS, iPadOS, Mac) | Google Play (Android) | |
|---|---|---|
| Release products | be.fousa.vulture.subscriptions.monthly, be.fousa.vulture.subscriptions.yearly (two products) | Subscription vulture_pro with base plans monthly and yearly. RevenueCat names them vulture_pro:monthly and vulture_pro:yearly |
| Debug build | be.fousa.vulture.debug.subscriptions.monthly, …yearly (the separate debug app) | None: be.fousa.vulture.debug is not on Play, so the debug build has no store |
| Defined in | vulture-ios/Packages/Preferences/Sources/Preferences/Vulture.swift | vulture-android/data/billing/src/main/kotlin/be/fousa/vulture/data/billing/SubscriptionProducts.kt, set-up steps in vulture-android/docs/billing-setup.md |
| Fallback when the offering is empty | Asks the store for the two product ids (fetchKnownProducts()) | Asks for vulture_pro, which returns both base plans (knownProductIds) |
plan analytics value | The last . component of the product id: monthly or yearly | The last . or : component: monthly or yearly (SubscriptionOffer.analyticsPlan) |
| SDK | RevenueCat with StoreKit 2 (Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift) | RevenueCat purchases (:data:billing) |
RevenueCat
Section titled “RevenueCat”| Item | Value |
|---|---|
| Project | One Vulture project. The Android app was added to the project that already held the iOS app (billing-setup.md §3) |
| Entitlement | all. Every Pro feature checks it. iOS: entitlements["all"] in Modules/App/Sources/Services/Subscriptions/PurchaserInfo+Active.swift. Android: SubscriptionProducts.ENTITLEMENT_ID |
| Trial | iOS treats periodType == .trial on all as a trial (isTrialActive) |
| Offering | The project’s current offering. Neither app names an offering id |
| Packages | $rc_monthly and $rc_annual, each with the iOS product and the Play base plan attached |
| Offering metadata | show_paywall_on_first_launch (Boolean): shows the paywall once after the first launch. One switch for both platforms. iOS: AppStoreSubscription.firstLaunchPaywallMetadataKey. Android: SubscriptionProducts.FIRST_LAUNCH_PAYWALL_METADATA_KEY, FirstLaunchPaywall |
| Other remote config | None. Apart from this metadata key, nothing is switched remotely. iOS’s isFreeTierEnabled is compiled in |
API keys. The values are not reproduced here.
| Platform | Key | Where |
|---|---|---|
| iOS | Public appl_… SDK keys | Hard-coded in vulture-ios/Packages/Preferences/Sources/Preferences/Vulture.swift (purchasesAPIKey). Debug and Release have different keys, so the debug app is a separate RevenueCat app |
| Android | Public goog_… SDK key | From local.properties or the CI secret REVENUECAT_API_KEY, written into BuildConfig.REVENUECAT_API_KEY (vulture-android/data/billing/build.gradle.kts) |
App user id
Section titled “App user id”RevenueCat ties a subscription to an app user id, not to a store.
| Platform | App user id | Where |
|---|---|---|
| iOS | The CloudKit user record name of iCloud.be.fousa.vulture.cloud, set with Purchases.logIn(recordName). With no iCloud account, RevenueCat’s anonymous id | AppStoreSubscription.identifyUser() / identify(with:) |
| Android | The Firebase uid after Google sign-in, set with logIn(uid). On sign-out or account deletion, logOut(), back to a new anonymous customer. A purchase made before sign-in is aliased into the uid | vulture-android/app/src/main/kotlin/be/fousa/vulture/account/SubscriptionIdentitySync.kt, decision P8-D14 |
The two ids never match today. A subscription bought on one platform therefore does not unlock the other. Android’s copy must not promise cross-platform access (P8-D8). The fix is for iOS to log in with the Firebase uid once it joins the Firebase sync: see ios-changes A4 and Sync schema §7.
The Firebase ID token also carries RevenueCat’s custom claim revenueCatEntitlements. When it contains all, the sync storage cap is lifted (vulture-android/firebase/firestore.rules, sync-schema §6).
Pro features and what stays free
Section titled “Pro features and what stays free”ProFeature is the full list of gates. iOS: vulture-ios/Packages/Preferences/Sources/Preferences/Pro/ProFeature.swift, decided by ProAccessPolicy. Android: vulture-android/data/billing/src/main/kotlin/be/fousa/vulture/data/billing/ProFeature.kt. Each raw value is also the analytics name and the paywall trigger.
ProFeature | Pro unlocks | Stays free | Android |
|---|---|---|---|
severalFlights | More than one flight on a map: a competition day, logbook flights side by side, Compare | One flight at a time | Gated. A batch opened from another app keeps only its first flight |
customAirspace | The pilot’s own airspace files on the map, instead of openAIP’s | openAIP airspace and its warnings | Gated |
debrief | Every part of the Debrief rating and its answers, and Siri’s flight analysis | The Debrief score and its band | Gated |
thermalAnalysis | The thermal sheet: climb, centring, 3D view | Thermals drawn on the map | Gated |
taskPlanning | Creating and editing a task | Opening, flying and sharing an existing task, including a shared one | Never shown: task planning is out of scope for v1 |
recording | Recording a flight: the in-flight map, its airspace warnings, the Live Activity | Resuming a recording that is already running | Never shown: recording is out of scope for v1 |
skySight | SkySight forecasts and the satellite clouds on the maps (with the pilot’s own SkySight account) | n/a | Never shown: SkySight is out of scope for v1 |
statistics | The logbook’s statistics, year by year | n/a | Gated |
uploads | Uploading to WeGlide, SkyLines or Charron.line | n/a | Gated |
taskOverride | Putting another task over the flights on a map, by picking it or dropping it | Resetting the override; the task a competition day opens with | Gated |
raceRanking | The race ranking of a competition replay, and Replay Day | n/a (the flights themselves are severalFlights) | Gated |
syncStorage | Android only. Cloud storage beyond the free 100 MB. The server enforces the cap; this case exists so the account screen’s upsell can open the paywall with it first | 100 MB of synced files | Gated |
Free with no gate at all: the logbook, import, one flight on the map with replay and the barogram, competitions (Soaring Spot, Charron.line), openAIP airspace, waypoints, viewing tasks, the Debrief score, connected-service logins, and sync. Sync is iCloud on iOS, and Firebase on Android up to 100 MB.
Rules shared by both apps (iOS CLAUDE.md, “Vulture is free, with Vulture Pro”):
- Every feature has one choke point, which asks
ProAccessand opens the paywall for thatProFeature. - Never hide data a lapsed pilot already has. Files stay listed, tasks open, and recordings resume.
- A gate never switches a synced setting off. Without Pro, openAIP is shown whatever the airspace setting says, and the setting keeps its saved value.
The free tier is always on in Release. iOS can only turn it off in Debug with -VultureHardPaywall (ProAccess.isFreeTierEnabled, Modules/App/Sources/Services/Subscriptions/ProAccess.swift). With the free tier off, the app becomes a hard paywall. Android has ProAccessPolicy.IS_FREE_TIER_ENABLED, which is always on.
Paywall triggers
Section titled “Paywall triggers”Every paywall event carries trigger, which records what put the paywall up (see Analytics events).
Trigger (name) | When | iOS | Android |
|---|---|---|---|
a ProFeature raw value | The pilot reached for that feature without Pro. The paywall leads with that feature’s hero image and headline | Yes | Yes |
settings | The Vulture Pro row in Settings | Yes | Yes |
firstLaunch | Once after the first launch, when the offering metadata show_paywall_on_first_launch is true | Yes | Yes. Skipped when the app was started by a link |
launch | The hard paywall over the app at launch, only when the free tier is off | Yes (Debug only) | No |
promoted | A purchase started from the App Store product page (promoted in-app purchase) | Yes | No: Google Play has no promoted purchases |
Sources: iOS vulture-ios/Modules/App/Sources/Services/Subscriptions/PaywallTrigger.swift. Android vulture-android/data/billing/src/main/kotlin/be/fousa/vulture/data/billing/PaywallTrigger.kt.
Paywall screens:
- iOS:
vulture-ios/Packages/SettingsScreens/Sources/SettingsScreens/Subscription/(design A, onePaywallContextper feature, closed by its button and never by a swipe). - Android:
:feature:paywall. Android has no Restore row in subscription settings (P8-D13), but the paywall footer keeps Restore.
