Skip to content

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

  1. The mobile build is configured with the RevenueCat public SDK key via VITE_REVENUECAT_IOS_KEY / VITE_REVENUECAT_ANDROID_KEY. The composable ionic_frontend/src/composables/useInAppPurchases.ts reads the platform key in apiKey() and calls Purchases.configure. (A missing/empty key makes apiKey() return ''; configure() captures a Sentry warning and no-ops, so IAP is silently disabled — that is the bug this runbook prevents.)
  2. On login the composable calls Purchases.logIn with the RevenueCat app_user_id set to the user's User#public_id, so the webhook can resolve the user.
  3. The user buys a package from the default offering.
  4. RevenueCat processes the store transaction and POSTs a webhook to POST /webhooks/revenuecat (Webhooks::RevenueCatController). The controller does a constant-time compare of the request Authorization header against REVENUECAT_WEBHOOK_AUTH (valid_auth? in revenue_cat_controller.rb); mismatch → 401 + Sentry warning.
  5. Billing::ProcessRevenueCatWebhook (rails_api/app/interactions/billing/process_revenue_cat_webhook.rb) maps the product_id via PRODUCT_MAP, records a PaymentRecord (provider revenuecat, idempotent on provider_event_id), and publishes the canonical billing.payment_processed domain event.
  6. A subscriber flips user.supporter_tier — the same subscriber the Stripe path uses. The backend remains the source of truth for User#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 IDApp Store ConnectPlay ConsolePrice
objectuve_supporter_monthlyAuto-Renewable Subscription — Subscriptions section, in the Supporter groupSubscription~$3.99/mo (web canonical $4.00/mo)
objectuve_supporter_yearlyAuto-Renewable Subscription — Subscriptions section, in the Supporter groupSubscription~$35.99/yr (web canonical $36.00/yr, "Save 25%")
objectuve_lifetime_99Non-Consumable — In-App Purchases sectionOne-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 99 in objectuve_lifetime_99 encodes 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 (matches PRODUCT_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. buy or lifetime-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 IDDisplay Name (≤30)Description (≤55)
objectuve_supporter_monthlySupporter MonthlySupport Objectuve. Themes, badge, and early access.
objectuve_supporter_yearlySupporter YearlySupport Objectuve. Themes, badge, and early access.
objectuve_lifetime_99Supporter LifetimeSupport 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:

  1. Subscription PriceSubscription Prices → + → set the US price (monthly ≈ $3.99, yearly ≈ $35.99). Most common cause of the flag.
  2. App Store Localization — Display Name + Description (see table above), at least English (U.S.).
  3. Review Information → Review Screenshot — required; a grab of the in-app SupporterUpgradeModal paywall works. Optional review notes.
  4. 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

  1. Create a RevenueCat project with one iOS app and one Android app.
  2. Create an entitlement with identifier exactly supporter (matches SUPPORTER_ENTITLEMENT in useInAppPurchases.ts).
  3. Create one offering with identifier exactly default containing all three products as packages (the composable reads getOfferings().current).
  4. 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

  1. 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)
  2. 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 as REVENUECAT_WEBHOOK_AUTH (next section).

Secrets to provision

  • GitHub repo secrets — VITE_REVENUECAT_IOS_KEY and VITE_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 Assets step 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-api Cloud Run service as env var REVENUECAT_WEBHOOK_AUTH using the existing <secret-name>:latest=ENV_VAR --set-secrets convention (e.g. revenuecat-webhook-auth:latest → REVENUECAT_WEBHOOK_AUTH), the same pattern used for clerk-secret-key:latest → CLERK_SECRET_KEY. See docs/operations/deployment.md and the --set-secrets block 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 401 and the purchase never grants supporter tier.

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).

WhatWhereValue
Frontend product→slug mapionic_frontend/src/composables/useInAppPurchases.ts (PRODUCT_SLUG)objectuve_supporter_monthly / objectuve_supporter_yearly / objectuve_lifetime_99
Backend product→slug maprails_api/app/interactions/billing/process_revenue_cat_webhook.rb (PRODUCT_MAP)same three
Entitlement idRevenueCat dashboard + useInAppPurchases.ts (SUPPORTER_ENTITLEMENT)supporter
Offering idRevenueCat dashboard + useInAppPurchases.ts (getOfferingscurrent)default

Device verification

  • iOS (StoreKit sandbox):
    1. App Store Connect → Users and Access → Sandbox → create a sandbox tester.
    2. Sign in with the sandbox tester on a physical device.
    3. Install the build and run a test purchase of each product.
  • Android (Play internal testing):
    1. Play Console → add the account as a license tester.
    2. Install the build from the internal testing track.
    3. Run a test purchase of each product.
  • In both cases, confirm the full path fired:
    • The webhook reached POST /webhooks/revenuecat and returned 200 (check Cloud Run logs; a 401 means REVENUECAT_WEBHOOK_AUTH ≠ the RevenueCat Authorization header value).
    • A PaymentRecord exists with provider revenuecat.
    • The billing.payment_processed domain event published and user.supporter_tier flipped (supporter badge / entitlement granted).

Last updated: 2026-06-09

Loading…