Docs · Mobile Apps

In-App Purchases

Why Stripe can't run in-app, the StoreKit/Play Billing bridge, and the entitlement handoff.

Apple and Google require digital subscriptions purchased inside a native app to go through their own purchase systems, StoreKit and Play Billing, and they take roughly 15 to 30 percent of the charge for doing it. You generally can't use your Stripe web checkout inside the app to unlock a digital feature; the platforms don't allow it. One Shot handles this honestly rather than hiding it: web billing stays Stripe, and in-app billing goes through the platform's own system, wired end to end into the same entitlement your web subscribers get.

Be honest in your pricing UI. Don't show a Stripe checkout button inside the app and hope it works; the pricing view already branches on this, and any pricing UI you add yourself should do the same.

How the trigger is wired

  1. app/views/pricing/show.html.erb checks hotwire_native_app?. Inside the app, it renders a link to /iap/subscribe?product_id=... (and a "Manage" link to /iap/manage) instead of the Stripe checkout and billing-portal buttons a web visitor sees. Stripe is never surfaced inside the app at all.
  2. The path configuration marks /iap/subscribe and /iap/manage external. Each shell intercepts that navigation (OAuthNavigatorDelegate on iOS, MainActivity.handleExternalRoute on Android) and runs StoreKit or Play Billing natively instead of loading a web page, then reloads the web view once the purchase completes.
  3. The purchase result posts its receipt to /iap/purchase (Iap::PurchasesController). The server verifies it through the InApp adapter, following the same adapter+fake shape as every other integration in the kit, and activates the same Subscription record a Stripe purchase would have created. Renewals and cancellations arrive later at /webhooks/app_store and /webhooks/play, both idempotent and built to never 500, the same contract the Stripe webhook holds itself to.

Apple verification

StoreKit 2's client-side transaction token carries no certificate chain to verify locally, so InApp.verify_apple (app/adapters/app_store.rb) reads the transaction id from that token and confirms it authoritatively against the App Store Server API instead. That API's response is signed and gets validated against the pinned Apple Root CA, and the query is scoped to your own app, so a forged transaction id from another app is rejected. App Store Server Notifications (delivered to /webhooks/app_store) do carry a verifiable signature and are checked locally.

This switches on when the In-App Purchase key is configured (APP_STORE_KEY_ID/APP_STORE_ISSUER_ID/APP_STORE_PRIVATE_KEY); without those credentials, the offline fake stays active. Create the key in App Store Connect, under Users and Access, Integrations, In-App Purchase, and point the App Store Server Notifications V2 URL, both Production and Sandbox, at https://<your-host>/webhooks/app_store. The production App Store Server API only answers once your app is actually live on the App Store; sandbox and TestFlight verification work before that.

Google Play verification

The Play Billing client purchase token is an opaque handle with nothing to verify locally, so InApp.verify_play (app/adapters/play_store.rb) confirms it authoritatively against the Play Developer API instead, scoped to your app's package: a forged or foreign token 404s, and a purchase claiming the wrong product is rejected. Authentication is a short-lived service-account JWT exchanged for a Google OAuth2 token. The Play real-time developer notification webhook (/webhooks/play) re-derives authoritative state from the same API before it moves a subscription, rather than trusting the webhook payload directly.

This switches on when a service account is configured; without one, the offline fake stays active.

Store the Play service-account JSON in encrypted Rails credentials, not an environment variable. A roughly 3KB JSON blob passed through a Kamal/Docker container's environment can be silently mangled in transit, characters dropped from a long single-line value, which makes the decode produce garbage and every purchase fail with a 422 in production. Short secrets survive that path; long ones don't. Instead:

rails credentials:edit
# add:
# play:
#   service_account: <base64 of the service-account .json>

PlayStore.service_account decodes base64-or-raw, and the credentials read is gated to Rails.env.production?, so local development and tests keep using the offline fake regardless.

Setting up Play: create a Google Cloud service account, enable the Google Play Android Developer API, and grant it access in Play Console under Users and permissions (View financial data, plus Manage orders and subscriptions). For renewals and cancellations, configure real-time developer notifications: a Cloud Pub/Sub topic with the Play publisher granted Pub/Sub Publisher, a push subscription pointed at https://<your-host>/webhooks/play, and that topic entered in Play Console under Monetization setup.

Product ids

Create the actual subscription products in App Store Connect and Play Console, then map their ids in InApp::PRODUCT_PLANS. These ids can't be inferred; they have to match exactly what you configure in each store's console.

Next

Once purchases are wired up, review what each store expects before you submit: Submitting to the Stores.