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.
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.
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.
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.
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.
