Quizzr Logo

In-App Purchase (IAP) Architecture

Implementing Secure Client-Side Transaction Handoff to Backend

Learn how to capture purchase tokens and JWS-signed transactions on iOS and Android to safely transmit them to your validation server.

Mobile DevelopmentIntermediate12 min read

The Architectural Shift: From Client-Side Trust to Server-Side Authority

In the early days of mobile development, many engineers attempted to verify purchases directly on the device by checking the local response from the App Store or Play Store. This approach is fundamentally flawed because the client environment is inherently compromised and can be manipulated by tools that simulate successful payment responses. To build a robust monetization system, we must treat the mobile application as a low-trust environment that only serves as a relay for transaction data.

The modern architectural standard shifts the responsibility of validation to a secure backend environment that communicates directly with platform providers. This ensures that every digital good or subscription is granted based on verified proof rather than a local boolean flag. By moving the validation logic off the device, you eliminate the risk of local data tampering and create a single source of truth for user entitlements.

Implementing this pattern requires a clear understanding of what each platform provides as evidence of a transaction. On iOS, you are dealing with cryptographically signed JSON Web Signatures that provide self-contained proof of purchase. On Android, you receive a purchase token which acts as a unique pointer that your server must exchange for actual transaction details via a secure API call.

The mobile client should never be the final judge of a transaction status; its only job is to capture the platform-signed artifacts and safely deliver them to your infrastructure for scrutiny.

Identifying the Vulnerabilities of Local State

Local state management is susceptible to man in the middle attacks where an attacker intercepts the network traffic between the device and the platform store. Even with certificate pinning, a compromised device can use runtime injection to override the logic that determines if a user has access to premium features. Relying on the backend ensures that even if the app binary is modified, the underlying service will not provide the requested content without a valid server-side record.

Another critical issue with local validation is the lack of synchronization across multiple devices. If a user buys a subscription on their phone, their tablet will have no way of knowing about that purchase without a centralized database. A server-centric architecture naturally solves the cross-device problem by linking purchases to a unique user identifier in your own system rather than a device-specific ID.

Capturing JWS-Signed Transactions on iOS with StoreKit 2

StoreKit 2 revolutionized how developers handle purchase data on Apple platforms by moving away from the complex legacy receipt format. Instead, it uses the JSON Web Signature standard to provide a secure and compact representation of a transaction. When a user completes a purchase, the App Store provides your app with a signed string that contains all the necessary metadata to verify the purchase offline or online.

This JWS artifact contains a header, a payload, and a signature that is cryptographically tied to Apple's root certificates. The payload includes critical information such as the product identifier, the transaction date, and a unique transaction ID. Your primary goal on the client is to listen for these transaction updates and ensure they are persisted and forwarded to your server without modification.

swiftHandling StoreKit 2 Transaction Updates
1import StoreKit
2
3class PurchaseManager {
4    // This task monitors for transactions initiated outside the app
5    var transactionListener: Task<Void, Error>?
6
7    init() {
8        transactionListener = observeTransactionUpdates()
9    }
10
11    private func observeTransactionUpdates() -> Task<Void, Error> {
12        return Task.detached {
13            // Iterate through any new transactions provided by StoreKit
14            for await result in Transaction.updates {
15                switch result {
16                case .verified(let transaction):
17                    // The JWS is valid; capture the raw data to send to the server
18                    let signedData = transaction.jsonRepresentation
19                    await self.transmitToServer(jwsData: signedData)
20                    
21                    // Always finish the transaction after your server confirms receipt
22                    await transaction.finish()
23                case .unverified(_, let error):
24                    // Handle cryptographic verification failure
25                    print("Transaction signature is invalid: \(error)")
26                }
27            }
28        }
29    }
30}

Extracting the Transaction Payload

When capturing the JWS on iOS, you should avoid parsing the data locally to make business decisions. Instead, extract the raw verification result and pass it directly to your backend service as a string. This prevents the client from becoming a bottleneck for new product types or updated metadata fields that Apple might introduce in the future.

One common pitfall is failing to call the finish method on the transaction object. If your app captures the token but crashes before notifying the server, the App Store will continue to prompt the user or replay the transaction. You should only finish the transaction after your backend has successfully persisted the record and returned a successful HTTP status code.

Managing Android Purchase Tokens and Play Billing

Google Play takes a slightly different approach compared to Apple by focusing on a token-based system for purchase validation. When a purchase is successful, the Play Billing Library returns a purchase object containing a purchase token. This token is not self-signed data; rather, it is a unique identifier that acts as a key to retrieve the transaction details from the Google Play Developer API.

The lifecycle on Android requires a strict sequence of acknowledge or consume operations to prevent automatic refunds. If a purchase is not acknowledged within three days, Google assumes the app failed to deliver the content and reverts the transaction. This makes the reliable transmission of the purchase token to your server a mission-critical part of your application logic.

kotlinCapturing Purchase Tokens in Android
1val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
2    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
3        for (purchase in purchases) {
4            // Check if the purchase is in the purchased state before proceeding
5            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
6                // The token is the unique identifier needed by the backend
7                val token = purchase.purchaseToken
8                val productId = purchase.products.firstOrNull()
9                
10                // Forward the token and product info to your validation endpoint
11                sendTokenToBackend(token, productId)
12            }
13        }
14    }
15}
16
17fun sendTokenToBackend(token: String, productId: String?) {
18    // Implementation of your network request to the validation server
19    // Ensure you use a retry policy for network failures
20}

The Importance of Acknowledgment

Once your server receives the purchase token, it must use a service account to call the Google Play API and verify the status. After the server confirms the purchase is valid and updates the user database, it must signal the client to acknowledge the purchase. This handshake ensures that the user is only charged when the entitlement has been safely recorded in your system.

If your backend logic is slow or experiences downtime, the client must be prepared to retry the token transmission during the next app launch. Storing the pending token in local encrypted storage is a recommended practice to ensure that no valid purchase is lost due to transient network issues.

Designing a Resilient Transmission Layer

Sending a purchase token from a mobile device to a server is not as simple as a standard API call. Mobile networks are notoriously unreliable, and transactions can be interrupted by tunnel entries, battery death, or app crashes. Your transmission layer must be designed with idempotency and persistence to handle these edge cases without double-charging or failing to deliver goods.

The API endpoint on your server should be designed to accept the platform-specific token and a unique request identifier generated by the client. This allows the server to recognize if it has already processed the same transaction, preventing duplicate records in your database. Using a standard structure for your request payload makes it easier to support both iOS and Android within the same backend logic.

  • Platform Type: Explicitly define whether the token is from iOS or Android to route to the correct validation logic.
  • Transaction Artifact: The raw JWS string for iOS or the purchase token for Android.
  • User Identity: A secure identifier linked to the logged-in user account in your system.
  • Client Metadata: Information like app version and locale to help with debugging and regional pricing analysis.
jsonStandardized Validation Request Payload
1{
2  "platform": "ios",
3  "transaction_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6In...",
4  "internal_user_id": "user_98765",
5  "idempotency_key": "uuid-v4-generated-on-client",
6  "app_version": "2.1.0"
7}

Retry Strategies and Offline Support

To maximize the success rate of purchase processing, implement an exponential backoff strategy for failed network requests. If the server is unreachable, the client should store the transaction details in a persistent queue. This queue should be processed as soon as the network state changes or during the next application foreground event.

Developers often forget to handle the scenario where a user switches accounts or devices mid-purchase. By including an internal user identifier in the transmission payload, your server can ensure the purchase is mapped to the correct account regardless of the device state. This prevents entitlement drifting where a purchase is verified but never actually applied to the user's profile.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.