StoreKit 2 API: How Apple simplified integration of in-app purchases
Updated: October 9, 2023
14 min read
Apple introduced a new version of StoreKit 2 during WWDC 2021 that took place recently. This is a framework responsible for making purchases in iOS. A share of apps with in-app purchase and subscription features grows steadily, and Apple significantly simplified integration of in-app purchases into the app by releasing StoreKit 2. Today, we will consider working with StoreKit 2 on the part of the server, in other words, with the help of App Store Server API.
API request authentication
In the current API version, you need Shared Secret to send a request. This is a secret fixed string that you can get in App Store Connect. A new version of API uses JSON Web Token (JWT) standard for request authentication.
IAP key generation
First of all, create a private key that will be used to authorize the requests. Open App Store Connect and go to the Users and Access section, then to Keys tab. Select In-App Purchase key type. Download a new key. You will also need its ID – you can copy it on the same page as Issue ID which can be found in the App Store Connect API tab.
Creating a token
The next step is to create a token that will be used to authorize the requests. This process is described in detail in documentation, so there’s no reason to pay too much attention to it. Here’s an example of a ready-made implementation for Python. It’s worth noting that it makes no sense to generate a new token for every new request. When creating a token, you set its lifetime at up to 60 minutes and use the same token during this period.
import time, uuid
from authlib.jose import jwt
BUNDLE_ID = 'com.adapty.sample_app'
ISSUER_ID = '4336a124-f214-4d40-883b-6db275b5e4aa'
KEY_ID = 'J65UYBDA74'
PRIVATE_KEY = '''
-----BEGIN PRIVATE KEY-----
MIGTAgMGByqGSMBHkAQQgR/fR+3Lkg4...
-----END PRIVATE KEY-----
'''
issue_time = round(time.time())
expiration_time = issue_time + 60 * 60 # 1 hour expiration
header = {
'alg': 'ES256',
'kid': KEY_ID,
'typ': 'JWT'
}
payload = {
'iss': ISSUER_ID,
'iat': issue_time,
'exp': expiration_time,
'aud': 'appstoreconnect-v1',
'nonce': str(uuid.uuid4()),
'bid': BUNDLE_ID
}
token_encoded = jwt.encode(header, payload, PRIVATE_KEY)
token_decoded = token_encoded.decode()
authorization_header = {
'Authorization': f'Bearer {token_decoded}'
}
StoreKit 2 signed transactions
In a new version of API, all transactions returned in JSON Web Signature (JWS) standard. This is a string consisting of three parts divided by dots.
- Base64 header.
- Base64 transaction payload.
- Transaction signature.
Base64(header) + "." + Base64(payload) + "." + sign(Base64(header) + "." + Base64(payload))
Transaction header
A header is needed to make sure the transaction is authentic. Alg key contains an encryption algorithm, x5c key contains a certificate chain.
{
"kid": "AMP/DEV",
"alg": "ES256",
"x5c": [
"MIIEO...",
"MIIDK..."
]
}
Transaction payload
{
"transactionId": "1000000831360853",
"originalTransactionId": "1000000806937552",
"webOrderLineItemId": "1000000063561721",
"bundleId": "com.adapty.sample_app",
"productId": "basic_subscription_1_month",
"subscriptionGroupIdentifier": "27636320",
"purchaseDate": 1624446341000,
"originalPurchaseDate": 1619686337000,
"expiresDate": 1624446641000,
"quantity": 1,
"type": "Auto-Renewable Subscription",
"appAccountToken": "fd12746f-2d3a-46c8-bff8-55b75ed06aca",
"inAppOwnershipType": "PURCHASED",
"signedDate": 1624446484882,
"offerType": 2,
"offerIdentifier": "basic_subscription_1_month.pay_as_you_go.3_months"
}
Apple changed and extended the transaction format. From my point of view, now, it’s more convenient to work with them. You can learn details about a new format in documentation. Below, I will describe the most important changes.
- Apple added appAccountToken field, which contains your system’s user ID. This ID must be in UUID format, it is set in the mobile app when a purchase is being initialized. If it is set, it will be returned in all transactions in this chain (renewal, billing issues, etc.), and you will easily understand which user made a purchase.
- Apple also added offerType and offerIdentifier fields that contain the information about a used offer (if any). Here are values for offerType field:
- 1 — intro offer (available only for the users without active or expired subscriptions)
- 2 — promo offer (available only for current and expired subscriptions)
- 3 — offer code
If a promo offer or offer code was used, offerIdentifier key will contain the ID of the used offer. In the past, it was impossible to track the use of the offer on the server-side, this worsened the analytics. Now, you can use offer codes for analytics.
- Apple added inAppOwnershipType field, which helps to understand whether a user bought a product or accessed it thanks to a family subscription. Possible values:
- PURCHASED
- FAMILY_SHARED
- Another new field – type – includes transaction type. Possible values:
- Auto-Renewable Subscription
- Non-Consumable
- Consumable
- Non-Renewing Subscription
- Cancellation_date and cancellation_reason fields have new names now: revocationDate and revocationReason. As a reminder, they contain a date and a reason for subscription revocation as a result of a refund, so the new name looks more logical.
- All keys return in camelCase format (just like in all App Store Server API requests).
- All dates are displayed in Unix timestamp format in milliseconds.
App Store user subscription status
To check the current user’s subscription status, send a GET request to https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}, where {originalTransactionId} is the ID of any transaction chain of the user. In return, you will get transactions with statuses for every group of subscriptions.
{
"environment": "Sandbox",
"bundleId": "com.adapty.sample_app",
"data": [
{
"subscriptionGroupIdentifier": "39636320",
"lastTransactions": [
{
"originalTransactionId": "1000000819078552",
"status": 2,
"signedTransactionInfo": "eyJraWQiOi...",
"signedRenewalInfo": "eyJraWQiOi..."
}
]
}
]
}
The status key displays the current subscription status, based on it, you can decide if you should provide a user with access to the paid features of the app. Possible values:
- 1 — subscription is active, a user must be able to access paid features.
- 2 — subscription has expired, a user must not be able to access paid functions.
- 3 — the subscription’s status is Billing Retry, meaning that a user didn’t cancel it, but experiences problems with paying. Apple will try to charge the card for 60 days. A user must not be able to access paid functions.
- 4 — the subscription’s status is Grace Period, meaning that a user didn’t cancel it, but experiences payment issues. Grace Period is on in App Store Connect, so a user must be able to access paid features.
- 5 — subscription was canceled as a result of a refund, a user must not be able to access paid functions.
SignedTransactionInfo key contains the information about the last transaction in the chain. You can find the details about its format above.
Information about subscription renewal
SignedRenewalInfo key contains the information about subscription renewal.
{
"expirationIntent": 1,
"originalTransactionId": "1000000819078552",
"autoRenewProductId": "basic_subscription_1_month",
"productId": "basic_subscription_1_month",
"autoRenewStatus": 0,
"isInBillingRetryPeriod": false,
"signedDate": 1624520884048
}
This information allows us to understand what will happen to the subscription during the next pay period. For example, if you see that a user canceled auto-renewal, you can offer them to switch to another subscription plan or provide them a promo offer. It’s convenient to track this kind of events with the help of server notifications, which I’ll tell you about soon.
User’s transaction history
To get the user’s transaction history, send GET request to https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}, where {originalTransactionId} is the ID of any chain of transactions of the user. In return, you will get an array of transactions sorted by time.
{
"revision": "1625872984000_1000000212854038",
"bundleId": "com.adapty.sample_app",
"environment": "Sandbox",
"hasMore": true,
"signedTransactions": [
"eyJraWQiOiJ...",
"joiRVMyNeyX...",
"5MnkvOTlOZl...",
...
]
}
A request can contain no more than 20 transactions. If a user has more, the hasMore flag’s value will be true. If you need the next transaction page, send the request again with the revision GET parameter containing. It will contain the value from the same key.
Server transaction notifications
Server notifications help to get information about new purchases, renewals, billing issues, etc. This helps to build more accurate analytics, as well as simplifies managing the subscriber’s status.
The existing server notifications (V1) can solve most of the problems, but sometimes they are inconvenient. Mostly, it’s about the situation when you get several notifications for just one action of a user. For example, now, when a user upgrades a subscription, Apple sends two notifications: DID_CHANGE_RENEWAL_STATUS and INTERACTIVE_RENEWAL. To process this case currently, you need to save the status somehow and check if the second notification was sent. In a new version of server notifications (V2), there’s only one notification for one action of a user. This is much more convenient.
The second version of server notifications features new events – OFFER_REDEEMED, EXPIRED, and GRACE_PERIOD_EXPIRED. They make managing subscriber status much easier. SUBSCRIBED and PRICE_INCREASE events are improved events from the first version.
Notification types
Notifications now have types, thus, one notification for any action of a user is enough to understand what happened.
Notification types
{
"notificationType": "SUBSCRIBED",
"subtype": "INITIAL_BUY",
"version": 2,
"data": {
"environment": "Sandbox",
"bundleId": "com.adapty.sample_app",
"appAppleId": 739104078,
"bundleVersion": 1,
"signedTransactionInfo": "eyJraWQiOi...",
"signedRenewalInfo": "eyJraWQiOi..."
}
}
Server notifications contain information about a transaction and a renewal in JWS format which I described earlier.
Working with Sandbox environment
You need to use the URL of the Sandbox environment to test purchases: https://api.storekit-sandbox.itunes.apple.com.
A new version of server notifications isn’t available for testing yet. Once it’s available, it will be possible to specify different URLs for Production and Sandbox notifications. You can choose V2 for Sandbox, and V1 for Production for testing.
Also, App Store Connect now allows to:
- Clear purchase history for the Sandbox user, meaning that you don’t have to create a new account to do that anymore.
- Change the store country for the Sandbox user.
- Change Sandbox subscription renewal period, for example, you can make a monthly purchase that lasts 1 hour instead of 5 minutes.
Conclusion
Apple significantly improved working with in-app purchases and subscriptions on the server-side. From my point of view, here’re the most useful new features:
- Full-fledged promo offer and offer code support;
- Simpler and more informative server notifications;
- An opportunity to learn about the current subscription’s status with a simple API call;
- Clearing purchase history of the user’s Sandbox.
Switching to a new API won’t be hard, it’s enough to get originalTransactionId for every receipt. It’s likely it’s already contained in your base.
Anyway, the hardest part of integrating subscriptions into a mobile app is building an analytics system and optimizing the economy. Adapty can solve these problems well:
- In-built analytics allows us to understand the main app’s metrics quickly.
- Cohort analysis helps to understand if there are any problems with economy.
- A/B testing increases the app’s profitability.
- Integrations with external systems allow to send transactions to attribution and product analytics services.
- Promo campaigns reduce audience loss.
- Open-source SDK allows to integrate subscriptions into an app within several hours.
Learn more about these features, to implement subscriptions into your app faster and monetize your app sooner and better.