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](/docs/mobile-apps/submitting-to-the-stores).
