RevenueCat Native IAP Setup
Operational runbook for provisioning native In-App Purchases (StoreKit / Play Billing) via RevenueCat. Every step here is external or secret configuration — none of it is code. The IAP code already shipped (PRs #1070/#1071); this doc covers what a human must do in App Store Connect, Play Console, RevenueCat, GitHub, and GCP to make native purchases work end-to-end.
Web/Stripe purchasing is a separate path and is not covered here. Web lifetime stays pay-what-you-want ($99 min); native uses a single fixed $99 tier.
How the pieces connect
- The mobile build is configured with the RevenueCat public SDK key via
VITE_REVENUECAT_IOS_KEY/VITE_REVENUECAT_ANDROID_KEY. The composableionic_frontend/src/composables/useInAppPurchases.tsreads the platform key inapiKey()and callsPurchases.configure. (A missing/empty key makesapiKey()return'';configure()captures a Sentry warning and no-ops, so IAP is silently disabled — that is the bug this runbook prevents.) - On login the composable calls
Purchases.logInwith the RevenueCatapp_user_idset to the user'sUser#public_id, so the webhook can resolve the user. - The user buys a package from the
defaultoffering. - RevenueCat processes the store transaction and POSTs a webhook to
POST /webhooks/revenuecat(Webhooks::RevenueCatController). The controller does a constant-time compare of the requestAuthorizationheader againstREVENUECAT_WEBHOOK_AUTH(valid_auth?inrevenue_cat_controller.rb); mismatch →401+ Sentry warning. Billing::ProcessRevenueCatWebhook(rails_api/app/interactions/billing/process_revenue_cat_webhook.rb) maps theproduct_idviaPRODUCT_MAP, records aPaymentRecord(providerrevenuecat, idempotent onprovider_event_id), and publishes the canonicalbilling.payment_processeddomain event.- A subscriber flips
user.supporter_tier— the same subscriber the Stripe path uses. The backend remains the source of truth forUser#isSupporter.
Prerequisites
- Apple: Paid Apps agreement active — App Store Connect → Agreements, Tax, and Banking. Without it, IAP products will not be purchasable (even in sandbox).
- Google: Play merchant account / payments profile set up in Play Console.
Create in-app products
Create these products in both stores. The product IDs must match the code exactly (PRODUCT_SLUG / PRODUCT_MAP).
Both stores split one-time purchases and subscriptions into two separate sections. The monthly/yearly auto-renewable subscriptions are not created in the same place as the lifetime non-consumable:
- App Store Connect: the "In-App Purchases" → Create dialog only offers Consumable / Non-Consumable — use it for the lifetime product only. Auto-renewable subscriptions live under the separate Subscriptions section (sidebar → Monetization → Subscriptions), inside a Subscription Group.
- Play Console: "Subscriptions" is a distinct product type from "In-app products" (one-time). Create the lifetime as an in-app product and the two recurring plans as subscriptions.
| Product ID | App Store Connect | Play Console | Price |
|---|---|---|---|
objectuve_supporter_monthly | Auto-Renewable Subscription — Subscriptions section, in the Supporter group | Subscription | ~$3.99/mo (web canonical $4.00/mo) |
objectuve_supporter_yearly | Auto-Renewable Subscription — Subscriptions section, in the Supporter group | Subscription | ~$35.99/yr (web canonical $36.00/yr, "Save 25%") |
objectuve_lifetime_99 | Non-Consumable — In-App Purchases section | One-time / managed product | $99 |
Subscription Group (App Store Connect): put objectuve_supporter_monthly and objectuve_supporter_yearly in the same Subscription Group (e.g. "Supporter"). A user can hold only one active subscription per group, so this is what makes monthly and yearly mutually exclusive and enables in-store upgrade/downgrade between them. RevenueCat references all three product IDs identically regardless of which store section created them.
Notes:
- Native lifetime is a single fixed tier. There is no 149/199 tier — those were removed. Apple/Google disallow pay-what-you-want IAP, so the price is fixed at $99.
- The
99inobjectuve_lifetime_99encodes the price by pre-existing convention. Do not "fix" or rename it — the frontend and backend maps key on this exact string. - Web lifetime remains PWYW ($99 min) and is unaffected.
Play one-time products: Product ID vs Purchase option ID
Play Console's one-time-product flow asks for two identifiers — they are different things and cannot be the same:
- Product ID — the SKU your code and RevenueCat key on. Set it to exactly
objectuve_lifetime_99(matchesPRODUCT_MAP/PRODUCT_SLUG). Permanent — it can't be changed or reused after creation. Allows underscores. - Purchase option ID — an internal label for how the product is sold (Buy vs Rent). Users never see it. Allows lowercase letters, numbers, and hyphens only — no underscores, so it literally cannot equal the product ID. Use something simple and stable, e.g.
buyorlifetime-99. Purchase type = Buy (not Rent).
⚠️ Google Play subscription identifiers carry a base-plan suffix. Confirmed during import: RevenueCat references Play subscriptions as <product_id>:<base_plan_id> — e.g. objectuve_supporter_monthly:subscription-399 and objectuve_supporter_yearly:subscription-3599. On Android the SDK returns this suffixed string as pkg.product.identifier, and the webhook can send it as product_id; iOS and the Play one-time product (objectuve_lifetime_99) stay bare. The code already handles this — both PRODUCT_SLUG lookup (useInAppPurchases.ts) and plan_slug (process_revenue_cat_webhook.rb) match the full id first and fall back to the part before :. So PRODUCT_MAP / PRODUCT_SLUG only need the bare ids. The base-plan id (subscription-399) is whatever you named the base plan in Play Console; it can change without code edits because only the part before : is matched. Still verify the first internal-test purchase lands a PaymentRecord with provider 'revenuecat' and flips supporter tier (check provider_product_id in Cloud Run logs).
Store listing copy
Reuse the same description across all three products — the perks are identical per plan, so consistency is correct. Mirror the in-app paywall (SupporterUpgradeModal.vue: 9 custom themes · Supporter badge · early access). Do not put price or duration in the description — the stores render those automatically.
| Product ID | Display Name (≤30) | Description (≤55) |
|---|---|---|
objectuve_supporter_monthly | Supporter Monthly | Support Objectuve. Themes, badge, and early access. |
objectuve_supporter_yearly | Supporter Yearly | Support Objectuve. Themes, badge, and early access. |
objectuve_lifetime_99 | Supporter Lifetime | Support Objectuve. Themes, badge, and early access. |
- Subscription Group display name (App Store Connect, group-level localization):
Supporter. - Brand voice: "fund what you use" — no ads, no investors, no data sales. Keep it action-forward and perk-describing (Apple reviewers want the description to say what the purchase unlocks).
Clearing "Missing Metadata"
A freshly created product shows Missing Metadata until every required field is filled. It is not an error — but a product MUST reach Ready to Submit before StoreKit / RevenueCat getOfferings() will return it (so finish this before any sandbox device test). Per store:
App Store Connect — each auto-renewable subscription:
- Subscription Price — Subscription Prices → + → set the US price (monthly ≈ $3.99, yearly ≈ $35.99). Most common cause of the flag.
- App Store Localization — Display Name + Description (see table above), at least English (U.S.).
- Review Information → Review Screenshot — required; a grab of the in-app
SupporterUpgradeModalpaywall works. Optional review notes. - Subscription Group localization — the group-level display name
Supporter. (Duration is set at creation: 1 month / 1 year.)
App Store Connect — the lifetime non-consumable (objectuve_lifetime_99): price ($99) + localization (Display Name + Description) + review screenshot → Ready to Submit.
Play Console: set the price and the store listing (title/description) for each in-app product / subscription; activate it. Inactive products are not returned to the SDK.
First-submission rule (Apple): the first subscription and the first in-app purchase must each be attached to a new app version and submitted with it (select them on the version page under "In-App Purchases and Subscriptions"). After that first review, additional products can be submitted standalone.
RevenueCat dashboard
- Create a RevenueCat project with one iOS app and one Android app.
- Create an entitlement with identifier exactly
supporter(matchesSUPPORTER_ENTITLEMENTinuseInAppPurchases.ts). - Create one offering with identifier exactly
defaultcontaining all three products as packages (the composable readsgetOfferings().current). - Attach store credentials so RevenueCat can validate receipts:
- iOS: App Store Connect API key and/or app-specific shared secret.
- Android: Play service-account JSON with the required billing scopes.
RevenueCat webhook
- In the RevenueCat dashboard, configure a webhook pointed at:
- Production:
POST https://api.objectuve.com/webhooks/revenuecat - (Other envs:
POST https://<api-host>/webhooks/revenuecat)
- Production:
- Set a static Authorization header value on the webhook. This is the shared secret that the server compares constant-time in
Webhooks::RevenueCatController#valid_auth?. It must equal the value provisioned asREVENUECAT_WEBHOOK_AUTH(next section).
Secrets to provision
- GitHub repo secrets —
VITE_REVENUECAT_IOS_KEYandVITE_REVENUECAT_ANDROID_KEY- These are the public RevenueCat SDK keys (one per platform). They are safe to ship in the client bundle.
- Consumed at build time by the
Build Web Assetsstep of the mobile build workflows (mobile-ios.yml,mobile-android.yml,mobile-android-release.yml) — wired in this task set. - Add under Settings → Secrets and variables → Actions. Until they exist a missing secret resolves to an empty string and IAP stays disabled in CI builds (no error). Provision them before relying on a CI-built app for purchases.
- GCP Secret Manager — webhook Authorization value →
REVENUECAT_WEBHOOK_AUTH- Store the webhook Authorization value as a GCP Secret Manager secret (e.g.
revenuecat-webhook-auth). - Map it into the
rails-apiCloud Run service as env varREVENUECAT_WEBHOOK_AUTHusing the existing<secret-name>:latest=ENV_VAR--set-secretsconvention (e.g.revenuecat-webhook-auth:latest → REVENUECAT_WEBHOOK_AUTH), the same pattern used forclerk-secret-key:latest → CLERK_SECRET_KEY. See docs/operations/deployment.md and the--set-secretsblock in.github/workflows/production.yml. - The value MUST equal the RevenueCat webhook Authorization header — the server does a byte-for-byte constant-time compare. A mismatch returns
401and the purchase never grants supporter tier.
- Store the webhook Authorization value as a GCP Secret Manager secret (e.g.
Product-id contract
These locations MUST stay in sync. Changing a product ID, entitlement, or offering in one place requires the matching change in the other(s).
| What | Where | Value |
|---|---|---|
| Frontend product→slug map | ionic_frontend/src/composables/useInAppPurchases.ts (PRODUCT_SLUG) | objectuve_supporter_monthly / objectuve_supporter_yearly / objectuve_lifetime_99 |
| Backend product→slug map | rails_api/app/interactions/billing/process_revenue_cat_webhook.rb (PRODUCT_MAP) | same three |
| Entitlement id | RevenueCat dashboard + useInAppPurchases.ts (SUPPORTER_ENTITLEMENT) | supporter |
| Offering id | RevenueCat dashboard + useInAppPurchases.ts (getOfferings → current) | default |
Device verification
- iOS (StoreKit sandbox):
- App Store Connect → Users and Access → Sandbox → create a sandbox tester.
- Sign in with the sandbox tester on a physical device.
- Install the build and run a test purchase of each product.
- Android (Play internal testing):
- Play Console → add the account as a license tester.
- Install the build from the internal testing track.
- Run a test purchase of each product.
- In both cases, confirm the full path fired:
- The webhook reached
POST /webhooks/revenuecatand returned200(check Cloud Run logs; a401meansREVENUECAT_WEBHOOK_AUTH≠ the RevenueCat Authorization header value). - A
PaymentRecordexists with providerrevenuecat. - The
billing.payment_processeddomain event published anduser.supporter_tierflipped (supporter badge / entitlement granted).
- The webhook reached
Last updated: 2026-06-09