- [Who owns what: client, StoreKit/Play, and backend responsibilities]
- [SKU design that survives price changes and localization]
- [Designing a resilient purchase flow: edge cases, retries, and restores]
- [Server-side receipt validation and subscription reconciliation]
- [Sandboxing, testing, and staged rollout to avoid revenue loss]
- [Operational runbook: checklist, API snippets, and incident playbook]
Every mobile purchase is only as reliable as the weakest link between the client, the platform store, and your backend. Treat receipts and signed-store notifications as your system’s canonical sources of truth and build each layer to survive partial failures, abuse, and price churn.
The problem I see on most teams is operational: purchases work in happy-path QA, but edge cases create a steady stream of support tickets. Symptoms include incorrectly granted entitlements after refunds, missed auto-renewals, duplicate grants for the same purchase, and fraud from replayed client receipts. Those failures come from blurry ownership between client/store/backend, brittle SKU naming, and lax server validation and reconciliation.
Who owns what: client, StoreKit/Play, and backend responsibilities
Clear responsibility boundaries are the simplest defence against chaos.
| Actor | Primary responsibilities |
|---|---|
| Client (mobile app) | Present product catalog, run the purchase UI, handle UX states (loading, pending, deferred), collect platform-specific proof (receipt, purchaseToken, or signed transaction block), forward proof to backend, call finishTransaction() / acknowledge() only after server confirms entitlement grant. |
| Platform store (App Store / Google Play) | Process payment, issue signed receipts / tokens, provide server-side APIs and notifications (App Store Server API and Notifications V2; Google RTDN), enforce platform policies. |
| Backend (your server) | Authoritative validation and persistence of entitlements, call App Store / Google APIs for verification, handle notifications/webhooks, reconcile discrepancies, anti-fraud checks, and entitlement cleanup (refunds, cancels). |
Key operational rules (enforce in code and runbooks):
- The backend is the source of truth for user entitlements; client state is a cached view. This avoids entitlement drift when users switch devices or platforms.
- Always send platform proof (Apple: receipt or signed transaction; Android:
purchaseTokenplusoriginalJson/signature) to the backend for validation before granting durable access or persisting a subscription. - Do not acknowledge/finish a purchase locally until the backend has validated and stored the entitlement; this prevents auto-refunds and duplicate grants on retries. Google Play requires acknowledgement within three days or Google may refund the purchase.
acknowledgementguidance: check Play Billing docs.
Important: store-signed artifacts (JWS/JWT, receipt blobs, purchase tokens) are verifiable; use them as the canonical inputs to your server verification pipeline.
SKU design that survives price changes and localization
SKU design is a long-lived contract between product, code, and billing systems. Get it right once.
Rules for SKU naming
- Use a stable, reverse-DNS prefix:
com.yourcompany.app.. - Encode semantic product meaning, not price or currency:
com.yourcompany.app.premium.monthlyorcom.yourcompany.app.feature.unlock.v1. Avoid embeddingUSD/$/price` in the SKU. - Version using a trailing
vNonly when the product’s semantics truly change; prefer creating a new SKU for materially different product offerings rather than mutating an existing SKU. Keep migration paths in the backend mapping. - For subscriptions, separate product id (subscription) from base plan/offer (Google) or subscription group/price (Apple). On Play use
productId + basePlanId + offerIdmodel; on App Store use subscription groups and price tiers.
Pricing strategy notes
- Let the store manage local currency and tax; present localized prices by querying
SKProductsRequest/BillingClient.querySkuDetailsAsync()at runtime — do not hard-code prices.SkuDetailsobjects are ephemeral; refresh before showing checkout. - For subscription price increases, follow platform flows: Apple and Google provide managed UX for price changes (user confirmation when required) — reflect that flow in your UI and server logic. Rely on platform notifications for change events.
Example SKU table
| Use case | Example SKU |
|---|---|
| Monthly subscription (product) | com.acme.photo.premium.monthly |
| Annual subscription (base concept) | com.acme.photo.premium.annual |
| One-time non-consumable | com.acme.photo.unlock.pro.v1 |
Designing a resilient purchase flow: edge cases, retries, and restores
A purchase is a short-lived UX action but a long-lived lifecycle. Design for the lifecycle.
Canonical flow (client ↔ backend ↔ store)
- Client fetches product metadata (localized) via
SKProductsRequest(iOS) orquerySkuDetailsAsync()(Android). Render a disabled buy button until metadata returns. - User initiates purchase; platform UI handles payment. Client receives a platform proof (iOS: app receipt or signed transaction; Android:
Purchaseobject withpurchaseToken+originalJson+signature). - Client POSTs the proof to your backend endpoint (e.g.,
POST /iap/validate) withuser_idanddevice_id. Backend validates with App Store Server API or Google Play Developer API. Only after backend verification and persistence does the server respond OK. - Client, upon server OK, calls
finishTransaction(transaction)(StoreKit 1) /await transaction.finish()(StoreKit 2) oracknowledgePurchase()/consumeAsync()(Play) as appropriate. Failure to finish/acknowledge leaves transactions in a repeatable state.
Edge cases to handle (with minimal UX friction)
-
Pending payments / deferred parental approval: Present a "pending" UI and listen for transaction updates (
Transaction.updatesin StoreKit 2 oronPurchasesUpdated()in Play). Don’t grant entitlement until validation finishes. -
Network failure during validation: Accept the platform token locally (to avoid data loss), queue an idempotent job to retry server validation, and show a "verification pending" state. Use
originalTransactionId/orderId/purchaseTokenas idempotency keys. -
Duplicate grants: Use unique constraints on
original_transaction_id/order_id/purchase_tokenin the purchases table and make the grant operation idempotent. Log duplicates and increment a metric. (Example DB schema later.) - Refunds and chargebacks: Process platform notifications to detect refunds. Revoke access only per product policy (often revoke access for refunded consumables; for subscriptions follow your business policy), and keep an audit trail.
- Cross-platform & account linking: Map purchases to user accounts on the backend; enable account linking UI for users migrating between iOS and Android. The server must own the canonical mapping. Avoid granting access based solely on a client-side check on a different platform.
Practical client snippets
StoreKit 2 (Swift) — run purchase and forward proof to backend:
`swift
import StoreKit
func buy(product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
// Send transaction.signedTransaction or receipt to backend
let signed = transaction.signedTransaction ?? "" // platform-provided signed payload
try await Backend.verifyPurchase(signedPayload: signed, productId: transaction.productID)
await transaction.finish()
case .unverified(_, let error):
// treat as failed verification
throw error
}
case .pending:
// show pending UI
case .userCancelled:
// user cancelled
}
} catch {
// handle error
}
}
`
Google Play Billing (Kotlin) — on purchase update:
kotlin
override fun onPurchasesUpdated(result: BillingResult, purchases: MutableList<Purchase>?) {
if (result.responseCode == BillingResponseCode.OK && purchases != null) {
purchases.forEach { purchase ->
// Send purchase.originalJson and purchase.signature to backend
backend.verifyAndroidPurchase(packageName, purchase.sku, purchase.purchaseToken)
// backend will call Purchases.products:acknowledge or you can call acknowledge here after backend confirms
}
}
}
Note: Acknowledge/consume only after backend confirms to avoid refunds. Google requires acknowledgement for non-consumable purchases/initial subscription purchases or Play may refund within 3 days.
Server-side receipt validation and subscription reconciliation
The backend must run a robust verification and reconciliation pipeline — treat this as mission-critical infrastructure.
Core building blocks
-
Verify on receipt: Immediately call the platform verification endpoint when you receive client proof. For Google use
purchases.products.get/purchases.subscriptions.get(Android Publisher API). For Apple prefer the App Store Server API and the signed transaction flows; legacyverifyReceiptis deprecated in favor of App Store Server API + Server Notifications V2. -
Persist the canonical purchase record: Save fields such as:
-
user_id,platform,product_id,purchase_token/original_transaction_id,order_id,purchase_date,expiry_date(for subscriptions),acknowledged,raw_payload,validation_status,source_notification_id. - Enforce uniqueness on
purchase_token/original_transaction_idto dedupe. Use the DB primary/unique indexes to make the verify-and-grant operation idempotent.
-
-
Handle notifications:
- Apple: implement App Store Server Notifications V2 — they arrive as signed JWS payloads; verify signature and process events (renewal, refund, priceIncrease, grace period, etc.).
- Google: subscribe to Real-time Developer Notifications (RTDN) via Cloud Pub/Sub; RTDN tells you a state changed and you must call Play Developer API for full details.
-
Reconciliation worker: Run a scheduled job to scan accounts with questionable states (e.g.,
validation_status = pendingfor >48h) and call the platform APIs to reconcile. This catches missed notifications or race conditions. -
Security controls:
- Use OAuth service accounts for Google Play Developer API and the App Store Connect API key (.p8 + key id + issuer id) for Apple App Store Server API; rotate keys according to policy.
- Validate signed payloads using platform root certs and reject payloads with incorrect
bundleId/packageName. Apple provides libraries and examples to verify signed transactions.
Server-side example (Node.js) — verify Android subscription token:
`javascript
// uses googleapis
const {google} = require('googleapis');
const androidpublisher = google.androidpublisher('v3');
async function verifyAndroidSubscription(packageName, subscriptionId, purchaseToken, authClient) {
const res = await androidpublisher.purchases.subscriptions.get({
packageName,
subscriptionId,
token: purchaseToken,
auth: authClient
});
// res.data has fields like expiryTimeMillis, autoRenewing, acknowledgementState
return res.data;
}
`
For Apple verification use App Store Server API or Apple's server libraries to obtain signed transactions and decode/verify them; the App Store Server Library repo documents token use and decoding.
Reconciliation logic sketch
- Receive client proof -> validate immediately with store API -> insert canonical purchase record if verification succeeds (idempotent insert).
- Grant entitlement in your system atomically with that insert (transactionally or via event queue).
- Record the
acknowledgementState/finishedflag and persist raw store response. - On RTDN / App Store notification, lookup by
purchase_tokenororiginal_transaction_id, update DB, and re-evaluate entitlement.
Sandboxing, testing, and staged rollout to avoid revenue loss
Testing is where I spend the bulk of my time shipping billing code.
Apple testing essentials
- Use Sandbox test accounts in App Store Connect and test on real devices.
verifyReceiptlegacy flow is deprecated — adopt App Store Server API flows and test Server Notifications V2. - Use StoreKit Testing in Xcode (StoreKit Configuration Files) for local scenarios (renewals, expirations) during development and CI. Use the WWDC guidance for proactive restore behavior (StoreKit 2).
Google testing essentials
- Use internal/closed test tracks and Play Console license testers for purchases; use Play’s test instruments for pending payments. Test with
queryPurchasesAsync()and server-sidepurchases.*API calls. - Configure Cloud Pub/Sub and RTDN in a sandbox or staging project to test notifications and subscription lifecycle flows. RTDN messages are only a signal — always call the API to fetch full state after receiving RTDN.
Rollout strategy
- Use phased / staged rollouts (App Store phased release, Play staged rollout) to limit blast radius; observe metrics and stop the rollout on regression. Apple supports a 7-day phased release; Play provides percentage and country-targeted rollouts. Monitor payment success rates, acknowledgement errors, and webhooks.
Operational runbook: checklist, API snippets, and incident playbook
Checklist (pre-launch)
- [ ] Product IDs configured in App Store Connect and Play Console with matching SKUs.
- [ ] Backend endpoint
POST /iap/validateready and secured with auth + rate limits. - [ ] OAuth/service account for Google Play Developer API and App Store Connect API key (.p8) provisioned and secrets stored in a key vault.
- [ ] Cloud Pub/Sub topic (Google) and App Store Server Notifications URL configured and verified.
- [ ] Database unique constraints on
purchase_token/original_transaction_id. - [ ] Monitoring dashboards: validation success rate, ack/finish failures, RTDN inbound errors, reconcile job failures.
- [ ] Test matrix: create sandbox users for iOS and license testers for Android; validate happy-path and these edge cases: pending, deferred, price increase accepted/rejected, refund, linked-device restore.
Minimal DB schema (example)
sql
CREATE TABLE purchases (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
platform VARCHAR(16) NOT NULL, -- 'ios'|'android'
product_id TEXT NOT NULL,
purchase_token TEXT, -- Android
original_transaction_id TEXT, -- Apple
order_id TEXT,
purchase_date TIMESTAMP,
expiry_date TIMESTAMP,
acknowledged BOOLEAN DEFAULT false,
validation_status VARCHAR(32) DEFAULT 'pending',
raw_payload JSONB,
created_at TIMESTAMP DEFAULT now(),
UNIQUE(platform, COALESCE(purchase_token, original_transaction_id))
);
Incident playbook (high-level)
- Symptom: user reports they re-subscribed but still locked out.
- Check server logs for incoming validation requests for that
user_id. If missing, ask forpurchaseToken/receipt; verify quickly via API and grant; if client failed to POST proof, implement retry/backfill.
- Check server logs for incoming validation requests for that
- Symptom: purchases being auto-refunded on Play.
- Inspect acknowledgement path and ensure backend acknowledges purchases only after persistent grant. Look for
acknowledgeerrors and replay failures.
- Inspect acknowledgement path and ensure backend acknowledges purchases only after persistent grant. Look for
- Symptom: missing RTDN events.
- Pull transaction history / subscription status from platform API for affected users and reconcile; check Pub/Sub subscription delivery logs and allow Apple IP subnet (17.0.0.0/8) if you allowlist IPs.
- Symptom: duplicate entitlements.
- Verify uniqueness constraints on DB keys and reconcile records for duplicates; add idempotent guards at grant logic.
Sample backend endpoint (Express.js pseudocode)
javascript
app.post('/iap/validate', authenticate, async (req, res) => {
const { platform, productId, proof } = req.body;
if (platform === 'android') {
const verification = await verifyAndroidPurchase(packageName, productId, proof.purchaseToken);
// check purchaseState, acknowledgementState, expiry
await upsertPurchase(req.user.id, verification);
res.json({ ok: true });
} else { // ios
const verification = await verifyAppleTransaction(proof.signedPayload);
await upsertPurchase(req.user.id, verification);
res.json({ ok: true });
}
});
Auditability: store the raw platform response and the server verification request/response for 30–90 days to support disputes and audits.
Sources
App Store Server API - Apple’s official documentation for server-side APIs: transaction lookup, history, and guidance to prefer App Store Server API over legacy receipt verification. Used for server-side validation and recommended flows.
App Store Server Notifications V2 - Details about signed notification payloads (JWS), event types, and how to verify and process server-to-server notifications. Used for webhook/notification guidance.
Implement proactive in-app purchase restore — WWDC 2022 session 110404 - Apple guidance on StoreKit 2 restore patterns and the recommendation to post transactions to backend for reconciliation. Used for StoreKit 2 architecture and restore best practices.
Integrate the Google Play Billing Library into your app - Official Google Play Billing integration guidance including purchase acknowledgement requirements and querySkuDetailsAsync()/queryPurchasesAsync() usage. Used for acknowledge/consume rules and client flow.
Real-time developer notifications reference guide (Google Play) - Explains Play’s RTDN via Cloud Pub/Sub and why servers should fetch full purchase state after receiving a notification. Used for RTDN and webhook handling guidance.
Apple App Store Server Library (Python) - Apple-provided library and examples for validating signed transactions, decoding notifications, and interacting with the App Store Server API; used to illustrate server-side verification mechanics and signing key requirements.
purchases.subscriptions.get — Google Play Developer API reference - API reference to fetch subscription state from Google Play. Used for server-side subscription verification examples.
purchases.products.get — Google Play Developer API reference - API reference to verify one-time purchases and consumables on Google Play. Used for server-side purchase verification examples.
Release a version update in phases — App Store Connect Help - Apple documentation on phased rollouts (7-day phased release) and operational controls. Used for rollout strategy guidance.
.
Top comments (0)