Mobile in-app purchases (Apple / Google)
Module id: mobile_iap · Kind: commerce · Configured by: at least one IAP_PRODUCT_* mapped to a store product · Healthy when: an Apple or Google receipt verifier is configured too
Apple and Google require that a subscription bought inside a native app goes through their stores, not through Stripe. This module is the server side of that rule: it maps store product ids to Synaplan tiers and verifies every receipt server-side before it grants anything. You only need it if you publish your own build of the Synaplan mobile apps against your server and sell subscriptions in it. The published Synaplan apps pointed at a self-hosted server do not require it.
Background and design notes: docs/PAYMENTS_CHANNELS.md in the main repository.
What it adds
- Product ↔ tier mapping (
IAP_PRODUCT_PRO/_TEAM/_BUSINESS) and per-tier store prices for display in the app. POST /api/v1/iap/verify— redeem a fresh purchase for the signed-in user; Apple StoreKit 2 transaction JWS or Googlepurchases.subscriptionsv2.POST /api/v1/iap/apple/notificationsand/google/notifications— renewals, cancellations, refunds and grace periods applied without the app open.- One owner per subscription: a receipt already bound to another account is refused, Google pending purchases never unlock early, and a user with an active Stripe subscription cannot buy again through a store (and vice versa).
Without it
GET /api/v1/subscription/plans reports iapConfigured: false and the app does not offer a purchase; web checkout through Stripe is unaffected. When the module is absent and its gate is on, /api/v1/iap/verify answers 404 feature_not_configured. The two store notification endpoints are never gated — they keep answering 503 so Apple and Google retry instead of dropping the event.
How to enable
1. Create the products in the stores
In App Store Connect and the Play Console create one auto-renewable subscription per tier. The store price must include the store commission (≈30 %, sometimes 15 %) — see the pricing notes in PAYMENTS_CHANNELS.md.
2. Map them to tiers
IAP_PRODUCT_PRO=com.example.app.pro.monthly
IAP_PRODUCT_TEAM=com.example.app.team.monthly
IAP_PRODUCT_BUSINESS=com.example.app.business.monthly
IAP_STORE_PRICE_PRO=24.99 # fixed EUR store price points shown in the app
IAP_STORE_PRICE_TEAM=64.99
IAP_STORE_PRICE_BUSINESS=129.99
IAP_PRICE_MARKUP_PERCENT=30 # fallback when a tier's store price is 0: web price + markup, snapped to x.99
There are deliberately no defaults: a forgotten product id switches IAP off loudly instead of pointing at a product that does not exist in your account.
3. Configure at least one verifier
Apple (App Store Server API, StoreKit 2):
IAP_APPLE_BUNDLE_ID=com.example.app
IAP_APPLE_APP_APPLE_ID=1234567890
IAP_APPLE_ENVIRONMENT=Production # or Sandbox
IAP_APPLE_ROOT_CERTS_DIR=/secrets/apple-roots # Apple root certificates, DER, exactly as Apple ships them — MOUNT the directory
Google (Play Developer API through a service account):
IAP_GOOGLE_PACKAGE_NAME=com.example.app
IAP_GOOGLE_SERVICE_ACCOUNT_JSON=/secrets/play-service-account.json
Mount both secret locations into the backend and worker containers; a copy made inside a container is lost on the next recreate. Restart both.
4. Point the store notifications at your server
App Store Connect → App Store Server Notifications V2 → https://<your-domain>/api/v1/iap/apple/notifications.
Play Console → Monetization setup → Real-time developer notifications → a Pub/Sub topic with a push subscription to https://<your-domain>/api/v1/iap/google/notifications.
Check it works
- Operate → Feature Status → Mobile in-app purchases:
- Available / IAP products mapped; receipt verification ready — details list the mapped products and
apple_verifier/google_verifierflags. - Needs setup / IAP products mapped but no store verifier is configured — purchases would be refused with
503. - Not installed / No IAP_PRODUCT_* tier is mapped to a store product.
- Available / IAP products mapped; receipt verification ready — details list the mapped products and
GET /api/v1/subscription/plansshowsiapConfigured: trueand aniapProductIdper mapped tier.- Make a sandbox purchase from a test build and check the tier changes on the account.
Configuration reference
| Variable | Default | Meaning |
|---|---|---|
IAP_PRODUCT_PRO / _TEAM / _BUSINESS |
empty | Store product id per tier. At least one makes the module configured. |
IAP_STORE_PRICE_PRO / _TEAM / _BUSINESS |
24.99 / 64.99 / 129.99 |
Fixed EUR store price shown as appPrice until the store's localized price loads. 0 = use the markup fallback. |
IAP_PRICE_MARKUP_PERCENT |
30 |
Fallback markup on the web price, snapped to the nearest x.99. |
IAP_APPLE_BUNDLE_ID |
empty | iOS bundle identifier. |
IAP_APPLE_APP_APPLE_ID |
empty | Numeric App Store id of the app. |
IAP_APPLE_ENVIRONMENT |
Production |
Production or Sandbox. |
IAP_APPLE_ROOT_CERTS_DIR |
empty | Directory with Apple's root certificates in DER form. |
IAP_GOOGLE_PACKAGE_NAME |
empty | Android package name. |
IAP_GOOGLE_SERVICE_ACCOUNT_JSON |
empty | Absolute path to the Play service-account JSON. |
Troubleshooting
- Every Apple purchase fails while the deployment looks healthy. The root certificates were converted to PEM or copied into the container instead of mounted. Use the DER files as downloaded from Apple and mount the directory.
iapConfigured: falsealthough the products are set. Restart backend and worker; the values are read at start. Check for a typo — the ids must match the store exactly.- Google purchase stays pending. Deferred / SCA purchases are only granted when the confirming real-time notification arrives; check the Pub/Sub push subscription reaches your server.
IAP_OWNERSHIP_CONFLICT. The receipt is already bound to another account, or the user has an active Stripe subscription. The UI explains where the existing subscription is managed.
Related
- Stripe billing module — the web checkout counterpart
- Mobile Apps — the iPhone and Android apps
- Hosting Platform — tiers, budgets, selling AI capacity
- Feature modules — states, gates, the CLI