<!-- skill: Mobile Payments | version: 1.0.0 | description: Play Billing and StoreKit 2 with server verify and subscriptions | category: Mobile -->
## Instructions
- Model catalog server-side: single SKU map (`pro_monthly`, `pro_yearly`, `credits_100`) mirrored in Play Console + App Store Connect; app fetches prices via billing APIs, never hardcodes.
- Play Billing v7: `BillingClient.newBuilder(ctx).enablePendingPurchases().build()`, `queryProductDetails(QueryProductDetailsParams.newBuilder().setProductList(listOf(..., ProductType.SUBS)))`, launch with `launchBillingFlow(activity, params)`.
- StoreKit 2: `let products = try await Product.products(for: ["pro_monthly"])`, `let result = try await product.purchase()`; handle `.success(.verified(let t))`, `.pending`, `.userCancelled`; finish with `await t.finish()`.
- Always server-verify: send `purchaseToken` (Play) / `transaction.jwsRepresentation` (StoreKit 2) to backend; backend calls Play Developer API `purchases.subscriptions.get` / App Store Server API `getTransactionInfo`; grant entitlement only on 200-verified.
- Subscriptions + grace: handle `SUBSCRIPTION_ON_HOLD`/`IN_GRACE_PERIOD` (Play) and `revocationDate`/`offer` (StoreKit 2); keep read-only access during grace, block on `expired`; support account hold resolution deep link.
- Acknowledge/consume: Play subs `acknowledgePurchase` within 3 days or auto-refund; consumables `consumeAsync`; StoreKit 2 auto-finishes on `finish()`, consumables re-purchasable after finish.
- Intro offers/trials: configure per SKU in consoles; surface `subscriptionOfferDetails` (Play) / `subscription.offer` (StoreKit 2) trial text exactly as returned; log `trial_started` analytics param.
- Restore: Play auto-restores via `queryPurchasesAsync(SUBS)` on launch; StoreKit 2 `for await result in Transaction.currentEntitlements` sync on launch + `AppStore.sync()` button; single "Restore" button required by Apple.
- Sandbox testing: Play license testers + `android.test.purchased` static SKU; StoreKit Config file (`.storekit`) + Xcode StoreKit Testing + Sandbox Apple IDs; test renew-times acceleration, billing retry, grace.
- Server notifications: Play RTDN (Pub/Sub `SUBSCRIPTION_*`) + App Store Server Notifications v2 (`DID_RENEW`, `DID_FAIL_TO_RENEW`, `REFUND`); verify `signedPayload` JWS on every webhook, update entitlement table idempotently.
- Policy: digital goods must use platform billing (no Stripe inside app for unlocks); disclose price/renewal/terms on paywall; provide cancel path + privacy/terms links or reject.
- Security: never trust client `purchaseState`; pin backend calls with service-account (Play) + ES256 JWT (App Store); rotate webhook secrets via secret manager.
## Examples
- Pro sub: monthly/yearly paywall, trial copy from offer details, server verify → `pro=true`, grace keeps access 3 days.
- Consumable credits: `credits_100` consume flow, server increments balance once per `orderId` (idempotent), sandbox static-SKU test.
- Win-back: RTDN `CANCEL` → offer promo code (Play) / win-back offer (StoreKit 2), `DID_RENEW` re-grants.
## Workflows
1. Console setup: SKUs/products, base plans/offers, tax, RTDN + ASNv2 endpoints with JWS verify.
2. Client: query catalog → paywall → purchase → send token/JWS to backend.
3. Backend: verify with Google/Apple APIs → upsert entitlement → return grant.
4. Restore + grace/hold handling + consumable consume paths.
5. Sandbox matrix: purchase/cancel/refund/grace/renew-accelerated on both stores.
6. Submit: paywall disclosures, restore button, tester notes with sandbox accounts.
## Templates
- Play purchase + verify (Kotlin):
```kotlin
val client = BillingClient.newBuilder(ctx).enablePendingPurchases()
  .setListener { _, purchases -> purchases?.forEach { verifyOnServer(it.purchaseToken) } }.build()
client.startConnection(object : BillingClientStateListener {
  override fun onBillingSetupFinished(r: BillingResult) {
    if (r.responseCode != BillingClient.BillingResponseCode.OK) return
    val params = QueryProductDetailsParams.newBuilder().setProductList(
      listOf(QueryProductDetailsParams.Product.newBuilder()
        .setProductId("pro_monthly").setProductType(BillingClient.ProductType.SUBS).build())).build()
    client.queryProductDetailsAsync(params) { _, details -> showPaywall(details.productDetailsList) }
  }
  override fun onBillingServiceDisconnected() {}
})
fun buy(details: ProductDetails) {
  val subParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder().build()
  val flow = BillingFlowParams.newBuilder().setProductDetailsParamsList(
    listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details).build())).build()
  client.launchBillingFlow(activity, flow)
}
// backend (node): POST /verify { token, sku } -> purchases.subscriptions.get(pkg, sku, token)
```
- StoreKit 2 purchase + restore (Swift):
```swift
import StoreKit
func buy(_ product: Product) async {
  do {
    let result = try await product.purchase()
    switch result {
    case .success(let verification):
      let t: Transaction
      switch verification { case .verified(let v): t = v; case .unverified: return }
      await sendJWS(t.jwsRepresentation, to: "/verify") // server: App Store Server API
      await t.finish()
    case .pending: break // show pending UI
    case .userCancelled: break
    @unknown default: break
    }
  } catch { print("buy failed \(error)") }
}
func restore() async {
  for await result in Transaction.currentEntitlements {
    if case .verified(let t) = result { await sendJWS(t.jwsRepresentation, to: "/verify") }
  }
}
```
## Tools
- SARAH shell_run for `bundle exec fastlane` paywall screenshots, backend webhook tunnel tests.
- SARAH file_write for paywall/verify/webhook scaffolds; web_fetch for Play Billing + StoreKit docs.
## Advanced
- Promo/wim-back: Play `SubscriptionPromotion` + `launchPriceChangeConfirmationFlow` for price hikes; StoreKit 2 `Product.SubscriptionInfo` win-back + `showManageSubscriptions(in:)`.
- Refund abuse: RTDN `REFUND` + ASNv2 `REFUND` revoke consumable balance (negative guard), flag `orderId` reuse; rate-limit verify endpoint.
- Multi-platform: single entitlement source (backend `user_id → pro_until`); RevenueCat paywall fallback only if consoles stay source of truth.
## Documentation
- https://developer.android.com/google/play/billing
- https://developer.apple.com/documentation/storekit/in-app-purchase
- https://developer.apple.com/documentation/appstoreserverapi
- https://developer.android.com/google/play/billing/rtdn-reference
