Skip to content

Commit 6a0c8f6

Browse files
Merge pull request #134 from alexanderjordanbaker/ASSA1.15andASSN2.15
Update to support App Store Server API 1.15 and App Store Server Noti…
2 parents 275a1e0 + ce2e993 commit 6a0c8f6

12 files changed

+366
-4
lines changed

appstoreserverlibrary/api_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,13 @@ class APIError(IntEnum):
319319
https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror
320320
"""
321321

322+
APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048
323+
"""
324+
An error that indicates the endpoint doesn't support an app transaction ID.
325+
326+
https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror
327+
"""
328+
322329
SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004
323330
"""
324331
An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Copyright (c) 2025 Apple Inc. Licensed under MIT License.
2+
3+
import datetime
4+
from typing import Any, Dict, Optional
5+
import base64
6+
import json
7+
import jwt
8+
import uuid
9+
10+
from cryptography.hazmat.backends import default_backend
11+
from cryptography.hazmat.primitives import serialization
12+
13+
from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter
14+
15+
class AdvancedCommerceAPIInAppRequest:
16+
def __init__(self):
17+
pass
18+
19+
class JWSSignatureCreator:
20+
def __init__(self, audience: str, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
21+
self._audience = audience
22+
self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend())
23+
self._key_id = key_id
24+
self._issuer_id = issuer_id
25+
self._bundle_id = bundle_id
26+
27+
def _create_signature(self, feature_specific_claims: Dict[str, Any]) -> str:
28+
claims = feature_specific_claims
29+
current_time = datetime.datetime.now(datetime.timezone.utc)
30+
31+
claims["bid"] = self._bundle_id
32+
claims["iss"] = self._issuer_id
33+
claims["aud"] = self._audience
34+
claims["iat"] = current_time
35+
claims["nonce"] = str(uuid.uuid4())
36+
37+
return jwt.encode(claims,
38+
self._signing_key,
39+
algorithm="ES256",
40+
headers={"kid": self._key_id},
41+
)
42+
43+
class PromotionalOfferV2SignatureCreator(JWSSignatureCreator):
44+
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
45+
"""
46+
Create a PromotionalOfferV2SignatureCreator
47+
48+
:param signing_key: Your private key downloaded from App Store Connect
49+
:param key_id: Your private key ID from App Store Connect
50+
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
51+
:param bundle_id: Your app's bundle ID
52+
"""
53+
super().__init__(audience="promotional-offer", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)
54+
55+
def create_signature(self, product_id: str, offer_identifier: str, transaction_id: Optional[str]) -> str:
56+
"""
57+
Create a promotional offer V2 signature.
58+
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests
59+
60+
:param product_id: The unique identifier of the product
61+
:param offer_identifier: The promotional offer identifier that you set up in App Store Connect
62+
:param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended.
63+
:return: The signed JWS.
64+
"""
65+
if product_id is None:
66+
raise ValueError("product_id cannot be null")
67+
if offer_identifier is None:
68+
raise ValueError("offer_identifier cannot be null")
69+
feature_specific_claims = {
70+
"productId": product_id,
71+
"offerIdentifier": offer_identifier
72+
}
73+
if transaction_id is not None:
74+
feature_specific_claims["transactionId"] = transaction_id
75+
return self._create_signature(feature_specific_claims=feature_specific_claims)
76+
77+
class IntroductoryOfferEligibilitySignatureCreator(JWSSignatureCreator):
78+
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
79+
"""
80+
Create an IntroductoryOfferEligibilitySignatureCreator
81+
82+
:param signing_key: Your private key downloaded from App Store Connect
83+
:param key_id: Your private key ID from App Store Connect
84+
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
85+
:param bundle_id: Your app's bundle ID
86+
"""
87+
super().__init__(audience="introductory-offer-eligibility", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)
88+
89+
def create_signature(self, product_id: str, allow_introductory_offer: bool, transaction_id: str) -> str:
90+
"""
91+
Create an introductory offer eligibility signature.
92+
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests
93+
94+
:param product_id: The unique identifier of the product
95+
:param allow_introductory_offer: A boolean value that determines whether the customer is eligible for an introductory offer
96+
:param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app.
97+
:return: The signed JWS.
98+
"""
99+
if product_id is None:
100+
raise ValueError("product_id cannot be null")
101+
if allow_introductory_offer is None:
102+
raise ValueError("allow_introductory_offer cannot be null")
103+
if transaction_id is None:
104+
raise ValueError("transaction_id cannot be null")
105+
feature_specific_claims = {
106+
"productId": product_id,
107+
"allowIntroductoryOffer": allow_introductory_offer,
108+
"transactionId": transaction_id
109+
}
110+
return self._create_signature(feature_specific_claims=feature_specific_claims)
111+
112+
class AdvancedCommerceAPIInAppSignatureCreator(JWSSignatureCreator):
113+
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
114+
"""
115+
Create an AdvancedCommerceAPIInAppSignatureCreator
116+
117+
:param signing_key: Your private key downloaded from App Store Connect
118+
:param key_id: Your private key ID from App Store Connect
119+
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
120+
:param bundle_id: Your app's bundle ID
121+
"""
122+
super().__init__(audience="advanced-commerce-api", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)
123+
124+
def create_signature(self, advanced_commerce_in_app_request: AdvancedCommerceAPIInAppRequest) -> str:
125+
"""
126+
Create an Advanced Commerce in-app signed request.
127+
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests
128+
129+
:param advanced_commerce_in_app_request: The request to be signed.
130+
:return: The signed JWS.
131+
"""
132+
if advanced_commerce_in_app_request is None:
133+
raise ValueError("advanced_commerce_in_app_request cannot be null")
134+
c = _get_cattrs_converter(type(advanced_commerce_in_app_request))
135+
request = c.unstructure(advanced_commerce_in_app_request)
136+
encoded_request = base64.b64encode(json.dumps(request).encode(encoding='utf-8')).decode('utf-8')
137+
feature_specific_claims = {
138+
"request": encoded_request
139+
}
140+
return self._create_signature(feature_specific_claims=feature_specific_claims)

appstoreserverlibrary/models/AppTransaction.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .LibraryUtility import AttrsRawValueAware
88

99
from .Environment import Environment
10+
from .PurchasePlatform import PurchasePlatform
1011

1112
@define
1213
class AppTransaction(AttrsRawValueAware):
@@ -96,4 +97,23 @@ class AppTransaction(AttrsRawValueAware):
9697
The date the customer placed an order for the app before it's available in the App Store.
9798
9899
https://developer.apple.com/documentation/storekit/apptransaction/4013175-preorderdate
100+
"""
101+
102+
appTransactionId: Optional[str] = attr.ib(default=None)
103+
"""
104+
The unique identifier of the app download transaction.
105+
106+
https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid
107+
"""
108+
109+
originalPlatform: Optional[PurchasePlatform] = PurchasePlatform.create_main_attr('rawOriginalPlatform')
110+
"""
111+
The platform on which the customer originally purchased the app.
112+
113+
https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz
114+
"""
115+
116+
rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform')
117+
"""
118+
See originalPlatform
99119
"""

appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,25 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware):
174174
An array of win-back offer identifiers that a customer is eligible to redeem, which sorts the identifiers to present the better offers first.
175175
176176
https://developer.apple.com/documentation/appstoreserverapi/eligiblewinbackofferids
177+
"""
178+
179+
appAccountToken: Optional[str] = attr.ib(default=None)
180+
"""
181+
The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction.
182+
183+
https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken
184+
"""
185+
186+
appTransactionId: Optional[str] = attr.ib(default=None)
187+
"""
188+
The unique identifier of the app download transaction.
189+
190+
https://developer.apple.com/documentation/appstoreserverapi/appTransactionId
191+
"""
192+
193+
offerPeriod: Optional[str] = attr.ib(default=None)
194+
"""
195+
The duration of the offer.
196+
197+
https://developer.apple.com/documentation/appstoreserverapi/offerPeriod
177198
"""

appstoreserverlibrary/models/JWSTransactionDecodedPayload.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,18 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware):
237237
rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType')
238238
"""
239239
See offerDiscountType
240+
"""
241+
242+
appTransactionId: Optional[str] = attr.ib(default=None)
243+
"""
244+
The unique identifier of the app download transaction.
245+
246+
https://developer.apple.com/documentation/appstoreserverapi/appTransactionId
247+
"""
248+
249+
offerPeriod: Optional[str] = attr.ib(default=None)
250+
"""
251+
The duration of the offer.
252+
253+
https://developer.apple.com/documentation/appstoreserverapi/offerPeriod
240254
"""
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright (c) 2025 Apple Inc. Licensed under MIT License.
2+
3+
from enum import Enum
4+
5+
from .LibraryUtility import AppStoreServerLibraryEnumMeta
6+
7+
class PurchasePlatform(str, Enum, metaclass=AppStoreServerLibraryEnumMeta):
8+
"""
9+
Values that represent Apple platforms.
10+
11+
https://developer.apple.com/documentation/storekit/appstore/platform
12+
"""
13+
IOS = "iOS"
14+
MAC_OS = "macOS"
15+
TV_OS = "tvOS"
16+
VISION_OS = "visionOS"

tests/resources/models/appTransaction.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@
99
"originalApplicationVersion": "1.1.2",
1010
"deviceVerification": "device_verification_value",
1111
"deviceVerificationNonce": "48ccfa42-7431-4f22-9908-7e88983e105a",
12-
"preorderDate": 1698148700000
12+
"preorderDate": 1698148700000,
13+
"appTransactionId": "71134",
14+
"originalPlatform": "iOS"
1315
}

tests/resources/models/signedRenewalInfo.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@
1919
"eligibleWinBackOfferIds": [
2020
"eligible1",
2121
"eligible2"
22-
]
22+
],
23+
"appTransactionId": "71134",
24+
"offerPeriod": "P1Y",
25+
"appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138"
2326
}

tests/resources/models/signedTransaction.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@
2424
"storefrontId":"143441",
2525
"price": 10990,
2626
"currency": "USD",
27-
"offerDiscountType": "PAY_AS_YOU_GO"
27+
"offerDiscountType": "PAY_AS_YOU_GO",
28+
"appTransactionId": "71134",
29+
"offerPeriod": "P1Y"
2830
}

tests/test_decoded_payloads.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from appstoreserverlibrary.models.OfferDiscountType import OfferDiscountType
1212
from appstoreserverlibrary.models.OfferType import OfferType
1313
from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus
14+
from appstoreserverlibrary.models.PurchasePlatform import PurchasePlatform
1415
from appstoreserverlibrary.models.RevocationReason import RevocationReason
1516
from appstoreserverlibrary.models.Status import Status
1617
from appstoreserverlibrary.models.Subtype import Subtype
@@ -38,6 +39,9 @@ def test_app_transaction_decoding(self):
3839
self.assertEqual("device_verification_value", app_transaction.deviceVerification)
3940
self.assertEqual("48ccfa42-7431-4f22-9908-7e88983e105a", app_transaction.deviceVerificationNonce)
4041
self.assertEqual(1698148700000, app_transaction.preorderDate)
42+
self.assertEqual("71134", app_transaction.appTransactionId)
43+
self.assertEqual(PurchasePlatform.IOS, app_transaction.originalPlatform)
44+
self.assertEqual("iOS", app_transaction.rawOriginalPlatform)
4145

4246
def test_transaction_decoding(self):
4347
signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransaction.json')
@@ -79,6 +83,8 @@ def test_transaction_decoding(self):
7983
self.assertEqual("USD", transaction.currency)
8084
self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, transaction.offerDiscountType)
8185
self.assertEqual("PAY_AS_YOU_GO", transaction.rawOfferDiscountType)
86+
self.assertEqual("71134", transaction.appTransactionId)
87+
self.assertEqual("P1Y", transaction.offerPeriod)
8288

8389

8490
def test_renewal_info_decoding(self):
@@ -112,6 +118,9 @@ def test_renewal_info_decoding(self):
112118
self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, renewal_info.offerDiscountType)
113119
self.assertEqual("PAY_AS_YOU_GO", renewal_info.rawOfferDiscountType)
114120
self.assertEqual(['eligible1', 'eligible2'], renewal_info.eligibleWinBackOfferIds)
121+
self.assertEqual("71134", renewal_info.appTransactionId)
122+
self.assertEqual("P1Y", renewal_info.offerPeriod)
123+
self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", renewal_info.appAccountToken)
115124

116125
def test_notification_decoding(self):
117126
signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json')

0 commit comments

Comments
 (0)