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
app/views/pricing/show.html.erbcheckshotwire_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.- The path configuration marks
/iap/subscribeand/iap/manageexternal. Each shell intercepts that navigation (OAuthNavigatorDelegateon iOS,MainActivity.handleExternalRouteon Android) and runs StoreKit or Play Billing natively instead of loading a web page, then reloads the web view once the purchase completes. - The purchase result posts its receipt to
/iap/purchase(Iap::PurchasesController). The server verifies it through theInAppadapter, following the same adapter+fake shape as every other integration in the kit, and activates the sameSubscriptionrecord a Stripe purchase would have created. Renewals and cancellations arrive later at/webhooks/app_storeand/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.