In-app purchase with server to server notifications

 GitHub Repo

 

  • Contains these projects:
    • iOS-App (The iOS app that could be used to test the in-app purchase)
    • services-key-generator (a helper app that generates JWT for App Store content and server APIs)
    • app-store-server-library (Apple demo app that could be used to trigger various events in server to server notifications)
    • backend-server (the actual server, that will receive server-2-server notifications from Apple App Store server)

Creating application bundle in App Store connect

  1. Click + in `https://appstoreconnect.apple.com/apps`
  2. Create bundle identifier
    refer to Apple documentation for more details
  3. on ‘Platforms’ select iOS
  4. Add bundle identifier name (preferably following this pattern (com.MyCompany.appName)

Create new Application in App Store Connect

https://appstoreconnect.apple.com/apps

  1. Select New App
  2. Add the same app bundle that you created.
  3. Make sure that on `platforms` you select iOS

Create products in App Store connect.

Once you create application, you have to create some products to sell.

In general there are these types of products supported by Apple’s App Store:

  • Consumables.
    • Consumables are products that can be used (‘consumed’) once and then repurchased multiple times.
  • Non-consumables
    • Non-consumables are products that are purchased once, have no expiry date, and remain permanently available within your app.
  • Auto-renewal subscriptions
    • Auto-renewal subscriptions are products or services that you pay for on a recurring basis. Developers love these, as they guarantee a steady income stream.
  • Non auto-renewal subscriptions.
    • Non auto-renewal subscriptions are those that run for a fixed period of time, after which you can choose to manually renew. Often, these last longer and cost more than auto-renewal subscriptions.

To create products navigate to the newly created app, and locate “MONETIZATION” on the left side. From there you could select to create “in-app-purchases” or “Subscriptions

In app purchase

Refer to Apple documentation of how to create products: In-App purchase

 

When testing in the sandbox, subscription durations are shortened to help simulate different billing periods quickly:

  • 1 week subscription = 3 minutes in sandbox
  • 1 month subscription = 5 minutes in sandbox
  • 3 months subscription = 15 minutes in sandbox
  • 6 months subscription = 30 minutes in sandbox
  • 1 year subscription = 1 hour in sandbox

Creating Sandbox testing users

Ahh, yeah … you need test users so you won’t be charged with each test.

Refer to Apple documentation of how to create Sandbox test users

  • navigate to “users and access” -> sandbox
  • Click the “+” sign to create a new sandbox tester.

Set up your physical phone to use Sandbox test users

In order to test in-app purchase you have to deploy the app to the real device. Won’t work on the simulator.

In order to not be charged on every purchase test, you need to set up a Sandbox test users and set your physical iPhone to use them.

  1. Set up Sandbox test users (described above)
  2. On your physical phone navigate to Settings->App Store->SANDBOX ACCOUNT and add the new Sandbox user email there.

Make sure that you already enabled Developer mode on the iPhone

Creating the iOS app in X-Code

  1. Create new Xcode project and change the bundle id to the one that you set up above in the app store connect. (Refer to Creating application bundle in App Store connect in this article.)
  2. In-app purchase is not enabled by default. Let’s add it in Signing & Capabilities
  3. Add mainStore.storekit file by going to files->new and look for StoreKit2.

    – Name it mainStore.storekit
    – Make sure that you select ‘synced‘ so it will pull data from the actual App Store and select your apple bundle id (this is how the StoreKit2 will know which items to pull)4. Let’s add another Swift class which will be our shared Store.

store.swift

import StoreKit

@MainActor final class Store: ObservableObject {
    // use the same ids that you defined in the App Store connect
    private var productIDs = ["InAppPurchaseTutorialToniconsumableFuel", "InAppPurchaseTutorialToniConsumableOil", "BrakePads", "InAppPurchaseRenewalPro"]
    private var updates: Task<Void, Never>?
    
    @Published var products = [Product]()
    @Published var activeTransactions: Set<StoreKit.Transaction> = []
    
    init() {
        Task {
            await requestProducts()
        }
        Task {
            await transactionUpdates()
        }
    }
    
    deinit {
        updates?.cancel()
    }
    
    func transactionUpdates () async {
        for await update in StoreKit.Transaction.updates {
            if let transaction = try? update.payloadValue {
                activeTransactions.insert(transaction)
                await transaction.finish()
            }
            }
    }
    
    func requestProducts() async {
        do {
            products = try await Product.products(for: productIDs)
        } catch {
            print(error)
        }
    }
    
    func purchaseProduct(_ product: Product) async throws {
        print("Purchase tapped ...")
        let result = try await product.purchase()
        switch result {
        case .success(let verifyResult):
            print("Purchase successfull!")
            if let transaction = try? verifyResult.payloadValue {
                activeTransactions.insert(transaction)
                print("PURCHASE:")
                try print(verifyResult.payloadValue)
                print(transaction)
                await transaction.finish()
            }
        
        case .userCancelled:
            print("Purchase Canceled !")
            break
        case .pending:
            print("Purchase pending ...")
            break
        @unknown default:
            break
        }
    }
    
    func fetchActiveTransactions() async {
        var activeTransactions: Set<StoreKit.Transaction> = []
        for await entitelment in StoreKit.Transaction.currentEntitlements {
            if let transaction = try? entitelment.payloadValue {
                activeTransactions.insert(transaction)
                print("fetchActiveTransactions: ")
                print(transaction)
            }
        }
    }
}

In productIDs use the same ids that you defined in the App Store connect.

We define simple class that could be used in each View to retrieve purchases, make purchases, and update all views that is using it.
View updates happen since the class comforting to the ObservableObject protocol.

5. Edit the View to add purchase buttons.

Views/ContentView.swift

//
//  ContentView.swift
//  InAppPurchaseTutorial
//
//  Created by Toni Nichev on 1/2/24.
//

import SwiftUI
import StoreKit

struct ContentView: View {
    @EnvironmentObject var store: Store
    
    var body: some View {
        

        VStack {
            Text("Welcome to my store").font(.title)
            ProductView(id: "InAppPurchaseTutorialToniconsumableFuel") {
                Image(systemName: "crown")
            }
            .productViewStyle(.compact)
            .padding()
            .onInAppPurchaseCompletion { product, result in
                if case .success(.success(let transaction)) = result {
                    print("Purchased successfully: \(transaction.signedDate)")
                } else {
                    print("Something else happened")
                }
            }
        }
        
        Section(header: Text("To buy:").font(.title)) {
            ForEach(store.products, id: \.id)  { product in
                Button {
                    Task {
                        try await store.purchaseProduct(product)
                    }
                } label: {
                    HStack {
                        Text(product.displayName + ":")
                        Text(verbatim: product.displayPrice)
                    }
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

#Preview {
    ContentView().environmentObject(Store())
}

6. Last part is to add method to retreive purchases when the application starts.

InAppPurchaseTutorialApp.swift

//
//  InAppPurchaseTutorialApp.swift
//  InAppPurchaseTutorial
//
//  Created by Toni Nichev on 1/2/24.
//

import SwiftUI

@main
struct InAppPurchaseTutorialApp: App {
    @Environment(\.scenePhase) private var sceneParse
    @StateObject private var store = Store()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
                .task(id: sceneParse) {
                    if sceneParse == .active {
                        await store.fetchActiveTransactions()
                    }
                }
        }
    }
}

Setting up Backend server to listen to server-2-server notifications from Apple server

Apple server sends server-to-server notifications in real time when subscription event happens, like subscribed, cancel subscription, did renew etc.

Mode details of all notification types could be found in Apple documentation

Purpose: we can receive these notifications on transaction complete, on purchase made, on subscriptions: purchased, canceled, failed and we could grant access to subscribers to some paid services like (pro news, premium game levels etc.)

  1. Download AppleRootCA-G3.cer
  2. Convert certificate to PEM to use it in the PHP script below:
    On MacOS you could navigate to the downloaded certificate and execute:

    openssl x509 -in AppleRootCA-G3.cer -out apple_root.pem
  3. Copy the newly created PEM file to the root folder of the backend server handler, under ./assets folder.
  4. Create your backend server handler, that Apple App Store server will notify.
    I like to use PHP version cause it’s super easy to implement but any language works the same way:index.php

    <?php
    header('Status: 200');
    header("HTTP/1.1 200 OK"); 
    
    ini_set('display_errors', 1);
    error_reporting(E_ALL);
    
    
    // Download the certificate -> https://www.apple.com/certificateauthority/AppleRootCA-G3.cer
    // Convert it to .PEM file, run on macOS terminal ->  ```bash openssl x509 -in AppleRootCA-G3.cer -out apple_root.pem```
    
    $pem = file_get_contents('./assets/apple_root.pem');
    // $data = file_get_contents('./test.json'); // replace with file_get_contents('php://input');
    $data = file_get_contents('php://input'); // replace with file_get_contents('php://input');
    $json = json_decode($data);
    
    file_put_contents("./beep.txt", PHP_EOL . $data . PHP_EOL, FILE_APPEND);
    
    $header_payload_secret = explode('.', $json->signedPayload);
    
    //------------------------------------------
    // Header
    //------------------------------------------
    $header = json_decode(base64_decode($header_payload_secret[0]));
    
    $algorithm = $header->alg;
    $x5c = $header->x5c; // array
    $certificate = $x5c[0];
    $intermediate_certificate = $x5c[1];
    $root_certificate = $x5c[2];
    
    $certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $certificate
        . "\n-----END CERTIFICATE-----";
    
    $intermediate_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $intermediate_certificate
        . "\n-----END CERTIFICATE-----";
    
    $root_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $root_certificate
        . "\n-----END CERTIFICATE-----";
    
    //------------------------------------------
    // Verify the notification request   
    //------------------------------------------
    if (openssl_x509_verify($intermediate_certificate, $root_certificate) != 1){ 
        echo 'Intermediate and Root certificate do not match';
        exit;
    }
    
    // Verify again with Apple root certificate
    if (openssl_x509_verify($root_certificate, $pem) == 1){
        
        //------------------------------------------
        // Payload
        //------------------------------------------
        // https://developer.apple.com/documentation/appstoreservernotifications/notificationtype
        // https://developer.apple.com/documentation/appstoreservernotifications/subtype
    
        $payload = json_decode(base64_decode($header_payload_secret[1]));
        $notificationType = $payload->notificationType;
        $subtype = $payload->subtype;
    
        
    
        $transactionInfo = $payload->data->signedTransactionInfo;
        $ti = explode('.', $transactionInfo);
        
        $data = json_decode(base64_decode($ti[1]));
    
        // var_dump($payload); // this will contain our originalTransactionId
        file_put_contents("./data.txt", PHP_EOL . PHP_EOL . '=====================================' . PHP_EOL, FILE_APPEND);
        file_put_contents("./data.txt", print_r($payload, true),  FILE_APPEND);
        file_put_contents("./data.txt", '-------------------------------------' . PHP_EOL, FILE_APPEND);
        file_put_contents("./data.txt", print_r($data, true), FILE_APPEND);
    
        if($notificationType == "SUBSCRIBED") {
        }
        if ($notificationType == "EXPIRED" || $notificationType == "REFUND") {
        }
    } else {
        echo 'Header is not valid';
        exit;
    }
    

    what we just did:
    – decoded JWT
    – Saved the decoded response in the data.txt file
    so far so good, but we need to register our server-listener to the App-store connect so the Apple server will notify our server.

    Now it’s a good time to testing with curl command and make sure that the script reads POST body data.

    curl -X POST -H "Content-Type: application/json" -d '{"key":"value"}' https://yourserver.com/app-store-server-notification-tutorial/

    After executing the command look at ./beep.txt file for the raw response. If you see it, you are ready to continue with a real test notification from Apple server.

  5. Add your backend server url to Apps’ App Store Server notifications

 

Once server is set up you could issue test notification by following this tutorial from Apple.

If test notification is successful too, you could finally issue a real notification by doing test purchase from the app.

Now we could navigate to the physical phone, run the app and make a purchase.
Make sure that once prompted for the password you add the test account password.

The payload will look like this:

{
  "bundleId" : "com.toninichev.Blue.InAppPurchaseTutorial",
  "currency" : "USD",
  "deviceVerification" : "fewrfewwg5y334wPOJMZrp40ih0WW\/rwlc2fRsYqixrsB9g",
  "deviceVerificationNonce" : "7dfggrtrq6-9bec-4811-af53-edsfdfe675d4",
  "environment" : "Sandbox",
  "expiresDate" : 1704816065000,
  "inAppOwnershipType" : "PURCHASED",
  "originalPurchaseDate" : 1704479596000,
  "originalTransactionId" : "2000000494141906",
  "price" : 990,
  "productId" : "InAppPurchaseRenewalPro",
  "purchaseDate" : 1704815765000,
  "quantity" : 1,
  "signedDate" : 1704815791564,
  "storefront" : "USA",
  "storefrontId" : "143441",
  "subscriptionGroupIdentifier" : "21429665",
  "transactionId" : "2000000496365380",
  "transactionReason" : "PURCHASE",
  "type" : "Auto-Renewable Subscription",
  "webOrderLineItemId" : "2000000047529194"
}

At the same time our backend server should have received server-2-server notification that purchase was made that will look like this:

[notificationType] => SUBSCRIBED
[subtype] => RESUBSCRIBE
[notificationUUID] => b19f4a0f-5e0b-4092-8286-0ceb29ed757f
[data] => stdClass Object
    (
        [appAppleId] => 6475326521
        [bundleId] => com.toninichev.Blue.InAppPurchaseTutorial
        [bundleVersion] => 1
        [environment] => Sandbox
        [signedTransactionInfo] => eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURD ...
        [signedRenewalInfo] => eyJhbGciOiJFUzI1NiIsIng....
        [status] => 1
    )

[version] => 2.0
[signedDate] => 1704815828988

and the decoded transaction info:

[transactionId] => 2000000496365380
[originalTransactionId] => 2000000494141906
[webOrderLineItemId] => 2000000047529194
[bundleId] => com.toninichev.Blue.InAppPurchaseTutorial
[productId] => InAppPurchaseRenewalPro
[subscriptionGroupIdentifier] => 21429665
[purchaseDate] => 1704815765000
[originalPurchaseDate] => 1704479596000
[expiresDate] => 1704816065000
[quantity] => 1
[type] => Auto-Renewable Subscription
[inAppOwnershipType] => PURCHASED
[signedDate] => 1704815827353
[environment] => Sandbox
[transactionReason] => PURCHASE
[storefront] => USA
[storefrontId] => 143441
[price] => 990
[currency] => USD

Create API keys generator

Now we have server-2-server notification set up, but if we want to use more of Apple’s APIs (like transaction history, purchases, etc) we have to create an app to generate signed JWT.
Apple won’t accept JWT that lives longer than 20 min. so we have to make sure that this is the maximup ttl that we set up.

const expirationTime = now + 900; // Set to 15 minutes (900 seconds)

There are two types of Apple APIs like it was stated above:

This app creates two type of JWT keys: one for the App Store Content APIs, and one for App Store ServerAPIs
Make sure that you have them right.

jwt-generator.js

import jwt from "jsonwebtoken";
import fs from "fs";

const now = Math.round(new Date().getTime() / 1000);
const expirationTime = now + 900; // Set to 15 minutes (900 seconds)

export default async (keyId, issuerId, privateKeyFileLocation, bundleId) => {

    const privateKey = fs.readFileSync(privateKeyFileLocation);

    // Create the JWT header and payload
    const header = {
      'alg': 'ES256',
      'kid': keyId,
      'typ': 'JWT'
    };

    const payload = {
      "iss": issuerId,
      "iat": now,
      "exp": expirationTime,
      "aud": 'appstoreconnect-v1',
    };

    if(bundleId)
        payload["bid"] = bundleId;

    console.log('payload: ', payload);

    // Generate the JWT
    const token = jwt.sign(payload, privateKey, { header: header, algorithm: 'ES256' });

    console.log(`Generated JWT: ${token}`);
    return token;
}

generate-token.js

import jwt from "./jwt-generator.js";
import keys from "./keys/keys.js";

function getAppStoreContentApiToken() {
    const keyId = keys.appStoreContentKeyId;
    const issuerId = keys.issuerId;
    const privateKeyFileLocation = keys.appStoreContentPrivateKeyFileLocation;
    jwt(keyId, issuerId, privateKeyFileLocation, null);
}



function getAppStoreServerApiToken() {
    const keyId = keys.inAppPurchaseKeyId;
    const issuerId = keys.issuerId;
    const privateKeyFileLocation = keys.inAppPurchasePrivateKeyFileLocation;
    const bundleId = keys.bundleId;

    jwt(keyId, issuerId, privateKeyFileLocation, bundleId);
}

export default {
    getAppStoreServerApiToken,
    getAppStoreContentApiToken
}

jwt-generator.js

import jwt from "jsonwebtoken";
import fs from "fs";

const now = Math.round(new Date().getTime() / 1000);
const expirationTime = now + 500; // Set to 15 minutes (900 seconds)

export default async (keyId, issuerId, privateKeyFileLocation, bundleId) => {

    const privateKey = fs.readFileSync(privateKeyFileLocation);

    // Create the JWT header and payload
    const header = {
      'alg': 'ES256',
      'kid': keyId,
      'typ': 'JWT'
    };

    const payload = {
      "iss": issuerId,
      "iat": now,
      "exp": expirationTime,
      "aud": 'appstoreconnect-v1',
    };

    if(bundleId)
        payload["bid"] = bundleId;

    console.log('payload: ', payload);

    // Generate the JWT
    const token = jwt.sign(payload, privateKey, { header: header, algorithm: 'ES256' });

    console.log(`Generated JWT: ${token}`);
    return token;
}

 

Now we could explore Apple’s APIs.

Example querying NotificationHistoryRequest

We could use PostMan

  1. Generate JWT using the app above.
  2. Add the url and make sure that the method is POST
  3. Add two headers:
    ‘Content-Type’ : ‘application/json’
    ‘Authorization’ : ‘Bearer XXXXXXXXX
    where XXXXXXXXX is the JWT generated from the app above.
  4. Navigate to Body and add start and end date. In example:
    {
      “startDate”:1703949010000,
      “endDate” :1704745240512,
    }


    Response will have signedPayload of type JWT. You could decode it here
    Response should look like this:

    {
      "notificationType": "SUBSCRIBED",
      "subtype": "INITIAL_BUY",
      "notificationUUID": "fdc70802-df14-4157-8d01-38ff04eaac0b",
      "data": {
        "appAppleId": 6475326521,
        "bundleId": "com.toninichev.Blue.InAppPurchaseTutorial",
        "bundleVersion": "1",
        "environment": "Sandbox",
        "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZkMGZOdkZXdnpDMVlJQU5zWGpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJek1Ea3hNakU1TlRFMU0xb1hEVEkxTVRBeE1URTVOVEUxTWxvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCRUZFWWUvSnFUcXlRdi9kdFhrYXVESENTY1YxMjlGWVJWLzB4aUIyNG5DUWt6UWYzYXNISk9OUjVyMFJBMGFMdko0MzJoeTFTWk1vdXZ5ZnBtMjZqWFNqZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRkFNczhQanM2VmhXR1FsekUyWk9FK0dYNE9vL01BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3Tm9BREJsQWpFQTh5Uk5kc2twNTA2REZkUExnaExMSndBdjVKOGhCR0xhSThERXhkY1BYK2FCS2pqTzhlVW85S3BmcGNOWVVZNVlBakFQWG1NWEVaTCtRMDJhZHJtbXNoTnh6M05uS20rb3VRd1U3dkJUbjBMdmxNN3ZwczJZc2xWVGFtUllMNGFTczVrPSIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMDQ5MjA0MjcwMiIsIm9yaWdpbmFsVHJhbnNhY3Rpb25JZCI6IjIwMDAwMDA0OTIwNDI3MDIiLCJ3ZWJPcmRlckxpbmVJdGVtSWQiOiIyMDAwMDAwMDQ3MDc1MTE0IiwiYnVuZGxlSWQiOiJjb20udG9uaW5pY2hldi5CbHVlLkluQXBwUHVyY2hhc2VUdXRvcmlhbCIsInByb2R1Y3RJZCI6IkluQXBwUHVyY2hhc2VSZW5ld2FsUHJvIiwic3Vic2NyaXB0aW9uR3JvdXBJZGVudGlmaWVyIjoiMjE0Mjk2NjUiLCJwdXJjaGFzZURhdGUiOjE3MDQyNTg2MTEwMDAsIm9yaWdpbmFsUHVyY2hhc2VEYXRlIjoxNzA0MjU4NjE4MDAwLCJleHBpcmVzRGF0ZSI6MTcwNDI1ODkxMTAwMCwicXVhbnRpdHkiOjEsInR5cGUiOiJBdXRvLVJlbmV3YWJsZSBTdWJzY3JpcHRpb24iLCJpbkFwcE93bmVyc2hpcFR5cGUiOiJQVVJDSEFTRUQiLCJzaWduZWREYXRlIjoxNzA0MjU4NjIzNzYxLCJlbnZpcm9ubWVudCI6IlNhbmRib3giLCJ0cmFuc2FjdGlvblJlYXNvbiI6IlBVUkNIQVNFIiwic3RvcmVmcm9udCI6IlVTQSIsInN0b3JlZnJvbnRJZCI6IjE0MzQ0MSIsInByaWNlIjo5OTAsImN1cnJlbmN5IjoiVVNEIn0.1TnBWCm6WkmarcFsMVA_tYjmtLe2F6qKZVAUy_Y2j6Ki2vLA9KGW8xih3PZUb1UizXFbN-BbV7gnYxmPC4QlEg",
        "signedRenewalInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZkMGZOdkZXdnpDMVlJQU5zWGpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJek1Ea3hNakU1TlRFMU0xb1hEVEkxTVRBeE1URTVOVEUxTWxvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCRUZFWWUvSnFUcXlRdi9kdFhrYXVESENTY1YxMjlGWVJWLzB4aUIyNG5DUWt6UWYzYXNISk9OUjVyMFJBMGFMdko0MzJoeTFTWk1vdXZ5ZnBtMjZqWFNqZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRkFNczhQanM2VmhXR1FsekUyWk9FK0dYNE9vL01BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3Tm9BREJsQWpFQTh5Uk5kc2twNTA2REZkUExnaExMSndBdjVKOGhCR0xhSThERXhkY1BYK2FCS2pqTzhlVW85S3BmcGNOWVVZNVlBakFQWG1NWEVaTCtRMDJhZHJtbXNoTnh6M05uS20rb3VRd1U3dkJUbjBMdmxNN3ZwczJZc2xWVGFtUllMNGFTczVrPSIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJvcmlnaW5hbFRyYW5zYWN0aW9uSWQiOiIyMDAwMDAwNDkyMDQyNzAyIiwiYXV0b1JlbmV3UHJvZHVjdElkIjoiSW5BcHBQdXJjaGFzZVJlbmV3YWxQcm8iLCJwcm9kdWN0SWQiOiJJbkFwcFB1cmNoYXNlUmVuZXdhbFBybyIsImF1dG9SZW5ld1N0YXR1cyI6MSwic2lnbmVkRGF0ZSI6MTcwNDI1ODYyMzc2MSwiZW52aXJvbm1lbnQiOiJTYW5kYm94IiwicmVjZW50U3Vic2NyaXB0aW9uU3RhcnREYXRlIjoxNzA0MjU4NjExMDAwLCJyZW5ld2FsRGF0ZSI6MTcwNDI1ODkxMTAwMH0.TfMBSIU2kTu97Zs_V-MoOSaDPNdcKsaBKVodr46mZsgSvIY5LrTTSlPBgNj6BFlys2lYyMYAA2PrzJu-O5zxtA",
        "status": 1
      },
      "version": "2.0",
      "signedDate": 1704258625515
    }

     

    You could use jwt.io to decode signedTransactionInfo and signedRenewalInfo the same way.

    signedTransactionInfo

    {
      "transactionId": "2000000492042702",
      "originalTransactionId": "2000000492042702",
      "webOrderLineItemId": "2000000047075114",
      "bundleId": "com.toninichev.Blue.InAppPurchaseTutorial",
      "productId": "InAppPurchaseRenewalPro",
      "subscriptionGroupIdentifier": "21429665",
      "purchaseDate": 1704258611000,
      "originalPurchaseDate": 1704258618000,
      "expiresDate": 1704258911000,
      "quantity": 1,
      "type": "Auto-Renewable Subscription",
      "inAppOwnershipType": "PURCHASED",
      "signedDate": 1704258623761,
      "environment": "Sandbox",
      "transactionReason": "PURCHASE",
      "storefront": "USA",
      "storefrontId": "143441",
      "price": 990,
      "currency": "USD"
    }

     

    signedRenewalInfo

    {
      "originalTransactionId": "2000000492042702",
      "autoRenewProductId": "InAppPurchaseRenewalPro",
      "productId": "InAppPurchaseRenewalPro",
      "autoRenewStatus": 1,
      "signedDate": 1704258623761,
      "environment": "Sandbox",
      "recentSubscriptionStartDate": 1704258611000,
      "renewalDate": 1704258911000
    }

     

Leave a Reply